/ packages / string-templates / src / helpers / javascript.ts
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  }