Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@bonniernews/local-esi

Package Overview
Dependencies
Maintainers
8
Versions
73
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@bonniernews/local-esi - npm Package Compare versions

Comparing version 1.2.9 to 2.0.0

CHANGELOG.md

142

index.js
"use strict";
const ESIEvaluator = require("./lib/ESIEvaluator");
const ListenerContext = require("./lib/ListenerContext");
const {asStream, transform, createESIParser} = require("./lib/transformHtml");
const { pipeline, Readable } = require("stream");
const ESI = require("./lib/ESI");
const HTMLStream = require("@bonniernews/atlas-html-stream");
const HTMLWriter = require("./lib/HTMLWriter");
const redirectCodes = [301, 302, 303, 307, 308];
module.exports = {
ESI,
HTMLWriter,
parse,
};
module.exports = localEsi;
module.exports.createStream = streaming;
module.exports.createParser = createParser;
module.exports.htmlWriter = require("./lib/htmlWriter");
function parse(html, options) {
const response = {};
function localEsi(html, req, res, next) {
const context = ListenerContext(req);
let completed = false;
let body = "";
context.on("set_response_code", (statusCode, body) => {
completed = true;
res.status(statusCode).send(body === undefined ? "" : body);
});
context.on("add_header", (name, value) => {
if (name.toLowerCase() === "set-cookie") {
const cookie = parseCookie(value);
if (cookie) {
res.cookie(cookie.name, cookie.value, cookie.attributes);
}
} else {
res.set(name, value);
}
});
context.once("set_redirect", (statusCode, location) => {
completed = true;
res.redirect(location);
});
const esi = new ESI(options)
.on("set_response_code", onSetResponseCode)
.on("add_header", onAddHeader)
.once("set_redirect", onRedirect);
const listener = ESIEvaluator(context);
return transform(html, listener, (err, parsed) => {
if (err) return next(err);
if (!completed) res.send(parsed);
return new Promise((resolve, reject) => {
pipeline([
Readable.from(html),
new HTMLStream({ preserveWS: true }),
esi,
new HTMLWriter(),
], (err) => {
if (err && ![ "ERR_STREAM_DESTROYED", "ERR_STREAM_PREMATURE_CLOSE" ].includes(err.code)) return reject(err);
resolve({
body,
...response,
});
}).on("data", (chunk) => {
body += chunk;
});
});
}
function streaming(req) {
const context = ListenerContext(req);
const listener = ESIEvaluator(context);
const pipeline = asStream(listener);
context.emitter = pipeline;
let responseCode;
const headers = {};
pipeline
.on("set_response_code", onResponseCode)
.on("add_header", onAddHeader)
.once("set_redirect", close);
return pipeline;
function onResponseCode(int, body) {
responseCode = int;
if (int > 399 || body) return close();
if (headers.location && redirectCodes.includes(int)) pipeline.emit("set_redirect", responseCode, headers.location);
function onRedirect(statusCode, location) {
response.statusCode = statusCode;
if (location) {
response.headers = response.headers || {};
response.headers.location = location;
}
this.destroy();
}
function onAddHeader(name, value) {
const headerName = name.toLowerCase();
headers[headerName] = value;
if (headerName === "location" && redirectCodes.includes(responseCode)) pipeline.emit("set_redirect", responseCode, value);
const headers = response.headers = response.headers || {};
const lname = name.toLowerCase();
if (lname === "set-cookie") {
headers[lname] = headers[lname] || [];
headers[lname].push(value);
} else {
headers[lname] = value;
}
}
function close() {
pipeline
.removeListener("set_response_code", onResponseCode)
.removeListener("add_header", onAddHeader)
.removeListener("set_redirect", close)
.destroy();
function onSetResponseCode(statusCode, withBody) {
response.statusCode = statusCode;
if (!withBody) return;
response.body = withBody;
this.destroy();
}
}
function createParser(req) {
const context = ListenerContext(req);
const listener = ESIEvaluator(context);
const optimusPrime = createESIParser(listener);
context.emitter = optimusPrime;
return optimusPrime;
}
function parseCookie(cookieStr) {
const attrs = (cookieStr || "").split(";");
const [name, value] = attrs[0].split("=");
if (!name || !value) return;
const attributes = attrs.reduce((acc, attr, index) => {
if (index > 0) {
const [attrName, attrValue] = attr.split("=");
acc[attrName.trim()] = attrValue && attrValue.trim() || "";
}
return acc;
}, {});
return {
name,
value,
attributes
};
}

@@ -0,256 +1,222 @@

/* eslint-disable no-use-before-define */
"use strict";
const HtmlParser = require("@bonniernews/atlas-html-stream");
const request = require("got");
const url = require("url");
const {assign, test, replace} = require("./evaluateExpression");
const {convert, createESIParser} = require("./transformHtml");
const {chunkToMarkup} = require("./markup");
const {Readable} = require("stream");
const { assign, test, replace } = require("./evaluateExpression");
const { pipeline, Readable } = require("stream");
const ESIBase = require("./ESIBase");
const HTMLStream = require("@bonniernews/atlas-html-stream");
module.exports = function ESIEvaluator(context) {
const esiTags = {};
class ESITag {
constructor(context) {
this.context = context;
}
open(data, next) {
next();
}
close(next) {
next();
}
}
esiTags["esi:except"] = {
open(data, next) {
context.inExcept = true;
next();
},
close(next) {
context.inExcept = false;
next();
}
};
class ESIAttempt extends ESITag {
open(data, next) {
this.context.inAttempt = true;
next();
}
}
esiTags["esi:choose"] = {
open(data, next) {
context.chooses.push({ hasEvaluatedToTrue: false, isCurrentlyEvaluatedTo: false });
class ESIExcept extends ESITag {
open(data, next) {
this.context.inExcept = true;
next();
}
close(next) {
this.context.inExcept = false;
next();
}
}
return next();
},
close(next) {
context.chooses.pop();
class ESIChoose extends ESITag {
open(data, next) {
this.context.chooses.push({ hasEvaluatedToTrue: false, isCurrentlyEvaluatedTo: false });
return next();
}
close(next) {
this.context.chooses.pop();
return next();
}
}
return next();
class ESIWhen extends ESITag {
open(data, next) {
const context = this.context;
const lastChoose = context.chooses[context.chooses.length - 1];
const result = test(data.test, context);
if (data.matchname) {
context.assigns[data.matchname] = result;
}
};
esiTags["esi:assign"] = {
open(data, next) {
if (!shouldWrite()) {
return next();
}
lastChoose.isCurrentlyEvaluatedTo = !lastChoose.isCurrentlyEvaluatedTo && result;
lastChoose.hasEvaluatedToTrue = lastChoose.hasEvaluatedToTrue || result;
const value = data.value;
try {
context.assigns[data.name] = assign(value, context);
} catch (err) {
if (/unknown keyword/i.test(err.message)) context.assigns[data.name] = value;
else return next(err);
}
return next();
}
}
next();
}
};
class ESIOtherwise extends ESITag {
open(data, next) {
const context = this.context;
const lastChoose = context.chooses[context.chooses.length - 1];
lastChoose.isCurrentlyEvaluatedTo = !lastChoose.hasEvaluatedToTrue;
return next();
}
}
esiTags["esi:vars"] = {
open(data, next) {
next();
},
close(next) {
next();
}
};
class ESIText extends ESITag {
get plainText() {
return true;
}
}
esiTags["esi:include"] = {
open(data, next) {
if (!shouldWrite()) return next();
fetchIncluded(data, (fetchError, fetchResult) => {
if (fetchError) {
return next(fetchError);
}
const listener = ESIEvaluator(context.clone());
const chunks = [];
let pipeline = convert(fetchResult).pipe(new HtmlParser({ preserveWS: true }));
if (data.dca === "esi") {
pipeline = pipeline.pipe(createESIParser(listener));
}
pipeline.on("data", (chunk) => chunks.push(chunk))
.on("finish", () => {
writeToResult(chunks, next);
})
.on("error", next);
});
class ESIAssign extends ESITag {
open(data, next) {
const context = this.context;
if (!context.shouldWrite()) {
return next();
}
};
esiTags["esi:eval"] = {
open(data, next) {
if (!shouldWrite()) return next();
fetchIncluded(data, (fetchError, fetchResult) => {
if (fetchError) {
return next(fetchError);
}
const listener = ESIEvaluator(context.clone(true));
const chunks = [];
convert(fetchResult)
.pipe(new HtmlParser({ preserveWS: true }))
.pipe(createESIParser(listener))
.on("data", (chunk) => chunks.push(chunk))
.on("finish", () => {
writeToResult(chunks, next);
})
.on("error", next);
});
const value = data.value;
try {
context.assigns[data.name] = assign(value, context);
} catch (err) {
if (/unknown keyword/i.test(err.message)) context.assigns[data.name] = value;
else return next(err);
}
};
esiTags["esi:try"] = {
open(data, next) {
next();
}
};
next();
}
}
esiTags["esi:text"] = {
plainText: true,
open(data, next) {
next();
},
close(next) {
next();
}
};
class ESIBreak extends ESITag {
open(data, next) {
const context = this.context;
if (!context.inForeach) return next(new Error("esi:break outside esi:foreach"));
context.breakHit = context.breakHit || context.shouldWrite();
return context.breakHit ? next(null, { name: "esi:break" }) : next();
}
}
esiTags["esi:attempt"] = {
open(data, next) {
context.inAttempt = true;
next();
}
};
class ESIEval extends ESITag {
open(data, next) {
const context = this.context;
if (!context.shouldWrite()) return next();
esiTags["esi:when"] = {
open(data, next) {
const lastChoose = context.chooses[context.chooses.length - 1];
const result = test(data.test, context);
if (data.matchname) {
context.assigns[data.matchname] = result;
const chunks = [];
pipeline([
context.fetch(data),
new HTMLStream({ preserveWS: true }),
new ESIBase(new ESIEvaluator(context.clone(true))),
], (err) => {
if (err) {
if (err.inAttempt) return next();
return next(err);
}
return context.writeToResult(chunks, next);
}).on("data", (chunk) => chunks.push(chunk));
}
}
lastChoose.isCurrentlyEvaluatedTo = !lastChoose.isCurrentlyEvaluatedTo && result;
lastChoose.hasEvaluatedToTrue = lastChoose.hasEvaluatedToTrue || result;
class ESIInclude extends ESITag {
open(data, next) {
const context = this.context;
if (!context.shouldWrite()) return next();
return next();
},
close(next) {
next();
const chunks = [];
const streams = [
context.fetch(data),
new HTMLStream({ preserveWS: true }),
];
if (data.dca === "esi") {
streams.push(new ESIBase(new ESIEvaluator(context.clone())));
}
};
pipeline(streams, (err) => {
if (err) {
if (err.inAttempt) return next();
return next(err);
}
return context.writeToResult(chunks, next);
}).on("data", (chunk) => chunks.push(chunk));
}
}
esiTags["esi:otherwise"] = {
open(data, next) {
const lastChoose = context.chooses[context.chooses.length - 1];
lastChoose.isCurrentlyEvaluatedTo = !lastChoose.hasEvaluatedToTrue;
return next();
},
close(next) {
next();
class ESIForEach extends ESITag {
open(data, next) {
const context = this.context;
context.items = assign(data.collection, context);
if (!Array.isArray(context.items)) {
context.items = Object.entries(context.items);
}
};
esiTags["esi:foreach"] = {
open(data, next) {
context.items = assign(data.collection, context);
if (!Array.isArray(context.items)) {
context.items = Object.entries(context.items);
}
context.foreachChunks = [];
return next();
}
close(next) {
const context = this.context;
const foreachChunks = context.foreachChunks;
delete context.foreachChunks;
context.foreachChunks = [];
return next();
},
close(next) {
const foreachChunks = context.foreachChunks;
delete context.foreachChunks;
let buffered = [];
let buffered = [];
context.items.forEach((value) => {
if (Array.isArray(value)) value = `[${value.map((v) => typeof v === "string" ? `'${v}'` : v).join(",")}]`;
buffered = buffered.concat([ {
name: "esi:assign", data: { name: "item", value: value.toString() },
}, { name: "esi:assign" } ], foreachChunks);
});
context.items.forEach((value) => {
if (Array.isArray(value)) value = `[${value.map((v) => typeof v === "string" ? `'${v}'` : v).join(",")}]`;
buffered = buffered.concat([{
name: "esi:assign", data: {name: "item", value: value.toString()}
}, {name: "esi:assign"}], foreachChunks);
});
const localContext = context.subContext();
localContext.inForeach = true;
const chunks = [];
const localContext = context.subContext();
localContext.inForeach = true;
const listener = ESIEvaluator(localContext);
const chunks = [];
createChunkStream(buffered)
.pipe(createESIParser(listener))
.on("data", function onData(chunk) {
if (chunk.name === "esi:break") {
this.pause();
return process.nextTick(() => this.destroy());
}
chunks.push(chunk);
})
.on("finish", complete)
.on("error", next);
function complete() {
writeToResult(chunks, next);
pipeline([
Readable.from(buffered),
new ESIBase(new ESIEvaluator(localContext)),
], (err) => {
if (err) return next(err);
return context.writeToResult(chunks, next);
}).on("data", function onData(chunk) {
if (chunk.name === "esi:break") {
this.pause();
return process.nextTick(() => this.push(null));
}
}
};
esiTags["esi:break"] = {
open(data, next) {
if (!context.inForeach) return next(new Error("esi:break outside esi:foreach"));
context.breakHit = context.breakHit || shouldWrite();
return context.breakHit ? next(null, {name: "esi:break"}) : next();
}
};
chunks.push(chunk);
});
}
}
async function fetchIncluded(data, fetchCallback) {
const options = {
throwHttpErrors: false,
retry: 0,
headers: Object.assign({}, context.req.headers)
};
const EsiTags = {
"esi:assign": ESIAssign,
"esi:attempt": ESIAttempt,
"esi:break": ESIBreak,
"esi:choose": ESIChoose,
"esi:except": ESIExcept,
"esi:otherwise": ESIOtherwise,
"esi:text": ESIText,
"esi:try": ESITag,
"esi:vars": ESITag,
"esi:when": ESIWhen,
"esi:eval": ESIEval,
"esi:include": ESIInclude,
"esi:foreach": ESIForEach,
};
options.headers.host = undefined;
delete options.headers["content-type"];
let includeUrl = replace(data.src, context);
if (!includeUrl.startsWith("http")) {
includeUrl = url.resolve(`http://localhost:${context.req.socket.server.address().port}`, includeUrl);
}
try {
const response = await request.get(includeUrl, options);
if (response.statusCode > 399) {
throw new Error(`Response code: ${response.statusCode}`);
}
return fetchCallback(null, response.body);
} catch (err) {
if (context.inAttempt) {
context.lastAttemptWasError = true;
return fetchCallback(null, "");
}
return fetchCallback(err);
}
class ESIEvaluator {
constructor(context) {
this.context = context;
}
return {
onopentag,
ontext,
onclosetag,
};
function onopentag(name, data, next) {
onopentag(name, data, next) {
const context = this.context;
if (context.foreachChunks) {
context.foreachChunks.push({name, data});
context.foreachChunks.push({ name, data });
return next();

@@ -260,8 +226,9 @@ }

if (name.startsWith("esi:")) {
const esiFunc = esiTags[name];
const Tag = EsiTags[name];
const wasInPlainText = context.isInPlainText();
if (!esiFunc && !wasInPlainText) {
if (!Tag && !wasInPlainText) {
throw new Error(`ESI tag ${name} not implemented.`);
}
let esiFunc;
if (Tag) esiFunc = new Tag(context);
context.tags.push(esiFunc);

@@ -271,8 +238,8 @@ if (!wasInPlainText) return esiFunc.open(data, next);

writeToResult({name, data: makeAttributes(data)}, next);
context.writeToResult({ name, data: this.makeAttributes(data) }, next);
}
function onclosetag(name, next) {
onclosetag(name, next) {
const context = this.context;
if (name !== "esi:foreach" && context.foreachChunks) {
context.foreachChunks.push({name});
context.foreachChunks.push({ name });
return next();

@@ -290,8 +257,8 @@ }

writeToResult({name}, next);
context.writeToResult({ name }, next);
}
function ontext(text, next) {
ontext(text, next) {
const context = this.context;
if (context.foreachChunks) {
context.foreachChunks.push({text});
context.foreachChunks.push({ text });
return next();

@@ -301,3 +268,3 @@ }

if (!context.isProcessing()) {
return writeToResult({text}, next);
return context.writeToResult({ text }, next);
}

@@ -311,7 +278,7 @@

try {
return writeToResult((currentContext) => {
const result = {text: replace(text, currentContext || context)};
return context.writeToResult((currentContext) => {
const result = { text: replace(text, currentContext || context) };
context.bufferingString = false;
return result;
}, next); //handleProcessingInstructions may cause an (expected) error and we're not sure writeToResult will actually write so we pass a function that it can call if it should write
}, next); // handleProcessingInstructions may cause an (expected) error and we're not sure writeToResult will actually write so we pass a function that it can call if it should write
} catch (err) {

@@ -327,6 +294,6 @@ if (err.message.includes("Found end of file before end")) {

}
function makeAttributes(data) {
makeAttributes(data) {
if (!data) return {};
const context = this.context;
return Object.keys(data).reduce((attributes, key) => {

@@ -341,51 +308,4 @@ let value = data[key];

}
}
function writeToResult(chunk, next) {
if (context.bufferingString) {
const [current = {}] = context.tags.slice(-1);
if (typeof chunk === "function") {
chunk = chunk();
}
current.text += chunkToMarkup(chunk);
return next();
}
if (shouldWrite()) {
if (typeof chunk === "function") {
chunk = chunk();
}
return next(null, chunk);
}
next();
}
function shouldWrite() {
if (context.inExcept && !context.lastAttemptWasError) return false;
if (context.breakHit) return false;
if (context.chooses.length) {
return context.chooses.every((choose) => choose.isCurrentlyEvaluatedTo);
}
return true;
}
};
function createChunkStream(chunks) {
const reader = new Readable({objectMode: true});
reader._read = function () {
if (!chunks.length) return reader.push(null);
while (reader.push(chunks.shift())) {
if (!chunks.length) {
reader.push(null);
break;
}
}
};
return reader;
}
module.exports = ESIEvaluator;
"use strict";
const evaluate = require("./expression/evaluate");
const {parse, split} = require("./expression/parser");
const { parse, split } = require("./expression/parser");

@@ -53,9 +53,9 @@ module.exports = {

text = text.replace(/(^|[^\\])(\\)($|[^\\])/ig, (_, group1, _2, group3) => { //Remove backslashes, but not escaped ones
text = text.replace(/(^|[^\\])(\\)($|[^\\])/ig, (_, group1, _2, group3) => { // Remove backslashes, but not escaped ones
return `${group1}${group3}`;
});
text = text.replace(/\\\\/g, "\\"); //Escaped backslashes, remove the escaping backslash
text = text.replace(/\\\\/g, "\\"); // Escaped backslashes, remove the escaping backslash
return text;
}

@@ -6,187 +6,191 @@ /* eslint-disable camelcase */

module.exports = function evaluate(ast, context) {
const funcs = {
exists([arg]) {
return !!getFunc(arg.type)(arg);
},
int([arg]) {
return parseInt(getFunc(arg.type)(arg)) || 0;
},
index([arg1, arg2]) {
return getFunc(arg1.type)(arg1).indexOf(getFunc(arg2.type)(arg2));
},
base64_decode([arg]) {
const string = getFunc(arg.type)(arg);
if (!string) {
return "";
}
return Buffer.from(string, "base64").toString("utf8");
},
base64_encode([arg]) {
const string = getFunc(arg.type)(arg);
if (!string) {
return "";
}
return Buffer.from(string, "utf8").toString("base64");
},
digest_md5([arg]) {
const string = getFunc(arg.type)(arg);
if (!string) {
return [];
}
class Evaluator {
constructor(context) {
this.context = context;
}
exists([ arg ]) {
return !!this.execute(arg.type, arg);
}
int([ arg ]) {
return parseInt(this.execute(arg.type, arg)) || 0;
}
index([ arg1, arg2 ]) {
return this.execute(arg1.type, arg1).indexOf(this.execute(arg2.type, arg2));
}
base64_decode([ arg ]) {
const string = this.execute(arg.type, arg);
if (!string) {
return "";
}
return Buffer.from(string, "base64").toString("utf8");
}
base64_encode([ arg ]) {
const string = this.execute(arg.type, arg);
if (!string) {
return "";
}
return Buffer.from(string, "utf8").toString("base64");
}
digest_md5([ arg ]) {
const string = this.execute(arg.type, arg);
if (!string) {
return [];
}
const md5 = crypto.createHash("md5").update(string).digest();
const esihash = [];
for (let offset = 0; offset < 16; offset += 4) {
esihash.push(md5.readInt32LE(offset));
}
const md5 = crypto.createHash("md5").update(string).digest();
const esihash = [];
for (let offset = 0; offset < 16; offset += 4) {
esihash.push(md5.readInt32LE(offset));
}
return esihash;
},
url_encode([arg]) {
const string = getFunc(arg.type)(arg);
if (!string) {
return "";
}
return encodeURIComponent(string);
},
add_header([name, value]) {
context.emit("add_header", getFunc(name.type)(name), getFunc(value.type)(value));
},
set_redirect([location]) {
context.emit("set_redirect", 302, getFunc(location.type)(location));
context.redirected = true;
},
set_response_code([code, body]) {
if (body) {
return context.emit("set_response_code", getFunc(code.type)(code), getFunc(body.type)(body));
}
return esihash;
}
url_encode([ arg ]) {
const string = this.execute(arg.type, arg);
if (!string) {
return "";
}
return encodeURIComponent(string);
}
add_header([ name, value ]) {
this.context.emitter.emit("add_header", this.execute(name.type, name), this.execute(value.type, value));
}
set_redirect([ location ]) {
this.context.emitter.emit("set_redirect", 302, this.execute(location.type, location));
this.context.redirected = true;
}
set_response_code([ code, body ]) {
if (body) {
return this.context.emitter.emit("set_response_code", this.execute(code.type, code), this.execute(body.type, body));
}
context.emit("set_response_code", getFunc(code.type)(code));
},
str([arg]) {
const value = getFunc(arg.type)(arg);
return (typeof value === "undefined") ? "None" : String(value);
},
substr([arg1, arg2, arg3]) {
const string = getFunc(arg1.type)(arg1);
if (typeof string !== "string") {
throw new Error("substr invoked on non-string");
}
let startIndex;
let length;
this.context.emitter.emit("set_response_code", this.execute(code.type, code));
}
str([ arg ]) {
const value = this.execute(arg.type, arg);
return (typeof value === "undefined") ? "None" : String(value);
}
substr([ arg1, arg2, arg3 ]) {
const string = this.execute(arg1.type, arg1);
if (typeof string !== "string") {
throw new Error("substr invoked on non-string");
}
let startIndex;
let length;
if (arg2) {
startIndex = getFunc(arg2.type)(arg2);
}
if (arg2) {
startIndex = this.execute(arg2.type, arg2);
}
if (typeof startIndex !== "number") {
throw new Error("substr invoked with non-number as start index");
}
if (typeof startIndex !== "number") {
throw new Error("substr invoked with non-number as start index");
}
if (arg3) {
length = getFunc(arg3.type)(arg3);
}
if (arg3) {
length = this.execute(arg3.type, arg3);
}
if (length < 0) {
length = string.length - startIndex + length;
}
return string.substr(startIndex, length);
},
time() {
return Math.round(Date.now() / 1000);
},
http_time([seconds]) {
const secondsInt = parseInt(getFunc(seconds.type)(seconds));
const now = new Date(secondsInt * 1000);
return now.toUTCString();
},
BinaryExpression(node) {
const left = getFunc(node.left.type)(node.left);
const right = getFunc(node.right.type)(node.right);
if (length < 0) {
length = string.length - startIndex + length;
}
return string.substr(startIndex, length);
}
time() {
return Math.round(Date.now() / 1000);
}
http_time([ seconds ]) {
const secondsInt = parseInt(this.execute(seconds.type, seconds));
const now = new Date(secondsInt * 1000);
return now.toUTCString();
}
BinaryExpression(node) {
const left = this.execute(node.left.type, node.left);
const right = this.execute(node.right.type, node.right);
if (node.operator === "==") return left === castRight(left, right);
if (node.operator === "!=") return left !== castRight(left, right);
if (node.operator === ">=") return left >= castRight(left, right);
if (node.operator === "<=") return left <= castRight(left, right);
if (node.operator === "<") return left < castRight(left, right);
if (node.operator === ">") return left > castRight(left, right);
if (node.operator === "+") return left + right;
if (node.operator === "-") return left - right;
if (node.operator === "*") return left * right;
if (node.operator === "/") return left / right;
if (node.operator === "%") return left % right;
if (node.operator === "has") return castString(left).indexOf(castString(right)) > -1;
if (node.operator === "has_i") return castString(left).toLowerCase().indexOf(castString(right).toLowerCase()) > -1;
if (node.operator === "matches") {
if (!left) {
return;
}
return left.match(right);
if (node.operator === "==") return left === castRight(left, right);
if (node.operator === "!=") return left !== castRight(left, right);
if (node.operator === ">=") return left >= castRight(left, right);
if (node.operator === "<=") return left <= castRight(left, right);
if (node.operator === "<") return left < castRight(left, right);
if (node.operator === ">") return left > castRight(left, right);
if (node.operator === "+") return left + right;
if (node.operator === "-") return left - right;
if (node.operator === "*") return left * right;
if (node.operator === "/") return left / right;
if (node.operator === "%") return left % right;
if (node.operator === "has") return castString(left).indexOf(castString(right)) > -1;
if (node.operator === "has_i") return castString(left).toLowerCase().indexOf(castString(right).toLowerCase()) > -1;
if (node.operator === "matches") {
if (!left) {
return;
}
if (node.operator === "matches_i") {
if (!left) {
return;
}
return left.match(new RegExp(right, "i"));
return left.match(right);
}
if (node.operator === "matches_i") {
if (!left) {
return;
}
return left.match(new RegExp(right, "i"));
}
throw new Error(`Unknown BinaryExpression operator ${node.operator}`);
},
BlockStatement(node) {
return getFunc(node.body.type)(node.body);
},
Identifier(node, nodeContext = context.assigns) {
return nodeContext[node.name];
},
CallExpression(node) {
return getFunc(node.callee.name)(node.arguments);
},
LogicalExpression(node) {
const left = getFunc(node.left.type);
const right = getFunc(node.right.type);
throw new Error(`Unknown BinaryExpression operator ${node.operator}`);
}
BlockStatement(node) {
return this.execute(node.body.type, node.body);
}
Identifier(node, nodeContext) {
if (!nodeContext) nodeContext = this.context.assigns;
return nodeContext[node.name];
}
CallExpression(node) {
return this.execute(node.callee.name, node.arguments);
}
LogicalExpression(node) {
const left = this.execute(node.left.type, node.left);
const right = this.execute(node.right.type, node.right);
if (node.operator === "&" || node.operator === "&&") return left(node.left) && right(node.right);
if (node.operator === "|" || node.operator === "||") return left(node.left) || right(node.right);
if (node.operator === "&" || node.operator === "&&") return left && right;
if (node.operator === "|" || node.operator === "||") return left || right;
throw new Error(`Unknown BinaryExpression operator ${node.operator}`);
},
MemberExpression(node) {
const object = getFunc(node.object.type)(node.object);
if (!object) return;
throw new Error(`Unknown BinaryExpression operator ${node.operator}`);
}
MemberExpression(node) {
const object = this.execute(node.object.type, node.object);
if (!object) return;
const property = getFunc(node.property.type)(node.property);
if (property === undefined) return;
const property = this.execute(node.property.type, node.property);
if (property === undefined) return;
return object[property];
},
ObjectExpression(node) {
if (!node.properties) return {};
return node.properties.reduce((obj, property) => {
obj[property.key.name] = getFunc(property.value.type)(property.value);
return obj;
}, {});
},
ArrayExpression(node) {
if (!node.elements) return [];
return node.elements.map((elm) => getFunc(elm.type)(elm));
},
Literal(node) {
return node.value;
},
UnaryExpression(node) {
if (node.operator !== "!") {
throw new Error(`Unary operator ${node.operator} not implemented`);
}
return !getFunc(node.argument.type)(node.argument);
return object[property];
}
ObjectExpression(node) {
if (!node.properties) return {};
return node.properties.reduce((obj, property) => {
obj[property.key.name] = this.execute(property.value.type, property.value);
return obj;
}, {});
}
ArrayExpression(node) {
if (!node.elements) return [];
return node.elements.map((elm) => this.execute(elm.type, elm));
}
Literal(node) {
return node.value;
}
UnaryExpression(node) {
if (node.operator !== "!") {
throw new Error(`Unary operator ${node.operator} not implemented`);
}
};
return getFunc(ast.type)(ast);
return !this.execute(node.argument.type, node.argument);
}
execute(name, ...args) {
if (!this[name]) throw new Error(`${name} is not implemented`);
const fn = this[name];
return fn.call(this, ...args);
}
}
function getFunc(name) {
if (!funcs[name]) throw new Error(`${name} is not implemented`);
return funcs[name];
}
module.exports = function evaluate(ast, context) {
return new Evaluator(context).execute(ast.type, ast);
};

@@ -193,0 +197,0 @@

@@ -15,3 +15,3 @@ /* eslint-disable prefer-template */

const keywords = ["matches", "matches_i", "has", "has_i"];
const keywords = [ "matches", "matches_i", "has", "has_i" ];

@@ -40,379 +40,426 @@ const NUMBERS = "0123456789";

module.exports = {
Lexer,
Scanner,
Token,
};
class EsiSyntaxError extends SyntaxError {
constructor(message, source, column) {
super(message);
this.columnNumber = column;
this.source = source;
Error.captureStackTrace(this, EsiSyntaxError);
}
}
function Lexer(str, columnOffset, line) {
const scanner = Scanner(str);
class Scanner {
constructor(str) {
this.str = str;
this.l = str.length;
this.column = -1;
}
get() {
const str = this.str;
const l = this.l;
const idx = ++this.column;
const c = str[idx];
const c2 = (idx + 1) < l ? c + str[idx + 1] : undefined;
const c3 = (idx + 2) < l ? c2 + str[idx + 2] : undefined;
return { column: idx, c, c2, c3 };
}
}
let column, char, c1, c2, c3, source = "";
getChar();
class Token {
constructor(parent, startChar, columnOffset = 0, line = 1) {
this.parent = parent;
this.startChar = startChar;
this.columnOffset = columnOffset;
this.line = line;
const { column, c: cargo } = startChar;
this.column = column;
this.cargo = cargo;
this.loc = {
start: {
line,
column,
},
};
}
abort(...args) {
this.parent.abort(this.column, ...args);
}
end(source, endColumn) {
this.loc.source = source;
this.loc.start.column += this.columnOffset;
this.loc.end = {
line: this.line,
column: endColumn + this.columnOffset,
};
}
get() {
switch (this.type) {
case BLOCK: {
return {
type: this.type,
loc: this.loc,
};
}
case BOOLEAN: {
return {
type: LITERAL,
value: this.cargo === "true",
loc: this.loc,
};
}
case IDENTIFIER: {
return {
type: this.type,
name: this.cargo,
loc: this.loc,
};
}
case LITERAL: {
return {
type: this.type,
value: this.cargo,
loc: this.loc,
};
}
case NUMBER: {
return {
type: LITERAL,
value: Number(this.cargo),
loc: this.loc,
};
}
case MEMBER: {
return {
type: this.type,
object: {
type: "Identifier",
name: this.cargo,
},
loc: this.loc,
};
}
case FUNCTION: {
return {
type: this.type,
callee: {
type: "Identifier",
name: this.cargo,
},
arguments: [],
loc: this.loc,
};
}
case ARRAY: {
return {
type: this.type,
elements: [],
loc: this.loc,
};
}
case OBJECT: {
return {
type: this.type,
loc: this.loc,
};
}
case UNARY: {
return {
type: this.type,
operator: this.cargo,
prefix: true,
loc: this.loc,
};
}
case "&":
case "&&":
case "|":
case "||": {
return {
type: LOGICAL,
operator: this.cargo,
loc: this.loc,
};
}
case "matches":
case "matches_i":
case "has":
case "has_i":
case "*":
case "/":
case "+":
case "%":
case "-":
case "<":
case ">":
case "!=":
case ">=":
case "<=":
case "==": {
return {
type: BINARY,
operator: this.cargo,
loc: this.loc,
};
}
default:
return {
type: this.type,
cargo: this.cargo,
loc: this.loc,
};
}
}
}
return {
get,
};
class Lexer {
constructor(str, columnOffset, line) {
this.str = str;
this.scanner = new Scanner(str);
this.columnOffset = columnOffset;
this.line = line;
this.column = -1;
this.char = null;
this.c1 = null;
this.c2 = null;
this.c3 = null;
this.source = "";
this.getChar();
}
getChar() {
if (this.c1) this.source += this.c1;
function get() {
const token = Token(char, columnOffset, line);
const char = this.char = this.scanner.get();
this.column = char.column;
this.c1 = char.c;
this.c2 = char.c2;
this.c3 = char.c3;
return char;
}
consumeWhitespace(token) {
token.type = WHITESPACE;
token.cargo = this.c1;
this.getChar();
return this.next();
}
consumeFunction(token) {
token.type = FUNCTION;
let c = this.getChar().c;
if (c === "(") {
token.type = IDENTIFIER;
c = this.getChar().c;
}
if (c1 === undefined) {
token.type = ENDMARK;
return next();
token.cargo = "";
while (IDENTIFIER_CHARS.indexOf(c) > -1) {
token.cargo += c;
c = this.getChar().c;
}
if (c1 === " ") {
token.type = WHITESPACE;
token.cargo = c1;
getChar();
return next();
if (token.type === IDENTIFIER && c === "{") {
token.type = MEMBER;
} else if (token.type === FUNCTION) {
if (c !== "(") return this._abort(this.column, "Unexpected", c ? "char " + c : "end of line");
} else if (token.type === IDENTIFIER) {
if (c !== ")") return this._abort(this.column, "Unexpected", c ? "char " + c : "end of line");
}
if (c1 === "$") {
token.type = FUNCTION;
getChar();
if (c1 === "(") {
token.type = IDENTIFIER;
getChar();
}
this.getChar();
return this.next();
}
consumeSingleQuoute(token) {
let quoteChars = this.c1;
token.cargo = "";
token.type = LITERAL;
let c3 = this.c3;
if (c3 === "'''") {
quoteChars = "'''";
token.cargo = "";
this.getChar();
this.getChar();
c3 = this.getChar().c3;
while (IDENTIFIER_CHARS.indexOf(c1) > -1) {
token.cargo += c1;
getChar();
while (c3 !== "'''") {
if (c3 === undefined) this._abort(this.column, "Found end of file before end of string literal");
token.cargo += this.c1;
c3 = this.getChar().c3;
}
if (token.type === IDENTIFIER && c1 === "{") {
token.type = MEMBER;
} else if (token.type === FUNCTION) {
if (c1 !== "(") abort({column}, "Unexpected", c1 ? "char " + c1 : "end of line");
} else if (token.type === IDENTIFIER) {
if (c1 !== ")") abort({column}, "Unexpected", c1 ? "char " + c1 : "end of line");
this.getChar();
this.getChar();
this.getChar();
} else {
let c = this.getChar().c;
while (c !== quoteChars) {
if (c === undefined) this._abort(this.column, "Found end of file before end of string literal");
else if (c === "\\") {
c = this.getChar().c;
}
token.cargo += c;
c = this.getChar().c;
}
getChar();
this.getChar();
}
return next();
return this.next(quoteChars, token.cargo, quoteChars);
}
consumeNumber(token) {
token.cargo = "";
token.type = NUMBER;
let c = this.c1;
while (NUMBER_CHARS.indexOf(c) > -1) {
token.cargo += c;
c = this.getChar().c;
}
if (c1 === "'") {
let quoteChars = c1;
token.cargo = "";
token.type = LITERAL;
return this.next();
}
consumeArray(token) {
token.type = ARRAY;
token.cargo = this.c1;
this.getChar();
return this.next();
}
consumeObject(token) {
token.type = OBJECT;
token.cargo = this.c1;
this.getChar();
return this.next();
}
consumeBlock(token) {
token.type = BLOCK;
token.cargo = this.c1;
this.getChar();
return this.next();
}
consumeTwoCharacters(token) {
const c2 = this.c2;
token.cargo = c2;
token.type = c2;
if (c3 === "'''") {
quoteChars = "'''";
if (this.c3 === undefined || this.column === 0) return this.unexpectedToken(c2);
getChar();
getChar();
getChar();
this.getChar();
this.getChar();
return this.next();
}
consumeUnary(token) {
token.cargo = this.c1;
token.type = UNARY;
while (c3 !== "'''") {
if (c3 === undefined) token.abort("Found end of file before end of string literal");
token.cargo += c1;
getChar();
}
const c = this.getChar().c;
if (c === "(" || c === "$") {
return this.next();
}
getChar();
getChar();
getChar();
} else {
getChar();
return this.unexpectedToken(token.cargo);
}
consumeOneCharacter(token) {
const c1 = this.c1;
token.cargo = c1;
token.type = c1;
while (c1 !== quoteChars) {
if (c1 === undefined) token.abort("Found end of file before end of string literal");
else if (c1 === "\\") {
getChar();
}
if (c1 === "|" && (this.c2 === undefined || this.column === 0)) return this.unexpectedToken("|");
token.cargo += c1;
getChar();
}
this.getChar();
return this.next();
}
consumeIdentifier(token) {
token.cargo = "";
getChar();
}
let c = this.c1;
while (IDENTIFIER_CHARS.indexOf(c) > -1) {
token.cargo += c;
c = this.getChar().c;
}
return next(quoteChars, token.cargo, quoteChars);
if (token.cargo === "true") {
token.type = BOOLEAN;
return this.next();
}
if (NUMBER_STARTCHARS.indexOf(c1) > -1 && c2 !== "- ") {
token.cargo = "";
token.type = NUMBER;
if (token.cargo === "false") {
token.type = BOOLEAN;
return this.next();
}
while (NUMBER_CHARS.indexOf(c1) > -1) {
token.cargo += c1;
getChar();
}
return next();
if (keywords.indexOf(token.cargo) === -1) {
return this._abort(token.column, `Unknown keyword "${token.cargo}"`);
}
if (c1 === "[") {
token.type = ARRAY;
token.cargo = c1;
getChar();
return next();
token.type = token.cargo;
return this.next();
}
get() {
const token = this.token = new Token(this, this.char, this.columnOffset, this.line);
const c1 = this.c1;
if (c1 === undefined) {
token.type = ENDMARK;
return this.next();
}
if (c1 === "{") {
token.type = OBJECT;
token.cargo = c1;
getChar();
return next();
switch (c1) {
case " ":
return this.consumeWhitespace(token);
case "$":
return this.consumeFunction(token);
case "'":
return this.consumeSingleQuoute(token);
case "[":
return this.consumeArray(token);
case "{":
return this.consumeObject(token);
case "(":
return this.consumeBlock(token);
}
if (c1 === "(") {
token.type = BLOCK;
token.cargo = c1;
getChar();
return next();
if (NUMBER_STARTCHARS.indexOf(c1) > -1 && this.c2 !== "- ") {
return this.consumeNumber(token);
}
if (twoCharacterSymbols.indexOf(c2) > -1) {
token.cargo = c2;
token.type = c2;
if (c3 === undefined || column === 0) token.abort("Unexpected token", c2);
getChar();
getChar();
return next();
if (twoCharacterSymbols.indexOf(this.c2) > -1) {
return this.consumeTwoCharacters(token);
}
if (c1 === "!") {
token.cargo = c1;
token.type = UNARY;
getChar();
if (c1 === "(" || c1 === "$") {
return next();
}
return token.abort("Unexpected token", token.cargo);
return this.consumeUnary(token);
}
if (oneCharacterSymbols.indexOf(c1) > -1) {
token.cargo = c1;
token.type = c1;
if (c1 === "|" && (c2 === undefined || column === 0)) token.abort("Unexpected token |");
getChar();
return next();
return this.consumeOneCharacter(token);
}
if (IDENTIFIER_CHARS.indexOf(c1) > -1) {
token.cargo = "";
while (IDENTIFIER_CHARS.indexOf(c1) > -1) {
token.cargo += c1;
getChar();
}
if (token.cargo === "true") {
token.type = BOOLEAN;
return next();
}
if (token.cargo === "false") {
token.type = BOOLEAN;
return next();
}
if (keywords.indexOf(token.cargo) === -1) {
token.abort("Unknown keyword", token.cargo);
}
token.type = token.cargo;
return next();
return this.consumeIdentifier(token);
}
return abort({column}, "Unexpected token", c1);
function next() {
while (c1 === " ") {
getChar();
}
token.end(source, column);
source = "";
return token;
return this.unexpectedToken(c1);
}
next() {
let c = this.c1;
while (c === " ") {
c = this.getChar().c;
}
this.token.end(this.source, this.column);
this.source = "";
return this.token;
}
function getChar() {
if (c1) source += c1;
char = scanner.get();
column = char.column;
c1 = char.c;
c2 = char.c2;
c3 = char.c3;
return char;
unexpectedToken(c) {
return this._abort(this.column, `Unexpected token "${c}"`);
}
}
function Scanner(str) {
const l = str.length;
let column = -1;
return {
get,
};
function get() {
const idx = ++column;
const c = str[idx];
const c2 = (idx + 1) < l ? c + str[idx + 1] : undefined;
const c3 = (idx + 2) < l ? c2 + str[idx + 2] : undefined;
return {column: idx, c, c2, c3};
abort(...args) {
this._abort(this.column, ...args);
}
_abort(column, ...args) {
args.push(`at "${this.str}" 0:${column}`);
const err = new EsiSyntaxError(args.join(" "), this.str, column);
throw err;
}
}
function Token(startChar, columnOffset = 0, line = 1) {
const {column, c: cargo} = startChar;
return {
column,
cargo,
loc: {
start: {
line,
column,
}
},
end(source, endColumn) {
this.loc.source = source;
this.loc.start.column += columnOffset;
this.loc.end = {
line,
column: endColumn + columnOffset,
};
},
abort(...args) {
abort(this, ...args);
},
get(opts) {
switch (this.type) {
case BLOCK: {
return {
type: this.type,
loc: this.loc,
};
}
case BOOLEAN: {
return {
type: LITERAL,
value: this.cargo === "true",
loc: this.loc,
};
}
case IDENTIFIER: {
return {
type: this.type,
name: this.cargo,
loc: this.loc,
};
}
case LITERAL: {
return {
type: this.type,
value: this.cargo,
loc: this.loc,
};
}
case NUMBER: {
return {
type: LITERAL,
value: Number(this.cargo),
loc: this.loc,
};
}
case MEMBER: {
return {
type: this.type,
object: {
type: "Identifier",
name: this.cargo
},
loc: this.loc
};
}
case FUNCTION: {
return {
type: this.type,
callee: {
type: "Identifier",
name: this.cargo
},
arguments: [],
loc: this.loc,
};
}
case ARRAY: {
return {
type: this.type,
elements: [],
loc: this.loc,
};
}
case OBJECT: {
return {
type: this.type,
loc: this.loc,
};
}
case UNARY: {
return {
type: this.type,
operator: this.cargo,
prefix: true,
loc: this.loc,
};
}
case "&":
case "&&":
case "|":
case "||": {
return {
type: LOGICAL,
operator: this.cargo,
loc: this.loc,
};
}
case "matches":
case "matches_i":
case "has":
case "has_i":
case "*":
case "/":
case "+":
case "%":
case "-":
case "<":
case ">":
case "!=":
case ">=":
case "<=":
case "==": {
return {
type: BINARY,
operator: this.cargo,
loc: this.loc,
...opts,
};
}
default:
return {
type: this.type,
cargo: this.cargo,
loc: this.loc,
};
}
},
};
}
function abort({column}, ...args) {
args.push(`at 0:${column}`);
throw new SyntaxError(args.join(" "));
}
module.exports = {
Lexer,
};
/* eslint-disable prefer-template */
"use strict";
const {Lexer} = require("./lexer");
const { Lexer } = require("./lexer");

@@ -27,80 +27,24 @@ const {

function parse(input, columnOffset) {
if (!input) return;
input = input.trim();
const ast = AST(columnOffset);
const parser = Parser(input, ast.openNode, ast.closeNode, columnOffset);
while ((parser.consume())) {
// No-op
class AST {
constructor() {
const expression = this.expression = {
type: EXPRESSION,
body: {},
};
this.stack = [ expression ];
}
return ast.tree;
}
function split(input) {
const lines = input.split("\n");
return lines.reduce((result, str, idx) => {
let columnOffset = 0;
let match;
while ((match = str.match(/(?<!\\)\$(.*)/))) {
const line = idx + 1;
columnOffset += match.index;
if (match.index > 0) {
result.push({type: "TEXT", text: str.substring(0, match.index)});
}
const ast = AST();
const parser = Parser(match[0], ast.openNode, ast.closeNode, columnOffset, line);
const hit = {
expression: parser.consume(),
};
result.push(hit);
const hitSourceLength = hit.expression.loc.source.trim().length;
columnOffset += hitSourceLength;
str = str.substring(match.index + hitSourceLength);
get tree() {
while (this.stack.length > 1) {
this.doClose(this.stack.pop(), this.getLast());
}
return this.expression.body;
}
openNode(node) {
const current = this.getLast();
this.stack.push(node);
if (lines.length > 1 && idx < lines.length - 1) {
str += "\n";
}
if (str) {
result.push({type: "TEXT", text: str});
}
return result;
}, []);
}
function AST() {
const tree = {
type: EXPRESSION,
body: {},
};
const stack = [tree];
return {
openNode,
closeNode,
get tree() {
while (stack.length > 1) {
doClose(stack.pop(), getLast());
}
return tree.body;
}
};
function openNode(node) {
const current = getLast();
stack.push(node);
switch (node.type) {
case BINARY:
case LOGICAL:
switchLeft();
this.switchLeft(current, node);
break;

@@ -111,29 +55,27 @@ case UNARY:

}
function switchLeft() {
switch (current && current.type) {
case BLOCK:
case EXPRESSION: {
node.left = current.body;
current.body = node;
break;
}
case LOGICAL: {
node.left = current.right;
current.right = node;
break;
}
case FUNCTION: {
const left = current.arguments.pop();
node.left = left;
current.arguments.push(node);
break;
}
}
switchLeft(current, node) {
switch (current && current.type) {
case BLOCK:
case EXPRESSION: {
node.left = current.body;
current.body = node;
break;
}
case LOGICAL: {
node.left = current.right;
current.right = node;
break;
}
case FUNCTION: {
const left = current.arguments.pop();
node.left = left;
current.arguments.push(node);
break;
}
}
}
doClose(node, current) {
current = current || this.getLast();
function doClose(node, current) {
current = current || getLast();
switch (node.type) {

@@ -143,4 +85,4 @@ case LOGICAL:

node.loc.source = node.left.loc.source + node.loc.source + node.right.loc.source;
node.loc.start = {...node.left.loc.start};
node.loc.end = {...node.right.loc.end};
node.loc.start = { ...node.left.loc.start };
node.loc.end = { ...node.right.loc.end };
break;

@@ -150,3 +92,3 @@ }

node.loc.source = node.loc.source + node.argument.loc.source;
node.loc.end = {...node.argument.loc.end};
node.loc.end = { ...node.argument.loc.end };
break;

@@ -161,3 +103,3 @@ }

current.right = node;
closeNode(current);
this.closeNode(current);
break;

@@ -178,7 +120,7 @@ }

current.argument = node;
closeNode(current);
this.closeNode(current);
break;
}
case ARRAY: {
addArrayElement(current, node);
this.addArrayElement(current, node);
break;

@@ -188,3 +130,3 @@ }

if (node.type === BINARY) break;
addFunctionArgument(current, node);
this.addFunctionArgument(current, node);
break;

@@ -194,15 +136,12 @@ }

}
closeNode(node) {
this.stack.pop();
const current = this.getLast();
function closeNode(node) {
stack.pop();
const current = getLast();
doClose(node, current);
this.doClose(node, current);
}
function getLast() {
return stack[stack.length - 1] || tree.body;
getLast() {
return this.stack[this.stack.length - 1] || this.expression.body;
}
function addArrayElement(current, node) {
addArrayElement(current, node) {
const lastIdx = current.elements.length - 1;

@@ -219,3 +158,3 @@ if (lastIdx > -1) {

node.loc.source = lastElm.loc.source + node.loc.source;
node.loc.start = {...lastElm.loc.start};
node.loc.start = { ...lastElm.loc.start };
}

@@ -226,4 +165,3 @@ } else {

}
function addFunctionArgument(current, node) {
addFunctionArgument(current, node) {
const lastIdx = current.arguments.length - 1;

@@ -236,8 +174,7 @@ if (lastIdx > -1) {

} else {
// if (lastArg.type !== ",") throw new SyntaxError(`Unexpected ${node.type} in ${current.type} ----`);
if (lastArg.type !== ",") return node.abort("Unexpected", node.type, "in", current.type);
if (lastArg.type !== ",") throw new SyntaxError(`Unexpected ${node.type} in ${current.type}`);
current.arguments[lastIdx] = node;
node.loc.source = lastArg.loc.source + node.loc.source;
node.loc.start = {...lastArg.loc.start};
node.loc.start = { ...lastArg.loc.start };
}

@@ -250,14 +187,14 @@ } else {

function Parser(sourceText, onStart, onEnd, columnOffset, line) {
const lexer = Lexer(sourceText, columnOffset, line);
let token;
return {
consume,
};
function consume() {
token = lexer.get();
class Parser {
constructor(sourceText, ast, columnOffset, line) {
this.source = sourceText;
this.lexer = new Lexer(sourceText, columnOffset, line);
this.ast = ast;
this.token = null;
}
consume() {
const token = this.lexer.get();
if (token.type === ENDMARK) return;
const node = Node(token.get());
const node = token.get();

@@ -268,24 +205,24 @@ switch (node.type) {

case UNARY: {
onStart(node);
this.openNode(node);
break;
}
case ARRAY: {
onStart(node);
this.openNode(node);
node.elements = [];
let elm;
elm = consume();
elm = this.consume();
while (elm && elm.type !== "]") {
if (elm.type === ",") return token.abort("Unexpected", elm.type, "in", node.type);
if (elm.type === ",") return this.lexer.abort("Unexpected", elm.type, "in", node.type);
elm = consume();
elm = this.consume();
if (!elm) break;
if (elm.type === ",") {
elm = consume();
} else if (elm.type !== "]") return token.abort("Unexpected", elm.type, "in", node.type);
elm = this.consume();
} else if (elm.type !== "]") return this.lexer.abort("Unexpected", elm.type, "in", node.type);
}
if (!elm || elm.type !== "]") return token.abort("Unclosed", node.type);
if (!elm || elm.type !== "]") return this.lexer.abort("Unclosed", node.type);

@@ -297,17 +234,17 @@ node.elements.forEach((a) => {

node.loc.source += elm.loc.source;
node.loc.end = {...elm.loc.end};
node.loc.end = { ...elm.loc.end };
onEnd(node);
this.closeNode(node);
break;
}
case BLOCK: {
onStart(node);
this.openNode(node);
const source = node.loc.source;
let arg = consume();
let arg = this.consume();
while (arg && arg.type !== ")") {
arg = consume();
arg = this.consume();
}
if (!arg || arg.type !== ")") return token.abort("Unclosed", node.type);
if (!arg || arg.type !== ")") return this.lexer.abort("Unclosed", node.type);

@@ -317,18 +254,18 @@ node.loc.source = source + node.body.loc.source + arg.loc.source;

onEnd(node);
this.closeNode(node);
break;
}
case WHITESPACE: {
return consume();
return this.consume();
}
case FUNCTION: {
onStart(node);
this.openNode(node);
let arg = consume();
if (arg && arg.type === ",") return token.abort("Unexpected", arg.type, "in", node.type);
let arg = this.consume();
if (arg && arg.type === ",") return this.lexer.abort("Unexpected", arg.type, "in", node.type);
while (arg && arg.type !== ")") {
arg = consume();
arg = this.consume();
}
if (!arg || arg.type !== ")") return token.abort("Unclosed", node.type);
if (!arg || arg.type !== ")") return this.lexer.abort("Unclosed", node.type);

@@ -340,15 +277,15 @@ node.arguments.forEach((a) => {

node.loc.source += arg.loc.source;
node.loc.end = {...arg.loc.end};
node.loc.end = { ...arg.loc.end };
onEnd(node);
this.closeNode(node);
break;
}
case IDENTIFIER: {
onStart(node);
onEnd(node);
this.openNode(node);
this.closeNode(node);
break;
}
case LITERAL: {
onStart(node);
onEnd(node);
this.openNode(node);
this.closeNode(node);
break;

@@ -358,7 +295,7 @@ }

let prop;
onStart(node);
this.openNode(node);
do {
node.property = prop;
prop = consume();
prop = this.consume();
if (!prop) continue;

@@ -368,8 +305,8 @@ node.loc.source += prop.loc.source;

const end = consume();
if (!end || end.type !== ")") return token.abort("Unclosed", node.type);
const end = this.consume();
if (!end || end.type !== ")") return this.lexer.abort("Unclosed", node.type);
node.loc.source += end.loc.source;
node.loc.end = end.loc.end;
onEnd(node);
this.closeNode(node);

@@ -379,15 +316,15 @@ break;

case OBJECT: {
onStart(node);
this.openNode(node);
node.properties = [];
let prop = consume();
addObjectProperty(node, null, prop);
let prop = this.consume();
this.addObjectProperty(node, null, prop);
while (prop && prop.type !== "}") {
prop = consume();
prop = this.consume();
if (prop && prop.type === ",") {
addObjectProperty(node, prop, consume());
this.addObjectProperty(node, prop, this.consume());
}
}
if (!prop || prop.type !== "}") return token.abort("Unclosed", node.type);
if (!prop || prop.type !== "}") return this.lexer.abort("Unclosed", node.type);

@@ -401,8 +338,8 @@ node.properties.forEach((a) => {

onEnd(node);
this.closeNode(node);
break;
}
case ",":
onStart(node);
onEnd(node);
this.openNode(node);
this.closeNode(node);
break;

@@ -412,49 +349,102 @@ }

return node;
}
openNode(node) {
try {
this.ast.openNode(node);
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
const start = node.loc.start;
throw new SyntaxError(`${err.message} at "${this.source}" ${start.line}:${start.column}`);
}
}
closeNode(node) {
try {
this.ast.closeNode(node);
} catch (err) {
if (!(err instanceof SyntaxError)) throw err;
const start = node.loc.start;
throw new SyntaxError(`${err.message} at "${this.source}" ${start.line}:${start.column}`);
}
}
addObjectProperty(objectNode, commaNode, keyNode) {
if (keyNode && keyNode.type === "}") return;
if (!keyNode) return this.lexer.abort("Unclosed", OBJECT);
if (keyNode.type !== LITERAL) return this.lexer.abort("Unexpected key", keyNode.type);
function Node(firstToken) {
const tokenNode = {...firstToken};
const colon = this.consume();
if (!colon) return this.lexer.abort("Missing key value separator");
if (colon.type !== ":") return this.lexer.abort("Unexpected", colon.type, "in object");
Object.defineProperty(tokenNode, "abort", {
enumerable: false,
value: abort,
});
const value = this.consume();
const property = {
type: "Property",
key: {
type: IDENTIFIER,
name: keyNode.value,
},
value,
loc: {
source: [ commaNode, keyNode, colon, value ]
.filter(Boolean)
.map((n) => n.loc.source)
.join(""),
start: { ...((commaNode || keyNode).loc.start) },
end: { ...value.loc.end },
},
};
return tokenNode;
objectNode.properties.push(property);
}
}
function abort(...args) {
args.push("at", `${firstToken.loc.start.line}:${firstToken.loc.start.column}`);
throw new SyntaxError(args.join(" "));
}
}
function parse(input, columnOffset) {
if (!input) return;
input = input.trim();
function addObjectProperty(objectNode, commaNode, keyNode) {
if (keyNode && keyNode.type === "}") return;
if (!keyNode) return token.abort("Unclosed", OBJECT);
if (keyNode.type !== LITERAL) return token.abort("Unexpected key", keyNode.type);
const ast = new AST(columnOffset);
const parser = new Parser(input, ast, columnOffset);
while ((parser.consume())) {
// No-op
}
const colon = consume();
if (!colon) return token.abort("Missing key value separator");
if (colon.type !== ":") return token.abort("Unexpected", colon.type, "in object");
return ast.tree;
}
const value = consume();
const property = {
type: "Property",
key: {
type: IDENTIFIER,
name: keyNode.value,
},
value,
loc: {
source: [commaNode, keyNode, colon, value]
.filter(Boolean)
.map((n) => n.loc.source)
.join(""),
start: {...((commaNode || keyNode).loc.start)},
end: {...value.loc.end},
}
function split(input) {
const lines = input.split("\n");
return lines.reduce((result, str, idx) => {
let columnOffset = 0;
let match;
while ((match = str.match(/(?<!\\)\$(.*)/))) {
const line = idx + 1;
columnOffset += match.index;
if (match.index > 0) {
result.push({ type: "TEXT", text: str.substring(0, match.index) });
}
const ast = new AST();
const parser = new Parser(match[0], ast, columnOffset, line);
const hit = {
expression: parser.consume(),
};
result.push(hit);
objectNode.properties.push(property);
const hitSourceLength = hit.expression.loc.source.trim().length;
columnOffset += hitSourceLength;
str = str.substring(match.index + hitSourceLength);
}
}
if (lines.length > 1 && idx < lines.length - 1) {
str += "\n";
}
if (str) {
result.push({ type: "TEXT", text: str });
}
return result;
}, []);
}
"use strict";
const {EventEmitter} = require("events");
const { chunkToMarkup } = require("./markup");
const { EventEmitter } = require("events");
const { replace } = require("./evaluateExpression");
const request = require("got");
module.exports = function ListenerContext(req, res, emitter) {
emitter = emitter || new EventEmitter();
module.exports = class ListenerContext {
constructor(options = {}, emitter) {
this.options = options;
this.emitter = emitter || new EventEmitter();
this.inAttempt = false;
this.lastAttemptWasError = false;
this.inExcept = false;
this.includeError = false;
this.replacement = "";
this.chooses = [];
this.tags = [];
this.cookies = options.cookies;
this.assigns = {
...buildHeaderVariables(options.headers),
HTTP_COOKIE: options.cookies || {},
REQUEST_PATH: options.path || {},
QUERY_STRING: options.query || {},
};
}
isProcessing() {
return Boolean((this.tags.length || this.isSubContext) && !this.isInPlainText());
}
isInPlainText() {
return this.tags.some((tag) => tag.plainText);
}
clone(linkAssigns) {
const c = new ListenerContext(this.options, this.emitter);
if (linkAssigns) {
c.assigns = this.assigns;
}
return c;
}
subContext() {
const clone = this.clone(true);
clone.isSubContext = true;
return clone;
}
shouldWrite() {
if (this.inExcept && !this.lastAttemptWasError) return false;
if (this.breakHit) return false;
return {
assigns: Object.assign(buildHeaderVariables(req && req.headers), {
"HTTP_COOKIE": req.cookies || {},
"REQUEST_PATH": req.path || {},
"QUERY_STRING": req.query || {}
}),
cookies: req.cookies,
req,
res,
isProcessing() {
return Boolean((this.tags.length || this.isSubContext) && !this.isInPlainText());
},
isInPlainText() {
return this.tags.some((tag) => tag.plainText);
},
inAttempt: false,
lastAttemptWasError: false,
inExcept: false,
includeError: false,
replacement: "",
chooses: [],
tags: [],
get emitter() {
return emitter;
},
set emitter(value) {
emitter = value;
},
on(...args) {
emitter.on(...args);
},
once(...args) {
emitter.once(...args);
},
emit(...args) {
emitter.emit(...args);
},
removeListener(...args) {
emitter.removeListener(...args);
},
clone(linkAssigns) {
const c = ListenerContext(req, res, emitter);
if (linkAssigns) {
c.assigns = this.assigns;
if (this.chooses.length) {
return this.chooses.every((choose) => choose.isCurrentlyEvaluatedTo);
}
return true;
}
writeToResult(chunk, next) {
if (this.bufferingString) {
const [ current = {} ] = this.tags.slice(-1);
if (typeof chunk === "function") {
chunk = chunk();
}
return c;
},
subContext() {
const clone = this.clone(true);
clone.isSubContext = true;
return clone;
current.text += chunkToMarkup(chunk);
return next();
}
};
function buildHeaderVariables(headers) {
if (!headers) return {};
return Object.entries(headers).reduce((acc, pair) => {
const header = pair[0];
if (header === "x-forwarded-for") {
acc.REMOTE_ADDR = pair[1];
if (this.shouldWrite()) {
if (typeof chunk === "function") {
chunk = chunk();
}
return next(null, chunk);
}
const httpKey = header.replace(/-/g, "_").toUpperCase();
acc[`HTTP_${httpKey}`] = pair[1];
return acc;
}, {});
next();
}
fetch(data) {
const self = this;
const options = {
throwHttpErrors: false,
method: "GET",
retry: 0,
headers: {
...self.options.headers,
host: undefined,
"content-type": undefined,
},
};
let fetchUrl = replace(data.src, self);
if (!fetchUrl.startsWith("http")) {
const host = this.options.localhost || self.assigns.HTTP_HOST;
fetchUrl = new URL(fetchUrl, `http://${host}`).toString();
}
return request.stream(fetchUrl, options)
.on("response", function onResponse(resp) {
if (resp.statusCode < 400) return;
if (self.inAttempt) {
self.lastAttemptWasError = true;
return this.push(null);
}
return this.destroy(new request.HTTPError(resp));
})
.on("error", (err) => {
if (!self.inAttempt) return;
self.lastAttemptWasError = true;
err.inAttempt = true;
});
}
};
function buildHeaderVariables(headers) {
if (!headers) return {};
return Object.entries(headers).reduce((acc, pair) => {
const header = pair[0];
if (header === "x-forwarded-for") {
acc.REMOTE_ADDR = pair[1];
}
const httpKey = header.replace(/-/g, "_").toUpperCase();
acc[`HTTP_${httpKey}`] = pair[1];
return acc;
}, {});
}
"use strict";
const voidElements = ["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source", "track", "wbr"];
const selfClosingElements = ["esi:include", "esi:eval", "esi:assign", "esi:debug", "esi:break"];
const { selfClosingElements, voidElements } = require("./voidElements");

@@ -14,3 +13,3 @@ module.exports = {

function chunkToMarkup({name, data, text}) {
function chunkToMarkup({ name, data, text }) {
let markup = "";

@@ -46,4 +45,3 @@ if (text) markup += text;

if (!attr) return "";
return Object.keys(attr).reduce((attributes, key) => {
const value = attr[key];
return Object.entries(attr).reduce((attributes, [ key, value ]) => {
if (value === "") {

@@ -50,0 +48,0 @@ return `${attributes} ${key}`;

{
"name": "@bonniernews/local-esi",
"version": "1.2.9",
"version": "2.0.0",
"description": "Local Edge Side Includes parser",

@@ -16,17 +16,20 @@ "main": "index.js",

"license": "MIT",
"peerDependencies": {
"@bonniernews/atlas-html-stream": ">=2",
"got": ">11",
"pumpify": ">=2"
},
"devDependencies": {
"@bonniernews/atlas-html-stream": "^2.0.0",
"chai": "^4.3.4",
"chronokinesis": "^3.1.2",
"eslint": "^8.0.0",
"mocha": "^9.1.2",
"nock": "^13.1.3"
},
"dependencies": {
"@bonniernews/atlas-html-stream": "^1.2.2",
"got": "^11.8.2",
"pump": "^3.0.0",
"eslint": "^8.4.1",
"eslint-config-exp": "0.0.9",
"got": "^11.8.3",
"mocha": "^9.1.3",
"nock": "^13.2.1",
"pumpify": "^2.0.1"
},
"engines": {
"node": ">=9.11.2"
"node": ">=14"
},

@@ -33,0 +36,0 @@ "repository": {

@@ -10,27 +10,73 @@ Local-ESI

- [`localEsi(html, req, res, next)`](#localesihtml-req-res-next)
- [`localEsi.createStream(req)`](#localesicreatestreamreq)
- [`localEsi.createParser(req)`](#localesicreateparserreq)
- [`localEsi.htmlWriter()`](#localesihtmlwriter)
- [`ESI`](#new-esioptions): transform class that returns an ESI transform stream
- [`HTMLWriter`](#new-htmlwriter): transform class that returns markup from object stream
- [`parse`](#parsehtml-options): async function that returns ESI evaluated markup
## `localEsi(html, req, res, next)`
## `new ESI([options])`
Use as an expressjs request callback function.
Create an ESI transform stream. Emits [events](#esi-parsing-events).
Arguments:
- `html`: string with markup
- `req`: request with headers and cookies
- `res`: response with send, redirect, set, and status function
- `next`: function to catch occasional error in
- `options`: optional options object with headers and cookies
- `headers`: request headers, accessible through ESI globals `HTTP_<HEADER_NAME>`, `x-forwarded-for` will be accessible as `REMOTE_ADDR`
- `cookies`: object with request cookies, accessible through ESI global `HTTP_COOKIE`
- `path`: string request path, mapped to ESI global `REQUEST_PATH`
- `query`: object request query parameters, accessible through ESI global `QUERY_STRING`
- `localhost`: host to use when a relative src is used by eval or include, defaults to `headers.host`
Returns:
- esi evaluated object stream
__Example express route:__
```javascript
"use strict";
const localEsi = require("@bonniernews/local-esi");
const HTMLParser = require("@bonniernews/atlas-html-stream");
const {ESI, HTMLWriter} = require("@bonniernews/local-esi");
const {pipeline} = require("stream");
module.exports = (req, res, next) => {
res.render("index", { data: "a" }, (err, html) => {
if (err) return next(err);
module.exports = function streamRender(req, res, next) {
const { headers, cookies, path, query } = req;
localEsi(html, req, res, next);
const options = {
headers,
cookies,
path,
query,
localhost: `localhost:${req.socket.server.address().port}`,
};
const esi = new ESI(options)
.once("set_redirect", function onSetRedirect(statusCode, location) {
res.status(statusCode).redirect(location);
this.destroy();
})
.on("set_response_code", function onSetResponseCode(statusCode, body) {
res.status(statusCode);
if (!body) return;
res.send(body);
this.destroy();
})
.on("add_header", (name, value) => {
res.set(name, value);
});
const body = "";
pipeline([
res.render("index"),
new HTMLParser({preserveWS: true}),
esi,
new HTMLWriter(),
], (err) => {
if (err?.code === "ERR_STREAM_PREMATURE_CLOSE"]) {
return;
} else if (err) {
return next(err);
}
return res.send(body);
}).on("data", (chunk) => {
body += chunk;
});

@@ -40,67 +86,45 @@ };

## `localEsi.createStream(req)`
## `parse(html, options)`
Create pipable ESI parse as stream. Emits [events](#esi-parsing-events).
Arguments:
- `req`: request with headers and cookies
- `html`: markup to parse
- `options`: same as for for [ESI](#new-esioptions)
Returns markup stream.
Returns promise:
- `body`: string with ESI evaluated markup or body from `$set_response_code`
- `statusCode`: occasional status code from `$set_response_code` or `$set_redirect`
- `headers`: object with added headers (in lowercase) from `$add_header` or `$set_redirect(location)`, NB! `set-cookie` will be in a list
__Example express route:__
```javascript
"use strict";
const {createStream} = require("@bonniernews/local-esi");
const HTMLParser = require("@bonniernews/atlas-html-stream");
const {parse} = require("@bonniernews/local-esi");
module.exports = (req, res, next) => {
const esiParseStream = createStream(req)
.on("add_header", (name, value) => res.set(name, value))
.once("set_redirect", (statusCode, location) => res.redirect(statusCode, location));
module.exports = function render(req, res, next) {
const { headers, cookies, path, query } = req;
res.render("index")
.pipe(esiParseStream)
.on("error", next);
};
```
const options = {
headers,
cookies,
path,
query,
localhost: `localhost:${req.socket.server.address().port}`,
};
## `localEsi.createParser(req)`
const html = res.render("index");
Create ESI parse transform stream. Emits [events](#esi-parsing-events).
const {statusCode, headers, body} = await parse(html, options);
if (statusCode < 309 && statusCode > 300) {
return res.redirect(response.statusCode, headers.location);
}
Arguments:
- `req`: request with headers and cookies
Requires [markup stream](#markup-object-stream) to read from. Writes object stream.
```javascript
"use strict";
const HtmlParser = require("@bonniernews/atlas-html-stream");
const {createParser: createESIParser, htmlWriter} = require("@bonniernews/local-esi");
module.exports = function channelRendering(req, res, next) {
const esiParser = createESIParser(req)
.once("set_redirect", (statusCode, location) => {
res.status(statusCode).redirect(location);
})
.on("set_response_code", (statusCode, body) => {
res.status(statusCode);
if (body) res.send(body);
})
.on("add_header", (name, value) => {
res.set(name, value);
});
return res.render("index")
.pipe(new HtmlParser({preserveWS: true}))
.pipe(esiParser)
.pipe(htmlWriter())
.pipe(res)
.once("error", (err) => {
next(err);
});
res.status(statusCode || 200);
return res.send(body);
};
```
## `localEsi.htmlWriter()`
## `new HTMLWriter()`

@@ -115,14 +139,28 @@ Returns transform [object stream](#markup-object-stream) to markup buffer stream.

Set status code and optional body.
Parser encountered a `$set_response_code` instruction with status code and optional body.
Signature:
- `statusCode`: number HTTP status code
- `body`: optional string body
### `add_header`
Set header name and value.
Parser encountered a `$add_header` instruction with HTTP header name and value.
Signature:
- `name`: HTTP header name
- `value`: HTTP header value
### `set_redirect`
Redirect with status code and location.
Parser encountered a `$set_redirect` instruction with optional status code and location.
Signature:
- `statusCode`: redirect HTTP status code
- `location`: redirect location
## Markup object stream
Object streams requires the schema `{name, data, text}` representing tag name, tag attributes, and text. This project uses [atlas-html-stream](https://www.npmjs.com/package/atlas-html-stream) for html parsing.
Object streams requires the schema `{name, data, text}` representing tag name, tag attributes, and text. This project uses [@bonniernews/atlas-html-stream][0] for html parsing.
[0]: https://www.npmjs.com/package/@bonniernews/atlas-html-stream
SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc