diff options
Diffstat (limited to 'js')
-rw-r--r-- | js/deno.ts | 4 | ||||
-rw-r--r-- | js/error_stack.ts | 298 | ||||
-rw-r--r-- | js/error_stack_test.ts | 111 | ||||
-rw-r--r-- | js/globals.ts | 23 | ||||
-rw-r--r-- | js/main.ts | 3 | ||||
-rw-r--r-- | js/unit_tests.ts | 1 |
6 files changed, 440 insertions, 0 deletions
diff --git a/js/deno.ts b/js/deno.ts index 32899e045..8dc4fd791 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 000000000..e97020687 --- /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 += "<anonymous>"; + } + } + + 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 += "<anonymous>"; + } + + 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 += "<anonymous>"; + } + } 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 000000000..0df491f39 --- /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("<anonymous> (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 cff4b7dd9..27cef3909 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 cb27690b5..5687a6926 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 ff9f459e5..b55c1954a 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"; |