diff --git a/cli/BUILD.gn b/cli/BUILD.gn index 7f19f8b952..84ffe6da47 100644 --- a/cli/BUILD.gn +++ b/cli/BUILD.gn @@ -83,6 +83,7 @@ ts_sources = [ "../js/dispatch_minimal.ts", "../js/dom_types.ts", "../js/dom_util.ts", + "../js/error_stack.ts", "../js/errors.ts", "../js/event.ts", "../js/event_target.ts", diff --git a/cli/msg.fbs b/cli/msg.fbs index 2ab776ef39..014bfb7e93 100644 --- a/cli/msg.fbs +++ b/cli/msg.fbs @@ -1,5 +1,6 @@ union Any { Accept, + ApplySourceMap, Cache, Chdir, Chmod, @@ -257,6 +258,12 @@ table FetchSourceFileRes { data: [ubyte]; } +table ApplySourceMap { + filename: string; + line: int; + column: int; +} + table Cache { extension: string; module_id: string; diff --git a/cli/ops.rs b/cli/ops.rs index d4bc94f75b..227bb03b37 100644 --- a/cli/ops.rs +++ b/cli/ops.rs @@ -20,6 +20,8 @@ use crate::resources; use crate::resources::table_entries; use crate::resources::Resource; use crate::signal::kill; +use crate::source_maps::get_orig_position; +use crate::source_maps::CachedMaps; use crate::startup_data; use crate::state::ThreadSafeState; use crate::tokio_util; @@ -46,6 +48,7 @@ use log; use rand::{thread_rng, Rng}; use remove_dir_all::remove_dir_all; use std; +use std::collections::HashMap; use std::convert::From; use std::fs; use std::net::Shutdown; @@ -194,6 +197,7 @@ pub fn dispatch_all_legacy( pub fn op_selector_std(inner_type: msg::Any) -> Option { match inner_type { msg::Any::Accept => Some(op_accept), + msg::Any::ApplySourceMap => Some(op_apply_source_map), msg::Any::Cache => Some(op_cache), msg::Any::Chdir => Some(op_chdir), msg::Any::Chmod => Some(op_chmod), @@ -532,6 +536,48 @@ fn op_fetch_source_file( Ok(Op::Sync(result_buf)) } +fn op_apply_source_map( + state: &ThreadSafeState, + base: &msg::Base<'_>, + data: Option, +) -> CliOpResult { + if !base.sync() { + return Err(deno_error::no_async_support()); + } + assert!(data.is_none()); + let inner = base.inner_as_apply_source_map().unwrap(); + let cmd_id = base.cmd_id(); + let filename = inner.filename().unwrap(); + let line = inner.line(); + let column = inner.column(); + + let mut mappings_map: CachedMaps = HashMap::new(); + let (orig_filename, orig_line, orig_column) = get_orig_position( + filename.to_owned(), + line.into(), + column.into(), + &mut mappings_map, + &state.ts_compiler, + ); + + let builder = &mut FlatBufferBuilder::new(); + let msg_args = msg::ApplySourceMapArgs { + filename: Some(builder.create_string(&orig_filename)), + line: orig_line as i32, + column: orig_column as i32, + }; + let res_inner = msg::ApplySourceMap::create(builder, &msg_args); + ok_buf(serialize_response( + cmd_id, + builder, + msg::BaseArgs { + inner: Some(res_inner.as_union_value()), + inner_type: msg::Any::ApplySourceMap, + ..Default::default() + }, + )) +} + fn op_chdir( _state: &ThreadSafeState, base: &msg::Base<'_>, diff --git a/cli/source_maps.rs b/cli/source_maps.rs index 6b453d883c..a886c6afc7 100644 --- a/cli/source_maps.rs +++ b/cli/source_maps.rs @@ -17,9 +17,9 @@ pub trait SourceMapGetter { /// Cached filename lookups. The key can be None if a previous lookup failed to /// find a SourceMap. -type CachedMaps = HashMap>; +pub type CachedMaps = HashMap>; -struct SourceMap { +pub struct SourceMap { mappings: Mappings, sources: Vec, } @@ -202,7 +202,7 @@ fn get_maybe_orig_position( } } -fn get_orig_position( +pub fn get_orig_position( script_name: String, line: i64, column: i64, diff --git a/js/deno.ts b/js/deno.ts index 32899e0456..8dc4fd791d 100644 --- a/js/deno.ts +++ b/js/deno.ts @@ -58,6 +58,7 @@ export { statSync, lstatSync, stat, lstat } from "./stat"; export { linkSync, link } from "./link"; export { symlinkSync, symlink } from "./symlink"; export { writeFileSync, writeFile, WriteFileOptions } from "./write_file"; +export { applySourceMap } from "./error_stack"; export { ErrorKind, DenoError } from "./errors"; export { permissions, @@ -88,6 +89,9 @@ export const args: string[] = []; /** @internal */ export { core } from "./core"; +/** @internal */ +export { setPrepareStackTrace } from "./error_stack"; + // TODO Don't expose Console nor stringifyArgs. /** @internal */ export { Console, stringifyArgs } from "./console"; diff --git a/js/error_stack.ts b/js/error_stack.ts new file mode 100644 index 0000000000..e97020687c --- /dev/null +++ b/js/error_stack.ts @@ -0,0 +1,298 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +// Some of the code here is adapted directly from V8 and licensed under a BSD +// style license available here: https://github.com/v8/v8/blob/24886f2d1c565287d33d71e4109a53bf0b54b75c/LICENSE.v8 + +import * as msg from "gen/cli/msg_generated"; +import * as flatbuffers from "./flatbuffers"; +import * as dispatch from "./dispatch"; +import { assert } from "./util"; + +export interface Location { + /** The full url for the module, e.g. `file://some/file.ts` or + * `https://some/file.ts`. */ + filename: string; + + /** The line number in the file. It is assumed to be 1-indexed. */ + line: number; + + /** The column number in the file. It is assumed to be 1-indexed. */ + column: number; +} + +function req( + filename: string, + line: number, + column: number +): [flatbuffers.Builder, msg.Any.ApplySourceMap, flatbuffers.Offset] { + const builder = flatbuffers.createBuilder(); + const filename_ = builder.createString(filename); + const inner = msg.ApplySourceMap.createApplySourceMap( + builder, + filename_, + // On this side, line/column are 1 based, but in the source maps, they are + // 0 based, so we have to convert back and forth + line - 1, + column - 1 + ); + return [builder, msg.Any.ApplySourceMap, inner]; +} + +function res(baseRes: msg.Base | null): Location { + assert(baseRes != null); + assert(baseRes!.innerType() === msg.Any.ApplySourceMap); + const res = new msg.ApplySourceMap(); + assert(baseRes!.inner(res) != null); + const filename = res.filename()!; + assert(filename != null); + return { + filename, + // On this side, line/column are 1 based, but in the source maps, they are + // 0 based, so we have to convert back and forth + line: res.line() + 1, + column: res.column() + 1 + }; +} + +/** Given a current location in a module, lookup the source location and + * return it. + * + * When Deno transpiles code, it keep source maps of the transpiled code. This + * function can be used to lookup the original location. This is automatically + * done when accessing the `.stack` of an error, or when an uncaught error is + * logged. This function can be used to perform the lookup for creating better + * error handling. + * + * **Note:** `line` and `column` are 1 indexed, which matches display + * expectations, but is not typical of most index numbers in Deno. + * + * An example: + * + * const orig = Deno.applySourceMap({ + * location: "file://my/module.ts", + * line: 5, + * column: 15 + * }); + * console.log(`${orig.filename}:${orig.line}:${orig.column}`); + * + */ +export function applySourceMap(location: Location): Location { + const { filename, line, column } = location; + return res(dispatch.sendSync(...req(filename, line, column))); +} + +/** Mutate the call site so that it returns the location, instead of its + * original location. + */ +function patchCallSite(callSite: CallSite, location: Location): CallSite { + return { + getThis(): unknown { + return callSite.getThis(); + }, + getTypeName(): string { + return callSite.getTypeName(); + }, + getFunction(): Function { + return callSite.getFunction(); + }, + getFunctionName(): string { + return callSite.getFunctionName(); + }, + getMethodName(): string { + return callSite.getMethodName(); + }, + getFileName(): string { + return location.filename; + }, + getLineNumber(): number { + return location.line; + }, + getColumnNumber(): number { + return location.column; + }, + getEvalOrigin(): string | null { + return callSite.getEvalOrigin(); + }, + isToplevel(): boolean { + return callSite.isToplevel(); + }, + isEval(): boolean { + return callSite.isEval(); + }, + isNative(): boolean { + return callSite.isNative(); + }, + isConstructor(): boolean { + return callSite.isConstructor(); + }, + isAsync(): boolean { + return callSite.isAsync(); + }, + isPromiseAll(): boolean { + return callSite.isPromiseAll(); + }, + getPromiseIndex(): number | null { + return callSite.getPromiseIndex(); + } + }; +} + +/** Return a string representations of a CallSite's method call name + * + * This is adapted directly from V8. + */ +function getMethodCall(callSite: CallSite): string { + let result = ""; + + const typeName = callSite.getTypeName(); + const methodName = callSite.getMethodName(); + const functionName = callSite.getFunctionName(); + + if (functionName) { + if (typeName) { + const startsWithTypeName = functionName.startsWith(typeName); + if (!startsWithTypeName) { + result += `${typeName}.`; + } + } + result += functionName; + + if (methodName) { + if (!functionName.endsWith(methodName)) { + result += ` [as ${methodName}]`; + } + } + } else { + if (typeName) { + result += `${typeName}.`; + } + if (methodName) { + result += methodName; + } else { + result += ""; + } + } + + return result; +} + +/** Return a string representations of a CallSite's file location + * + * This is adapted directly from V8. + */ +function getFileLocation(callSite: CallSite): string { + if (callSite.isNative()) { + return "native"; + } + + let result = ""; + + const fileName = callSite.getFileName(); + if (!fileName && callSite.isEval()) { + const evalOrigin = callSite.getEvalOrigin(); + assert(evalOrigin != null); + result += `${evalOrigin}, `; + } + + if (fileName) { + result += fileName; + } else { + result += ""; + } + + const lineNumber = callSite.getLineNumber(); + if (lineNumber != null) { + result += `:${lineNumber}`; + + const columnNumber = callSite.getColumnNumber(); + if (columnNumber != null) { + result += `:${columnNumber}`; + } + } + + return result; +} + +/** Convert a CallSite to a string. + * + * This is adapted directly from V8. + */ +function callSiteToString(callSite: CallSite): string { + let result = ""; + const functionName = callSite.getFunctionName(); + + const isTopLevel = callSite.isToplevel(); + const isAsync = callSite.isAsync(); + const isPromiseAll = callSite.isPromiseAll(); + const isConstructor = callSite.isConstructor(); + const isMethodCall = !(isTopLevel || isConstructor); + + if (isAsync) { + result += "async "; + } + if (isPromiseAll) { + result += `Promise.all (index ${callSite.getPromiseIndex})`; + return result; + } + if (isMethodCall) { + result += getMethodCall(callSite); + } else if (isConstructor) { + result += "new "; + if (functionName) { + result += functionName; + } else { + result += ""; + } + } else if (functionName) { + result += functionName; + } else { + result += getFileLocation(callSite); + return result; + } + + result += ` (${getFileLocation(callSite)})`; + return result; +} + +/** A replacement for the default stack trace preparer which will op into Rust + * to apply source maps to individual sites + */ +function prepareStackTrace( + error: Error, + structuredStackTrace: CallSite[] +): string { + return ( + `${error.name}: ${error.message}\n` + + structuredStackTrace + .map( + (callSite): CallSite => { + const filename = callSite.getFileName(); + const line = callSite.getLineNumber(); + const column = callSite.getColumnNumber(); + if (filename && line != null && column != null) { + return patchCallSite( + callSite, + applySourceMap({ + filename, + line, + column + }) + ); + } + return callSite; + } + ) + .map((callSite): string => ` at ${callSiteToString(callSite)}`) + .join("\n") + ); +} + +/** Sets the `prepareStackTrace` method on the Error constructor which will + * op into Rust to remap source code for caught errors where the `.stack` is + * being accessed. + * + * See: https://v8.dev/docs/stack-trace-api + */ +// @internal +export function setPrepareStackTrace(ErrorConstructor: typeof Error): void { + ErrorConstructor.prepareStackTrace = prepareStackTrace; +} diff --git a/js/error_stack_test.ts b/js/error_stack_test.ts new file mode 100644 index 0000000000..0df491f392 --- /dev/null +++ b/js/error_stack_test.ts @@ -0,0 +1,111 @@ +// Copyright 2018-2019 the Deno authors. All rights reserved. MIT license. +import { test, assert } from "./test_util.ts"; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const { setPrepareStackTrace } = Deno as any; + +interface CallSite { + getThis(): unknown; + getTypeName(): string; + getFunction(): Function; + getFunctionName(): string; + getMethodName(): string; + getFileName(): string; + getLineNumber(): number | null; + getColumnNumber(): number | null; + getEvalOrigin(): string | null; + isToplevel(): boolean; + isEval(): boolean; + isNative(): boolean; + isConstructor(): boolean; + isAsync(): boolean; + isPromiseAll(): boolean; + getPromiseIndex(): number | null; +} + +function getMockCallSite( + filename: string, + line: number | null, + column: number | null +): CallSite { + return { + getThis(): unknown { + return undefined; + }, + getTypeName(): string { + return ""; + }, + getFunction(): Function { + return (): void => {}; + }, + getFunctionName(): string { + return ""; + }, + getMethodName(): string { + return ""; + }, + getFileName(): string { + return filename; + }, + getLineNumber(): number | null { + return line; + }, + getColumnNumber(): number | null { + return column; + }, + getEvalOrigin(): null { + return null; + }, + isToplevel(): false { + return false; + }, + isEval(): false { + return false; + }, + isNative(): false { + return false; + }, + isConstructor(): false { + return false; + }, + isAsync(): false { + return false; + }, + isPromiseAll(): false { + return false; + }, + getPromiseIndex(): null { + return null; + } + }; +} + +test(function prepareStackTrace(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const MockError = {} as any; + setPrepareStackTrace(MockError); + assert(typeof MockError.prepareStackTrace === "function"); + const prepareStackTrace: ( + error: Error, + structuredStackTrace: CallSite[] + ) => string = MockError.prepareStackTrace; + const result = prepareStackTrace(new Error("foo"), [ + getMockCallSite("gen/cli/bundle/main.js", 23, 0) + ]); + assert(result.startsWith("Error: foo\n")); + assert( + result.includes(" (js/"), + "should remap to something in 'js/'" + ); +}); + +test(function applySourceMap(): void { + const result = Deno.applySourceMap({ + filename: "gen/cli/bundle/main.js", + line: 23, + column: 0 + }); + assert(result.filename.startsWith("js/")); + assert(result.line != null); + assert(result.column != null); +}); diff --git a/js/globals.ts b/js/globals.ts index cff4b7dd9c..27cef39098 100644 --- a/js/globals.ts +++ b/js/globals.ts @@ -40,6 +40,29 @@ import { immutableDefine } from "./util"; declare global { const console: consoleTypes.Console; const setTimeout: typeof timers.setTimeout; + + interface CallSite { + getThis(): unknown; + getTypeName(): string; + getFunction(): Function; + getFunctionName(): string; + getMethodName(): string; + getFileName(): string; + getLineNumber(): number | null; + getColumnNumber(): number | null; + getEvalOrigin(): string | null; + isToplevel(): boolean; + isEval(): boolean; + isNative(): boolean; + isConstructor(): boolean; + isAsync(): boolean; + isPromiseAll(): boolean; + getPromiseIndex(): number | null; + } + + interface ErrorConstructor { + prepareStackTrace(error: Error, structuredStackTrace: CallSite[]): string; + } } // A self reference to the global object. diff --git a/js/main.ts b/js/main.ts index cb27690b57..5687a69268 100644 --- a/js/main.ts +++ b/js/main.ts @@ -8,6 +8,7 @@ import "./globals"; import { assert, log } from "./util"; import * as os from "./os"; import { args } from "./deno"; +import { setPrepareStackTrace } from "./error_stack"; import { replLoop } from "./repl"; import { xevalMain, XevalFunc } from "./xeval"; import { setVersions } from "./version"; @@ -30,6 +31,8 @@ export default function denoMain(name?: string): void { os.exit(0); } + setPrepareStackTrace(Error); + const mainModule = startResMsg.mainModule(); if (mainModule) { assert(mainModule.length > 0); diff --git a/js/unit_tests.ts b/js/unit_tests.ts index ff9f459e51..b55c1954aa 100644 --- a/js/unit_tests.ts +++ b/js/unit_tests.ts @@ -13,6 +13,7 @@ import "./console_test.ts"; import "./copy_file_test.ts"; import "./custom_event_test.ts"; import "./dir_test.ts"; +import "./error_stack_test.ts"; import "./event_test.ts"; import "./event_target_test.ts"; import "./fetch_test.ts"; diff --git a/tests/error_stack.test b/tests/error_stack.test new file mode 100644 index 0000000000..88de56958a --- /dev/null +++ b/tests/error_stack.test @@ -0,0 +1,4 @@ +args: run --reload tests/error_stack.ts +check_stderr: true +exit_code: 1 +output: tests/error_stack.ts.out diff --git a/tests/error_stack.ts b/tests/error_stack.ts new file mode 100644 index 0000000000..f2125d662f --- /dev/null +++ b/tests/error_stack.ts @@ -0,0 +1,10 @@ +function foo(): never { + throw new Error("foo"); +} + +try { + foo(); +} catch (e) { + console.log(e); + throw e; +} diff --git a/tests/error_stack.ts.out b/tests/error_stack.ts.out new file mode 100644 index 0000000000..2bb629e2d3 --- /dev/null +++ b/tests/error_stack.ts.out @@ -0,0 +1,6 @@ +[WILDCARD]Error: foo + at foo ([WILDCARD]tests/error_stack.ts:2:9) + at [WILDCARD]tests/error_stack.ts:6:3 +error: Uncaught Error: foo + at foo ([WILDCARD]tests/error_stack.ts:2:9) + at [WILDCARD]tests/error_stack.ts:6:3