diff --git a/lib/runner/CircuitRunner.ts b/lib/runner/CircuitRunner.ts index 968dd38..7156d36 100644 --- a/lib/runner/CircuitRunner.ts +++ b/lib/runner/CircuitRunner.ts @@ -10,6 +10,7 @@ import type { RootCircuit } from "@tscircuit/core" import * as React from "react" import { importEvalPath } from "webworker/import-eval-path" import { setupDefaultEntrypointIfNeeded } from "./setupDefaultEntrypointIfNeeded" +import { addSourceLineToError } from "../utils/addSourceLineToError" export class CircuitRunner implements CircuitRunnerApi { _executionContext: ReturnType | null = null @@ -62,7 +63,16 @@ export class CircuitRunner implements CircuitRunnerApi { ? opts.entrypoint : `./${opts.entrypoint}` - await importEvalPath(entrypoint!, this._executionContext) + try { + await importEvalPath(entrypoint!, this._executionContext) + } catch (error: any) { + await addSourceLineToError( + error, + this._executionContext.fsMap, + this._executionContext.sourceMaps, + ) + throw error + } } async execute(code: string, opts: { name?: string } = {}) { @@ -84,7 +94,16 @@ export class CircuitRunner implements CircuitRunnerApi { this._executionContext.fsMap["entrypoint.tsx"] = code ;(globalThis as any).__tscircuit_circuit = this._executionContext.circuit - await importEvalPath("./entrypoint.tsx", this._executionContext) + try { + await importEvalPath("./entrypoint.tsx", this._executionContext) + } catch (error: any) { + await addSourceLineToError( + error, + this._executionContext.fsMap, + this._executionContext.sourceMaps, + ) + throw error + } } on(event: string, callback: (...args: any[]) => void) { diff --git a/lib/utils/addSourceLineToError.ts b/lib/utils/addSourceLineToError.ts new file mode 100644 index 0000000..9c62d65 --- /dev/null +++ b/lib/utils/addSourceLineToError.ts @@ -0,0 +1,40 @@ +import { SourceMapConsumer } from "source-map" +import type { RawSourceMap } from "source-map" + +export async function addSourceLineToError( + error: Error, + fsMap: Record, + sourceMaps?: Record, +) { + if (!error || typeof error !== "object") return + const stack = (error as any).stack as string | undefined + if (!stack) return + + const escapeRegExp = (str: string) => + str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + + for (const path of Object.keys(fsMap)) { + const regex = new RegExp(escapeRegExp(path) + ":(\\d+):(\\d+)") + const match = stack.match(regex) + if (match) { + const line = parseInt(match[1], 10) + let originalLine = line + if (sourceMaps && sourceMaps[path]) { + const consumer = await new SourceMapConsumer(sourceMaps[path]) + const pos = consumer.originalPositionFor({ line: line - 6, column: 0 }) + consumer.destroy() + if (pos && pos.line) { + originalLine = pos.line + } + } + const lines = fsMap[path].split(/\r?\n/) + const sourceLine = lines[originalLine - 1] + if (sourceLine !== undefined) { + error.message += `\n\n> ${path}:${originalLine}\n> ${sourceLine.trim()}` + } else { + error.message += `\n\n> ${path}:${originalLine}` + } + break + } + } +} diff --git a/lib/utils/index.ts b/lib/utils/index.ts index d8ad13c..9e64249 100644 --- a/lib/utils/index.ts +++ b/lib/utils/index.ts @@ -1 +1,2 @@ export * from "./get-imports-from-code" +export * from "./addSourceLineToError" diff --git a/tests/execution-error-line.test.tsx b/tests/execution-error-line.test.tsx new file mode 100644 index 0000000..2f4e4d1 --- /dev/null +++ b/tests/execution-error-line.test.tsx @@ -0,0 +1,24 @@ +import { expect, test } from "bun:test" +import { createCircuitWebWorker } from "lib/index" + +// Ensure that when runtime errors occur, the offending line is included + +test("execution error includes source line", async () => { + const worker = await createCircuitWebWorker({ + webWorkerUrl: new URL("../webworker/entrypoint.ts", import.meta.url), + }) + + await expect( + worker.executeWithFsMap({ + entrypoint: "index.tsx", + fsMap: { + "index.tsx": ` + circuit.add() + throw new Error("boom") + `, + }, + }), + ).rejects.toThrow(/boom\n[\s\S]*index.tsx:3/) + + await worker.kill() +}) diff --git a/webworker/entrypoint.ts b/webworker/entrypoint.ts index 77b7333..2353da9 100644 --- a/webworker/entrypoint.ts +++ b/webworker/entrypoint.ts @@ -14,6 +14,7 @@ import { importEvalPath } from "./import-eval-path" import { normalizeFsMap } from "../lib/runner/normalizeFsMap" import type { RootCircuit } from "@tscircuit/core" import { setupDefaultEntrypointIfNeeded } from "lib/runner/setupDefaultEntrypointIfNeeded" +import { addSourceLineToError } from "lib/utils/addSourceLineToError" globalThis.React = React @@ -76,7 +77,16 @@ const webWorkerApi = { entrypoint = `./${entrypoint}` } - await importEvalPath(entrypoint, executionContext) + try { + await importEvalPath(entrypoint, executionContext) + } catch (error: any) { + await addSourceLineToError( + error, + executionContext.fsMap, + executionContext.sourceMaps, + ) + throw error + } }, async execute(code: string, opts: { name?: string } = {}) { @@ -91,7 +101,16 @@ const webWorkerApi = { executionContext.fsMap["entrypoint.tsx"] = code ;(globalThis as any).__tscircuit_circuit = executionContext.circuit - await importEvalPath("./entrypoint.tsx", executionContext) + try { + await importEvalPath("./entrypoint.tsx", executionContext) + } catch (error: any) { + await addSourceLineToError( + error, + executionContext.fsMap, + executionContext.sourceMaps, + ) + throw error + } }, on: (event: string, callback: (...args: any[]) => void) => { diff --git a/webworker/eval-compiled-js.ts b/webworker/eval-compiled-js.ts index 50e82bb..3c39880 100644 --- a/webworker/eval-compiled-js.ts +++ b/webworker/eval-compiled-js.ts @@ -4,6 +4,7 @@ export function evalCompiledJs( compiledCode: string, preSuppliedImports: Record, cwd?: string, + sourceUrl?: string, ) { ;(globalThis as any).__tscircuit_require = (name: string) => { const resolvedFilePath = resolveFilePath(name, preSuppliedImports, cwd) @@ -56,6 +57,7 @@ export function evalCompiledJs( var module = { exports }; var circuit = globalThis.__tscircuit_circuit; ${compiledCode}; + //# sourceURL=${sourceUrl ?? "compiled.ts"} return module;`.trim() return Function(functionBody).call(globalThis) } diff --git a/webworker/execution-context.ts b/webworker/execution-context.ts index e3cac04..aa1c5f7 100644 --- a/webworker/execution-context.ts +++ b/webworker/execution-context.ts @@ -11,6 +11,7 @@ export interface ExecutionContext extends WebWorkerConfiguration { fsMap: Record entrypoint: string preSuppliedImports: Record + sourceMaps: Record circuit: RootCircuit } @@ -45,6 +46,7 @@ export function createExecutionContext( // ignore type imports in getImportsFromCode "@tscircuit/props": {}, }, + sourceMaps: {}, circuit, ...webWorkerConfiguration, } diff --git a/webworker/import-local-file.ts b/webworker/import-local-file.ts index 1191405..ef9e6b7 100644 --- a/webworker/import-local-file.ts +++ b/webworker/import-local-file.ts @@ -2,6 +2,7 @@ import * as Babel from "@babel/standalone" import { resolveFilePathOrThrow } from "lib/runner/resolveFilePath" import { dirname } from "lib/utils/dirname" import { getImportsFromCode } from "lib/utils/get-imports-from-code" +import { addSourceLineToError } from "lib/utils/addSourceLineToError" import { evalCompiledJs } from "./eval-compiled-js" import type { ExecutionContext } from "./execution-context" import { importEvalPath } from "./import-eval-path" @@ -38,8 +39,12 @@ export const importLocalFile = async ( const result = Babel.transform(fileContent, { presets: ["react", "typescript"], plugins: ["transform-modules-commonjs"], - filename: "virtual.tsx", + filename: fsPath, + sourceMaps: true, }) + if (result.map) { + ctx.sourceMaps[fsPath] = result.map + } if (!result || !result.code) { throw new Error("Failed to transform code") @@ -50,9 +55,11 @@ export const importLocalFile = async ( result.code, preSuppliedImports, dirname(fsPath), + fsPath, ) preSuppliedImports[fsPath] = importRunResult.exports } catch (error: any) { + await addSourceLineToError(error, ctx.fsMap, ctx.sourceMaps) throw new Error( `Eval compiled js error for "${importName}": ${error.message}`, ) @@ -63,7 +70,11 @@ export const importLocalFile = async ( presets: ["env"], plugins: ["transform-modules-commonjs"], filename: fsPath, + sourceMaps: true, }) + if (result.map) { + ctx.sourceMaps[fsPath] = result.map + } if (!result || !result.code) { throw new Error("Failed to transform JS code") @@ -73,6 +84,7 @@ export const importLocalFile = async ( result.code, preSuppliedImports, dirname(fsPath), + fsPath, ).exports } else { throw new Error( diff --git a/webworker/import-snippet.ts b/webworker/import-snippet.ts index 8785a05..82e6590 100644 --- a/webworker/import-snippet.ts +++ b/webworker/import-snippet.ts @@ -25,6 +25,8 @@ export async function importSnippet( preSuppliedImports[importName] = evalCompiledJs( cjs!, preSuppliedImports, + undefined, + importName, ).exports } catch (e) { console.error("Error importing snippet", e)