Remove the pre-execution validation in string templates to double performance and prevent JS binding issues when mutating context
This commit is contained in:
parent
7b20aa31d1
commit
28557a3f96
|
@ -1,4 +1,5 @@
|
||||||
const { atob } = require("../utilities")
|
const { atob } = require("../utilities")
|
||||||
|
const { cloneDeep } = require("lodash/fp")
|
||||||
|
|
||||||
// The method of executing JS scripts depends on the bundle being built.
|
// The method of executing JS scripts depends on the bundle being built.
|
||||||
// This setter is used in the entrypoint (either index.cjs or index.mjs).
|
// This setter is used in the entrypoint (either index.cjs or index.mjs).
|
||||||
|
@ -38,8 +39,12 @@ module.exports.processJS = (handlebars, context) => {
|
||||||
// This is required to allow the final `return` statement to be valid.
|
// This is required to allow the final `return` statement to be valid.
|
||||||
const js = `function run(){${atob(handlebars)}};run();`
|
const js = `function run(){${atob(handlebars)}};run();`
|
||||||
|
|
||||||
// Our $ context function gets a value from context
|
// Our $ context function gets a value from context.
|
||||||
const sandboxContext = { $: path => getContextValue(path, context) }
|
// We clone the context to avoid mutation in the binding affecting real
|
||||||
|
// app context.
|
||||||
|
const sandboxContext = {
|
||||||
|
$: path => getContextValue(path, cloneDeep(context)),
|
||||||
|
}
|
||||||
|
|
||||||
// Create a sandbox with out context and run the JS
|
// Create a sandbox with out context and run the JS
|
||||||
return runJS(js, sandboxContext)
|
return runJS(js, sandboxContext)
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
const handlebars = require("handlebars")
|
const handlebars = require("handlebars")
|
||||||
const { registerAll } = require("./helpers/index")
|
const { registerAll } = require("./helpers/index")
|
||||||
const processors = require("./processors")
|
const processors = require("./processors")
|
||||||
const { removeHandlebarsStatements, atob, btoa } = require("./utilities")
|
const { atob, btoa } = require("./utilities")
|
||||||
const manifest = require("../manifest.json")
|
const manifest = require("../manifest.json")
|
||||||
|
|
||||||
const hbsInstance = handlebars.create()
|
const hbsInstance = handlebars.create()
|
||||||
registerAll(hbsInstance)
|
registerAll(hbsInstance)
|
||||||
const hbsInstanceNoHelpers = handlebars.create()
|
const hbsInstanceNoHelpers = handlebars.create()
|
||||||
|
const defaultOpts = { noHelpers: false }
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* utility function to check if the object is valid
|
* utility function to check if the object is valid
|
||||||
|
@ -28,11 +29,7 @@ function testObject(object) {
|
||||||
* @param {object} opts optional - specify some options for processing.
|
* @param {object} opts optional - specify some options for processing.
|
||||||
* @returns {Promise<object|array>} The structure input, as fully updated as possible.
|
* @returns {Promise<object|array>} The structure input, as fully updated as possible.
|
||||||
*/
|
*/
|
||||||
module.exports.processObject = async (
|
module.exports.processObject = async (object, context, opts) => {
|
||||||
object,
|
|
||||||
context,
|
|
||||||
opts = { noHelpers: false }
|
|
||||||
) => {
|
|
||||||
testObject(object)
|
testObject(object)
|
||||||
for (let key of Object.keys(object || {})) {
|
for (let key of Object.keys(object || {})) {
|
||||||
if (object[key] != null) {
|
if (object[key] != null) {
|
||||||
|
@ -63,11 +60,7 @@ module.exports.processObject = async (
|
||||||
* @param {object} opts optional - specify some options for processing.
|
* @param {object} opts optional - specify some options for processing.
|
||||||
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be.
|
* @returns {Promise<string>} The enriched string, all templates should have been replaced if they can be.
|
||||||
*/
|
*/
|
||||||
module.exports.processString = async (
|
module.exports.processString = async (string, context, opts) => {
|
||||||
string,
|
|
||||||
context,
|
|
||||||
opts = { noHelpers: false }
|
|
||||||
) => {
|
|
||||||
// TODO: carry out any async calls before carrying out async call
|
// TODO: carry out any async calls before carrying out async call
|
||||||
return module.exports.processStringSync(string, context, opts)
|
return module.exports.processStringSync(string, context, opts)
|
||||||
}
|
}
|
||||||
|
@ -81,11 +74,7 @@ module.exports.processString = async (
|
||||||
* @param {object} opts optional - specify some options for processing.
|
* @param {object} opts optional - specify some options for processing.
|
||||||
* @returns {object|array} The structure input, as fully updated as possible.
|
* @returns {object|array} The structure input, as fully updated as possible.
|
||||||
*/
|
*/
|
||||||
module.exports.processObjectSync = (
|
module.exports.processObjectSync = (object, context, opts) => {
|
||||||
object,
|
|
||||||
context,
|
|
||||||
opts = { noHelpers: false }
|
|
||||||
) => {
|
|
||||||
testObject(object)
|
testObject(object)
|
||||||
for (let key of Object.keys(object || {})) {
|
for (let key of Object.keys(object || {})) {
|
||||||
let val = object[key]
|
let val = object[key]
|
||||||
|
@ -106,26 +95,20 @@ module.exports.processObjectSync = (
|
||||||
* @param {object} opts optional - specify some options for processing.
|
* @param {object} opts optional - specify some options for processing.
|
||||||
* @returns {string} The enriched string, all templates should have been replaced if they can be.
|
* @returns {string} The enriched string, all templates should have been replaced if they can be.
|
||||||
*/
|
*/
|
||||||
module.exports.processStringSync = (
|
module.exports.processStringSync = (string, context, opts) => {
|
||||||
string,
|
opts = { ...defaultOpts, ...opts }
|
||||||
context,
|
|
||||||
opts = { noHelpers: false }
|
// take a copy of input in case of error
|
||||||
) => {
|
|
||||||
if (!exports.isValid(string)) {
|
|
||||||
return string
|
|
||||||
}
|
|
||||||
// take a copy of input incase error
|
|
||||||
const input = string
|
const input = string
|
||||||
if (typeof string !== "string") {
|
if (typeof string !== "string") {
|
||||||
throw "Cannot process non-string types."
|
throw "Cannot process non-string types."
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const noHelpers = opts && opts.noHelpers
|
|
||||||
// finalising adds a helper, can't do this with no helpers
|
// finalising adds a helper, can't do this with no helpers
|
||||||
const shouldFinalise = !noHelpers
|
const shouldFinalise = !opts.noHelpers
|
||||||
string = processors.preprocess(string, shouldFinalise)
|
string = processors.preprocess(string, shouldFinalise)
|
||||||
// this does not throw an error when template can't be fulfilled, have to try correct beforehand
|
// this does not throw an error when template can't be fulfilled, have to try correct beforehand
|
||||||
const instance = noHelpers ? hbsInstanceNoHelpers : hbsInstance
|
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
|
||||||
const template = instance.compile(string, {
|
const template = instance.compile(string, {
|
||||||
strict: false,
|
strict: false,
|
||||||
})
|
})
|
||||||
|
@ -136,7 +119,7 @@ module.exports.processStringSync = (
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return removeHandlebarsStatements(input)
|
return input
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -155,7 +138,8 @@ module.exports.makePropSafe = property => {
|
||||||
* @param opts optional - specify some options for processing.
|
* @param opts optional - specify some options for processing.
|
||||||
* @returns {boolean} Whether or not the input string is valid.
|
* @returns {boolean} Whether or not the input string is valid.
|
||||||
*/
|
*/
|
||||||
module.exports.isValid = (string, opts = { noHelpers: false }) => {
|
module.exports.isValid = (string, opts) => {
|
||||||
|
opts = { ...defaultOpts, ...opts }
|
||||||
const validCases = [
|
const validCases = [
|
||||||
"string",
|
"string",
|
||||||
"number",
|
"number",
|
||||||
|
@ -169,7 +153,7 @@ module.exports.isValid = (string, opts = { noHelpers: false }) => {
|
||||||
// don't really need a real context to check if its valid
|
// don't really need a real context to check if its valid
|
||||||
const context = {}
|
const context = {}
|
||||||
try {
|
try {
|
||||||
const instance = opts && opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
|
const instance = opts.noHelpers ? hbsInstanceNoHelpers : hbsInstance
|
||||||
instance.compile(processors.preprocess(string, false))(context)
|
instance.compile(processors.preprocess(string, false))(context)
|
||||||
return true
|
return true
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -10,7 +10,10 @@ module.exports.swapStrings = (string, start, length, swap) => {
|
||||||
return string.slice(0, start) + swap + string.slice(start + length)
|
return string.slice(0, start) + swap + string.slice(start + length)
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports.removeHandlebarsStatements = string => {
|
module.exports.removeHandlebarsStatements = (
|
||||||
|
string,
|
||||||
|
replacement = "Invalid binding"
|
||||||
|
) => {
|
||||||
let regexp = new RegExp(exports.FIND_HBS_REGEX)
|
let regexp = new RegExp(exports.FIND_HBS_REGEX)
|
||||||
let matches = string.match(regexp)
|
let matches = string.match(regexp)
|
||||||
if (matches == null) {
|
if (matches == null) {
|
||||||
|
@ -18,7 +21,7 @@ module.exports.removeHandlebarsStatements = string => {
|
||||||
}
|
}
|
||||||
for (let match of matches) {
|
for (let match of matches) {
|
||||||
const idx = string.indexOf(match)
|
const idx = string.indexOf(match)
|
||||||
string = exports.swapStrings(string, idx, match.length, "Invalid Binding")
|
string = exports.swapStrings(string, idx, match.length, replacement)
|
||||||
}
|
}
|
||||||
return string
|
return string
|
||||||
}
|
}
|
||||||
|
|
|
@ -13,10 +13,14 @@ describe("test the custom helpers we have applied", () => {
|
||||||
|
|
||||||
describe("test that it can run without helpers", () => {
|
describe("test that it can run without helpers", () => {
|
||||||
it("should be able to run without helpers", async () => {
|
it("should be able to run without helpers", async () => {
|
||||||
const output = await processString("{{ avg 1 1 1 }}", {}, { noHelpers: true })
|
const output = await processString(
|
||||||
|
"{{ avg 1 1 1 }}",
|
||||||
|
{},
|
||||||
|
{ noHelpers: true }
|
||||||
|
)
|
||||||
const valid = await processString("{{ avg 1 1 1 }}", {})
|
const valid = await processString("{{ avg 1 1 1 }}", {})
|
||||||
expect(valid).toBe("1")
|
expect(valid).toBe("1")
|
||||||
expect(output).toBe("Invalid Binding")
|
expect(output).toBe("{{ avg 1 1 1 }}")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -185,17 +189,22 @@ describe("test the date helpers", () => {
|
||||||
|
|
||||||
it("should test the timezone capabilities", async () => {
|
it("should test the timezone capabilities", async () => {
|
||||||
const date = new Date(1611577535000)
|
const date = new Date(1611577535000)
|
||||||
const output = await processString("{{ date time 'HH-mm-ss Z' 'America/New_York' }}", {
|
const output = await processString(
|
||||||
time: date.toUTCString(),
|
"{{ date time 'HH-mm-ss Z' 'America/New_York' }}",
|
||||||
})
|
{
|
||||||
const formatted = new dayjs(date).tz("America/New_York").format("HH-mm-ss Z")
|
time: date.toUTCString(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
const formatted = new dayjs(date)
|
||||||
|
.tz("America/New_York")
|
||||||
|
.format("HH-mm-ss Z")
|
||||||
expect(output).toBe(formatted)
|
expect(output).toBe(formatted)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should guess the users timezone when not specified", async () => {
|
it("should guess the users timezone when not specified", async () => {
|
||||||
const date = new Date()
|
const date = new Date()
|
||||||
const output = await processString("{{ date time 'Z' }}", {
|
const output = await processString("{{ date time 'Z' }}", {
|
||||||
time: date.toUTCString()
|
time: date.toUTCString(),
|
||||||
})
|
})
|
||||||
const timezone = dayjs.tz.guess()
|
const timezone = dayjs.tz.guess()
|
||||||
const offset = new dayjs(date).tz(timezone).format("Z")
|
const offset = new dayjs(date).tz(timezone).format("Z")
|
||||||
|
@ -307,12 +316,12 @@ describe("test the comparison helpers", () => {
|
||||||
describe("Test the object/array helper", () => {
|
describe("Test the object/array helper", () => {
|
||||||
it("should allow plucking from an array of objects", async () => {
|
it("should allow plucking from an array of objects", async () => {
|
||||||
const context = {
|
const context = {
|
||||||
items: [
|
items: [{ price: 20 }, { price: 30 }],
|
||||||
{ price: 20 },
|
|
||||||
{ price: 30 },
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
const output = await processString("{{ literal ( sum ( pluck items 'price' ) ) }}", context)
|
const output = await processString(
|
||||||
|
"{{ literal ( sum ( pluck items 'price' ) ) }}",
|
||||||
|
context
|
||||||
|
)
|
||||||
expect(output).toBe(50)
|
expect(output).toBe(50)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -442,15 +451,15 @@ describe("Cover a few complex use cases", () => {
|
||||||
|
|
||||||
it("should only invalidate a single string in an object", async () => {
|
it("should only invalidate a single string in an object", async () => {
|
||||||
const input = {
|
const input = {
|
||||||
dataProvider:"{{ literal [c670254c9e74e40518ee5becff53aa5be] }}",
|
dataProvider: "{{ literal [c670254c9e74e40518ee5becff53aa5be] }}",
|
||||||
theme:"spectrum--lightest",
|
theme: "spectrum--lightest",
|
||||||
showAutoColumns:false,
|
showAutoColumns: false,
|
||||||
quiet:true,
|
quiet: true,
|
||||||
size:"spectrum--medium",
|
size: "spectrum--medium",
|
||||||
rowCount:8,
|
rowCount: 8,
|
||||||
}
|
}
|
||||||
const output = await processObject(input, tableJson)
|
const output = await processObject(input, tableJson)
|
||||||
expect(output.dataProvider).not.toBe("Invalid Binding")
|
expect(output.dataProvider).not.toBe("Invalid binding")
|
||||||
})
|
})
|
||||||
|
|
||||||
it("should be able to handle external ids", async () => {
|
it("should be able to handle external ids", async () => {
|
||||||
|
|
|
@ -8,7 +8,7 @@ jest.mock("nodemailer")
|
||||||
const nodemailer = require("nodemailer")
|
const nodemailer = require("nodemailer")
|
||||||
nodemailer.createTransport.mockReturnValue({
|
nodemailer.createTransport.mockReturnValue({
|
||||||
sendMail: sendMailMock,
|
sendMail: sendMailMock,
|
||||||
verify: jest.fn()
|
verify: jest.fn(),
|
||||||
})
|
})
|
||||||
|
|
||||||
describe("/api/global/email", () => {
|
describe("/api/global/email", () => {
|
||||||
|
@ -39,6 +39,6 @@ describe("/api/global/email", () => {
|
||||||
expect(sendMailMock).toHaveBeenCalled()
|
expect(sendMailMock).toHaveBeenCalled()
|
||||||
const emailCall = sendMailMock.mock.calls[0][0]
|
const emailCall = sendMailMock.mock.calls[0][0]
|
||||||
expect(emailCall.subject).toBe("Hello!")
|
expect(emailCall.subject).toBe("Hello!")
|
||||||
expect(emailCall.html).not.toContain("Invalid Binding")
|
expect(emailCall.html).not.toContain("Invalid binding")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
Loading…
Reference in New Issue