diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index d59d6e77e973..8d7689b5f09e 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -1604,18 +1604,30 @@ Most of the time, an explicit try/catch is not needed, as Bun prints uncaught ex Each item in `error.errors` is an instance of `BuildMessage` or `ResolveMessage` (subclasses of `Error`), containing detailed information for each error. ```ts title="build.ts" icon="/icons/typescript.svg" -class BuildMessage { - name: string; - position?: Position; +class BuildMessage extends Error { + name: "BuildMessage"; + position: Position | null; message: string; level: "error" | "warning" | "info" | "debug" | "verbose"; + notes: BuildMessage[]; // related locations, each with level "note" + line: number; // zero-based; position.line is one-based + column: number; // zero-based; position.column is one-based + toJSON(): Pick; } -class ResolveMessage extends BuildMessage { +class ResolveMessage extends Error { + name: "ResolveMessage"; + position: Position | null; + message: string; + level: "error" | "warning" | "info" | "debug" | "verbose"; code: string; + requireStack: string[] | undefined; // only set when a require() failed referrer: string; specifier: string; importKind: ImportKind; + line: number; // zero-based; position.line is one-based + column: number; // zero-based; position.column is one-based + toJSON(): Pick; } ``` @@ -1806,11 +1818,23 @@ interface BuildOutput { logs: Array; } -declare class ResolveMessage { +interface Position { + lineText: string; + file: string; + namespace: string; + line: number; // one-based + column: number; // one-based + length: number; + offset: number; +} + +declare class ResolveMessage extends Error { readonly name: "ResolveMessage"; readonly position: Position | null; readonly code: string; readonly message: string; + stack: string; // "ResolveMessage: ", no stack frames + readonly requireStack: string[] | undefined; // only set when a require() failed readonly referrer: string; readonly specifier: string; readonly importKind: @@ -1825,8 +1849,24 @@ declare class ResolveMessage { | "url" | "internal"; readonly level: "error" | "warning" | "info" | "debug" | "verbose"; + readonly line: number; // zero-based, 0 without a position + readonly column: number; // zero-based, 0 without a position + + toString(): string; // "ResolveMessage: " + toJSON(): Pick; +} + +declare class BuildMessage extends Error { + readonly name: "BuildMessage"; + readonly position: Position | null; + readonly message: string; + readonly level: "error" | "warning" | "info" | "debug" | "verbose"; + readonly notes: BuildMessage[]; // related locations, each with level "note" + readonly line: number; // zero-based, 0 without a position + readonly column: number; // zero-based, 0 without a position - toString(): string; + toString(): string; // "BuildMessage: " + toJSON(): Pick; } ``` diff --git a/packages/bun-types/globals.d.ts b/packages/bun-types/globals.d.ts index d576bcfcb400..d24ab21f873f 100644 --- a/packages/bun-types/globals.d.ts +++ b/packages/bun-types/globals.d.ts @@ -989,11 +989,28 @@ interface Position { offset: number; } -declare class ResolveMessage { +/** + * A module specifier that could not be resolved. Thrown by `import`, + * `require()` and {@link Bun.resolveSync}, and reported in + * {@link Bun.BuildOutput.logs} by {@link Bun.build}. + */ +declare class ResolveMessage extends Error { readonly name: "ResolveMessage"; readonly position: Position | null; readonly code: string; readonly message: string; + /** + * `"ResolveMessage: "`, with no `at ...` frames: no JavaScript + * stack is captured when resolution fails. + */ + stack: string; + /** + * Like the `requireStack` of a Node.js `MODULE_NOT_FOUND` error: the module + * whose `require()` or `require.resolve()` call did not find {@link specifier} + * (only the direct caller is recorded). `undefined` for every other failure, + * including everything reached through `import`. + */ + readonly requireStack: string[] | undefined; readonly referrer: string; readonly specifier: string; readonly importKind: @@ -1008,15 +1025,69 @@ declare class ResolveMessage { | "url" | "internal"; readonly level: "error" | "warning" | "info" | "debug" | "verbose"; + /** + * Zero-based line of {@link position}, or `0` when there is no position. + * `position.line` is the same line, one-based. + */ + readonly line: number; + /** + * Zero-based column of {@link position}, or `0` when there is no position. + * `position.column` is the same column, one-based. + */ + readonly column: number; + /** + * `"ResolveMessage: "`, which is also what converting the object to + * a string (`String(...)`, a template literal) produces. + */ toString(): string; + /** + * The fields `JSON.stringify()` serializes this message as. + */ + toJSON(): Pick; + [Symbol.toPrimitive](hint: "default" | "string"): string; + [Symbol.toPrimitive](hint: string): string | null; } -declare class BuildMessage { +/** + * An error, warning or note produced while parsing or bundling a module. + * Thrown by `import` and `require()` of a file with a syntax error, and + * reported in {@link Bun.BuildOutput.logs} by {@link Bun.build}. + */ +declare class BuildMessage extends Error { readonly name: "BuildMessage"; readonly position: Position | null; readonly message: string; readonly level: "error" | "warning" | "info" | "debug" | "verbose"; + /** + * Secondary locations attached to this message, each a `BuildMessage` whose + * `level` is `"note"`, such as the original declaration behind a + * "has already been declared" error. Empty when there are none. + */ + readonly notes: BuildMessage[]; + /** + * Zero-based line of {@link position}, or `0` when there is no position. + * `position.line` is the same line, one-based. + */ + readonly line: number; + /** + * Zero-based column of {@link position}, or `0` when there is no position. + * `position.column` is the same column, one-based. + */ + readonly column: number; + + /** + * `"BuildMessage: "`, which is also what converting the object to a + * string (`String(...)`, a template literal) produces. + */ + toString(): string; + /** + * The fields `JSON.stringify()` serializes this message as. {@link notes} + * are not included. + */ + toJSON(): Pick; + [Symbol.toPrimitive](hint: "default" | "string"): string; + [Symbol.toPrimitive](hint: string): string | null; } interface ErrorOptions { diff --git a/test/integration/bun-types/bun-types.test.ts b/test/integration/bun-types/bun-types.test.ts index 05fa801f45e5..a9a3597b000a 100644 --- a/test/integration/bun-types/bun-types.test.ts +++ b/test/integration/bun-types/bun-types.test.ts @@ -1,6 +1,6 @@ import { $ as Shell, fileURLToPath } from "bun"; import { afterAll, beforeAll, describe, expect, setDefaultTimeout, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, makeTree } from "harness"; +import { bunEnv, bunExe, isDebug, makeTree, tempDir } from "harness"; import { existsSync, readFileSync } from "node:fs"; import { cp, mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -369,34 +369,143 @@ describe("@types/bun integration test", () => { // Runs on debug builds too: spawning tsc over a single file is cheap, // unlike the in-process LanguageService runs above. + async function expectToTypecheck(dirName: string, fileName: string, source: string) { + const checkDir = join(TEMP_DIR, dirName); + const tsconfig = structuredClone(sourceTsconfig); + tsconfig.include = [fileName]; + tsconfig.compilerOptions.typeRoots = [join(BASE_FIXTURE_DIR, "node_modules", "@types")]; + await mkdir(checkDir, { recursive: true }); + await makeTree(checkDir, { + "tsconfig.json": JSON.stringify(tsconfig, null, 2), + [fileName]: source, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(BASE_FIXTURE_DIR, "node_modules", "typescript", "bin", "tsc"), "-p", "."], + env: bunEnv, + cwd: checkDir, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr.trim()).toBe(""); + expect(stdout.trim()).toBe(""); + expect(exitCode).toBe(0); + } + describe("Bun.mmap", () => { test("MMapOptions accepts offset and size", async () => { - const checkDir = join(TEMP_DIR, "mmap-options-check"); - const tsconfig = structuredClone(sourceTsconfig); - tsconfig.include = ["mmap-options.ts"]; - tsconfig.compilerOptions.typeRoots = [join(BASE_FIXTURE_DIR, "node_modules", "@types")]; - await mkdir(checkDir, { recursive: true }); - await makeTree(checkDir, { - "tsconfig.json": JSON.stringify(tsconfig, null, 2), - "mmap-options.ts": `const view = Bun.mmap("./data.bin", { shared: true, sync: false, offset: 4096, size: 1024 }); - view satisfies Uint8Array; - Bun.mmap("./data.bin", { offset: 4096 }) satisfies Uint8Array; - Bun.mmap("./data.bin", { size: 1024 }) satisfies Uint8Array;`, - }); + await expectToTypecheck( + "mmap-options-check", + "mmap-options.ts", + `const view = Bun.mmap("./data.bin", { shared: true, sync: false, offset: 4096, size: 1024 }); + view satisfies Uint8Array; + Bun.mmap("./data.bin", { offset: 4096 }) satisfies Uint8Array; + Bun.mmap("./data.bin", { size: 1024 }) satisfies Uint8Array;`, + ); + }); + }); - await using proc = Bun.spawn({ - cmd: [bunExe(), join(BASE_FIXTURE_DIR, "node_modules", "typescript", "bin", "tsc"), "-p", "."], - env: bunEnv, - cwd: checkDir, - stdout: "pipe", - stderr: "pipe", + describe("BuildMessage / ResolveMessage", () => { + test("declare the Error base and the members the runtime objects have", async () => { + using dir = tempDir("bun-types-message-members", { + "redeclared.ts": "let a = 1;\nlet a = 2;\n", + "missing-import.ts": 'import "./missing";\n', }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const { logs } = await Bun.build({ + entrypoints: [join(String(dir), "redeclared.ts"), join(String(dir), "missing-import.ts")], + throw: false, + }); + const buildMessage = logs.find(log => log instanceof BuildMessage)!; + const resolveMessage = logs.find(log => log instanceof ResolveMessage)!; + let requireFailure: unknown; + try { + require(join(String(dir), "missing-require")); + } catch (e) { + requireFailure = e; + } + if (!(requireFailure instanceof ResolveMessage)) throw new Error("require() did not throw a ResolveMessage"); + + const buildJsonKeys = Object.keys(buildMessage.toJSON()); + const resolveJsonKeys = Object.keys(resolveMessage.toJSON()); + + // The runtime shape the declarations below have to mirror. + expect({ + errors: [buildMessage instanceof Error, resolveMessage instanceof Error], + stacks: [buildMessage.stack, resolveMessage.stack], + notes: buildMessage.notes.map((note): [boolean, string] => [note instanceof BuildMessage, note.level]), + // zero-based [line, column], unlike the one-based position.line / position.column + locations: [ + [buildMessage.line, buildMessage.column, buildMessage.position?.line, buildMessage.position?.column], + [resolveMessage.line, resolveMessage.column, resolveMessage.position?.line, resolveMessage.position?.column], + ], + requireStacks: [resolveMessage.requireStack, requireFailure.requireStack], + strings: [`${buildMessage}`, buildMessage[Symbol.toPrimitive]("number")], + json: { build: buildJsonKeys, resolve: resolveJsonKeys }, + }).toEqual({ + errors: [true, true], + stacks: [undefined, `ResolveMessage: ${resolveMessage.message}`], + notes: [[true, "note"]], + locations: [ + [1, 4, 2, 5], + [0, 7, 1, 8], + ], + requireStacks: [undefined, [expect.any(String)]], + strings: [`BuildMessage: ${buildMessage.message}`, null], + json: { + build: ["name", "position", "message", "level"], + resolve: ["name", "position", "message", "level", "specifier", "importKind", "referrer"], + }, + }); - expect(stderr.trim()).toBe(""); - expect(stdout.trim()).toBe(""); - expect(exitCode).toBe(0); + await expectToTypecheck( + "build-message-members-check", + "build-logs.ts", + [ + `declare const output: Bun.BuildOutput;`, + `for (const log of output.logs) {`, + ` log.stack satisfies string | undefined;`, + ` log.cause satisfies unknown;`, + ` log.line satisfies number;`, + ` log.column satisfies number;`, + ` log[Symbol.toPrimitive]("default") satisfies string;`, + ` log[Symbol.toPrimitive]("number") satisfies string | null;`, + ` if (log instanceof ResolveMessage) {`, + ` log.stack satisfies string;`, + ` log.requireStack satisfies string[] | undefined;`, + ` const json = log.toJSON();`, + ...resolveJsonKeys.map(key => ` json.${key};`), + ` // @ts-expect-error - not serialized`, + ` json.code;`, + ` // @ts-expect-error - not serialized`, + ` json.requireStack;`, + ` } else {`, + ` log.notes satisfies BuildMessage[];`, + ` log.notes[0]?.notes satisfies BuildMessage[] | undefined;`, + ` const json = log.toJSON();`, + ...buildJsonKeys.map(key => ` json.${key};`), + ` // @ts-expect-error - not serialized`, + ` json.notes;`, + ` // @ts-expect-error - only ResolveMessage has requireStack`, + ` log.requireStack;`, + ` }`, + `}`, + `try {`, + ` require("./missing");`, + `} catch (e) {`, + ` if (e instanceof ResolveMessage) {`, + ` const requireStack: string[] = e.requireStack ?? [];`, + ` console.error(e.stack, requireStack, e.line, e.column);`, + ` } else if (e instanceof BuildMessage) {`, + ` const error: Error = e;`, + ` console.error(error.stack, e.notes, e.line, e.column);`, + ` }`, + `}`, + ].join("\n"), + ); }); }); diff --git a/test/integration/bun-types/fixture/build.ts b/test/integration/bun-types/fixture/build.ts index c87e2459cabb..02b39dd42b89 100644 --- a/test/integration/bun-types/fixture/build.ts +++ b/test/integration/bun-types/fixture/build.ts @@ -49,6 +49,58 @@ Bun.build({ expectType(result.success).is(); expectType(result.outputs).is(); expectType(result.logs).is>(); + + for (const log of result.logs) { + // Both message classes extend Error and share the location getters. + expectAssignable(log); + expectType(log.cause).is(); + expectType(log.position).is(); + expectType(log.line).is(); + expectType(log.column).is(); + expectType(log.toString()).is(); + expectType(log[Symbol.toPrimitive]("default")).is(); + expectType(log[Symbol.toPrimitive]("number")).is(); + + if (log instanceof ResolveMessage) { + expectType(log.name).is<"ResolveMessage">(); + expectType(log.stack).is(); + expectType(log.requireStack).is(); + + const json = log.toJSON(); + expectType(json.name).is<"ResolveMessage">(); + expectType(json.position).is(); + expectType(json.message).is(); + expectType(json.level).is(); + expectType(json.specifier).is(); + expectType(json.importKind).is(); + expectType(json.referrer).is(); + // @ts-expect-error - toJSON() does not serialize code + json.code; + // @ts-expect-error - notes only exist on BuildMessage + log.notes; + } else { + expectType(log).is(); + expectType(log.name).is<"BuildMessage">(); + // Inherited from Error: a BuildMessage has no stack of its own. + expectType(log.stack).is(); + expectType(log.notes).is(); + for (const note of log.notes) { + expectType(note.level).is(); + expectType(note.position).is(); + expectType(note.notes).is(); + } + + const json = log.toJSON(); + expectType(json.name).is<"BuildMessage">(); + expectType(json.position).is(); + expectType(json.message).is(); + expectType(json.level).is(); + // @ts-expect-error - toJSON() does not serialize notes + json.notes; + // @ts-expect-error - requireStack only exists on ResolveMessage + log.requireStack; + } + } }); build.onBeforeParse( @@ -66,3 +118,17 @@ Bun.build({ }, ], }); + +// Failed imports and require() calls throw the same classes. +try { + Bun.resolveSync("./missing", import.meta.dir); +} catch (e) { + if (e instanceof ResolveMessage) { + const requireStack: string[] = e.requireStack ?? []; + const stack: string = e.stack; + console.error(stack, requireStack, `${e.specifier} at ${e.line}:${e.column}`); + } else if (e instanceof BuildMessage) { + const error: Error = e; + console.error(error.stack, e.notes.length, `${e.position?.file} at ${e.line}:${e.column}`); + } +}