javascript.ts
1 import { 2 atob, 3 frontendWrapJS, 4 isBackendService, 5 isJSAllowed, 6 } from "../utilities" 7 import { LITERAL_MARKER } from "../helpers/constants" 8 import { getJsHelperList } from "./list" 9 import { iifeWrapper } from "../iife" 10 import { JsTimeoutError, UserScriptError } from "../errors" 11 import cloneDeep from "lodash/fp/cloneDeep" 12 import { Log, LogType } from "../types" 13 import { isTest } from "../environment" 14 15 // The method of executing JS scripts depends on the bundle being built. 16 // This setter is used in the entrypoint (either index.js or index.mjs). 17 let runJS: ((js: string, context: Record<string, any>) => any) | undefined = 18 undefined 19 export const setJSRunner = (runner: typeof runJS) => (runJS = runner) 20 21 export const removeJSRunner = () => { 22 runJS = undefined 23 } 24 25 let onErrorLog: (message: Error) => void 26 export const setOnErrorLog = (delegate: typeof onErrorLog) => 27 (onErrorLog = delegate) 28 29 // Helper utility to strip square brackets from a value 30 const removeSquareBrackets = (value: string) => { 31 if (!value || typeof value !== "string") { 32 return value 33 } 34 const regex = /\[+(.+)]+/ 35 const matches = value.match(regex) 36 if (matches && matches[1]) { 37 return matches[1] 38 } 39 return value 40 } 41 42 const isReservedKey = (key: string) => 43 key === "snippets" || 44 key === "helpers" || 45 key.startsWith("snippets.") || 46 key.startsWith("helpers.") 47 48 // Our context getter function provided to JS code as $. 49 // Extracts a value from context. 50 const getContextValue = (path: string, context: any) => { 51 // We populate `snippets` ourselves, don't allow access to it. 52 if (isReservedKey(path)) { 53 return undefined 54 } 55 const literalStringRegex = /^(["'`]).*\1$/ 56 let data = context 57 // check if it's a literal string - just return path if its quoted 58 if (literalStringRegex.test(path)) { 59 return path.substring(1, path.length - 1) 60 } 61 path.split(".").forEach(key => { 62 if (data == null || typeof data !== "object") { 63 return null 64 } 65 data = data[removeSquareBrackets(key)] 66 }) 67 68 return data 69 } 70 71 // Evaluates JS code against a certain context 72 export function processJS(handlebars: string, context: any) { 73 if (!isJSAllowed() || !runJS) { 74 throw new Error("JS disabled in environment.") 75 } 76 try { 77 // Wrap JS in a function and immediately invoke it. 78 // This is required to allow the final `return` statement to be valid. 79 const js = iifeWrapper(atob(handlebars)) 80 81 // Transform snippets into an object for faster access, and cache previously 82 // evaluated snippets 83 let snippetMap: any = {} 84 let snippetCache: any = {} 85 for (let snippet of context.snippets || []) { 86 snippetMap[snippet.name] = snippet.code 87 } 88 89 let clonedContext: Record<string, any> 90 if (isBackendService()) { 91 // On the backend, values are copied across the isolated-vm boundary and 92 // so we don't need to do any cloning here. This does create a fundamental 93 // difference in how JS executes on the frontend vs the backend, e.g. 94 // consider this snippet: 95 // 96 // $("array").push(2) 97 // return $("array")[1] 98 // 99 // With the context of `{ array: [1] }`, the backend will return 100 // `undefined` whereas the frontend will return `2`. We should fix this. 101 clonedContext = context 102 } else { 103 clonedContext = cloneDeep(context) 104 } 105 106 const sandboxContext: Record<string, any> = { 107 $: (path: string) => getContextValue(path, clonedContext), 108 helpers: getJsHelperList(), 109 // Proxy to evaluate snippets when running in the browser 110 snippets: new Proxy( 111 {}, 112 { 113 get: function (_, name) { 114 if (!(name in snippetCache)) { 115 snippetCache[name] = eval(iifeWrapper(snippetMap[name])) 116 } 117 return snippetCache[name] 118 }, 119 } 120 ), 121 } 122 123 const logs: Log[] = [] 124 // logging only supported on frontend 125 if (!isBackendService()) { 126 // this counts the lines in the wrapped JS *before* the user's code, so that we can minus it 127 const jsLineCount = frontendWrapJS(js).split(js)[0].split("\n").length 128 const buildLogResponse = (type: LogType) => { 129 return (...props: any[]) => { 130 if (!isTest()) { 131 console[type](...props) 132 } 133 props.forEach((prop, index) => { 134 if (typeof prop === "object") { 135 props[index] = JSON.stringify(prop) 136 } 137 }) 138 // quick way to find out what line this is being called from 139 // its an anonymous function and we look for the overall length to find the 140 // line number we care about (from the users function) 141 // JS stack traces are in the format function:line:column 142 const lineNumber = new Error().stack?.match( 143 /<anonymous>:(\d+):\d+/ 144 )?.[1] 145 logs.push({ 146 log: props, 147 line: lineNumber ? parseInt(lineNumber) - jsLineCount : undefined, 148 type, 149 }) 150 } 151 } 152 sandboxContext.console = { 153 log: buildLogResponse("log"), 154 info: buildLogResponse("info"), 155 debug: buildLogResponse("debug"), 156 warn: buildLogResponse("warn"), 157 error: buildLogResponse("error"), 158 // table should be treated differently, but works the same 159 // as the rest of the logs for now 160 table: buildLogResponse("table"), 161 } 162 } 163 164 // Create a sandbox with our context and run the JS 165 const res = { data: runJS(js, sandboxContext), logs } 166 return `{{${LITERAL_MARKER} js_result-${JSON.stringify(res)}}}` 167 } catch (error: any) { 168 onErrorLog && onErrorLog(error) 169 170 const { noThrow = true } = context.__opts || {} 171 172 // The error handling below is quite messy, because it has fallen to 173 // string-templates to handle a variety of types of error specific to usages 174 // above it in the stack. It would be nice some day to refactor this to 175 // allow each user of processStringSync to handle errors in the way they see 176 // fit. 177 178 // This is to catch the error vm.runInNewContext() throws when the timeout 179 // is exceeded. 180 if (error.code === "ERR_SCRIPT_EXECUTION_TIMEOUT") { 181 return "Timed out while executing JS" 182 } 183 184 // This is to catch the JsRequestTimeoutError we throw when we detect a 185 // timeout across an entire request in the backend. We use a magic string 186 // because we can't import from the backend into string-templates. 187 if (error.code === "JS_REQUEST_TIMEOUT_ERROR") { 188 return error.message 189 } 190 191 // This is to catch the JsTimeoutError we throw when we detect a timeout in 192 // a single JS execution. 193 if (error.code === JsTimeoutError.code) { 194 return JsTimeoutError.message 195 } 196 197 // This is to catch the error that happens if a user-supplied JS script 198 // throws for reasons introduced by the user. 199 if (error.code === UserScriptError.code) { 200 if (noThrow) { 201 return error.userScriptError.toString() 202 } 203 throw error 204 } 205 206 if (error.name === "SyntaxError") { 207 if (noThrow) { 208 return error.toString() 209 } 210 throw error 211 } 212 213 return "Error while executing JS" 214 } 215 }