From 49ea0c5f6f1adc1c21f95eda437c64954d20236a Mon Sep 17 00:00:00 2001 From: Puneet Dixit <236133619+puneetdixit200@users.noreply.github.com> Date: Sat, 23 May 2026 22:52:17 +0530 Subject: [PATCH] fix: serialize ESM loader TypeScript errors --- dprint.json | 1 + src/esm.ts | 62 +++++++++++++++++++++--- src/test/esm-loader.spec.ts | 10 ++++ tests/esm-invalid-tsconfig/package.json | 3 ++ tests/esm-invalid-tsconfig/tsconfig.json | 1 + 5 files changed, 71 insertions(+), 6 deletions(-) create mode 100644 tests/esm-invalid-tsconfig/package.json create mode 100644 tests/esm-invalid-tsconfig/tsconfig.json diff --git a/dprint.json b/dprint.json index 76e7ebc73..135ffe3db 100644 --- a/dprint.json +++ b/dprint.json @@ -24,6 +24,7 @@ "/website/readme-sources", "/website/static", "tests/main-realpath/symlink/tsconfig.json", + "tests/esm-invalid-tsconfig/tsconfig.json", "tests/throw error.ts", "tests/throw error react tsx.tsx", "tests/esm/throw error.ts", diff --git a/src/esm.ts b/src/esm.ts index 9ca6f0dec..414d33fc4 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -1,4 +1,4 @@ -import { register, RegisterOptions, Service } from './index'; +import { register, RegisterOptions, Service, TSError } from './index'; import { parse as parseUrl, format as formatUrl, UrlWithStringQuery, fileURLToPath, pathToFileURL } from 'url'; import { extname, resolve as pathResolve } from 'path'; import * as assert from 'assert'; @@ -101,7 +101,12 @@ export function filterHooksByAPIVersion( /** @internal */ export function registerAndCreateEsmHooks(opts?: RegisterOptions) { // Automatically performs registration just like `-r ts-node/register` - const tsNodeInstance = register(opts); + let tsNodeInstance: Service; + try { + tsNodeInstance = register(opts); + } catch (error) { + throw makeSerializableLoaderError(error); + } return createEsmHooks(tsNodeInstance); } @@ -113,10 +118,10 @@ export function createEsmHooks(tsNodeService: Service) { const extensions = tsNodeService.extensions; const hooksAPI = filterHooksByAPIVersion({ - resolve, - load, - getFormat, - transformSource, + resolve: wrapHook(resolve), + load: wrapHook(load), + getFormat: wrapHook(getFormat), + transformSource: wrapHook(transformSource), }); function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) { @@ -356,3 +361,48 @@ async function addShortCircuitFlag(fn: () => Promise) { shortCircuit: true, }; } + +function wrapHook Promise>(hook: T): T { + return (async (...args: Parameters) => { + try { + return await hook(...args); + } catch (error) { + throw makeSerializableLoaderError(error); + } + }) as T; +} + +function makeSerializableLoaderError(error: unknown) { + if (error instanceof TSError || isTSError(error)) { + const serializable = new Error(error.message); + serializable.name = error.name; + if (typeof error.stack === 'string') { + serializable.stack = error.stack; + } + Object.defineProperty(serializable, 'diagnosticText', { + configurable: true, + enumerable: true, + writable: true, + value: error.diagnosticText, + }); + Object.defineProperty(serializable, 'diagnosticCodes', { + configurable: true, + enumerable: true, + writable: true, + value: error.diagnosticCodes, + }); + return serializable; + } + return error; +} + +function isTSError(error: unknown): error is TSError { + return ( + typeof error === 'object' && + error !== null && + (error as TSError).name === 'TSError' && + typeof (error as TSError).message === 'string' && + typeof (error as TSError).diagnosticText === 'string' && + Array.isArray((error as TSError).diagnosticCodes) + ); +} diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts index e0372a830..ddae2dddd 100644 --- a/src/test/esm-loader.spec.ts +++ b/src/test/esm-loader.spec.ts @@ -115,6 +115,16 @@ test.suite('esm', (test) => { expect(r.err).toBe(null); expect(r.stdout).toBe(''); }); + test('reports invalid tsconfig diagnostics from the ESM loader', async () => { + const r = await exec(`${CMD_ESM_LOADER_WITHOUT_PROJECT} -e "console.log(1)"`, { + cwd: join(TEST_DIR, './esm-invalid-tsconfig'), + }); + + expect(r.err).not.toBe(null); + expect(r.stderr).toMatch('Unable to compile TypeScript'); + expect(r.stderr).toMatch("tsconfig.json(1,5): error TS1005: ':' expected."); + expect(r.stderr).not.toMatch('[Object: null prototype]'); + }); test('should throw type errors without transpile-only enabled', async () => { const r = await exec(`${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`, { cwd: join(TEST_DIR, './esm-transpile-only'), diff --git a/tests/esm-invalid-tsconfig/package.json b/tests/esm-invalid-tsconfig/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/esm-invalid-tsconfig/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm-invalid-tsconfig/tsconfig.json b/tests/esm-invalid-tsconfig/tsconfig.json new file mode 100644 index 000000000..acec93b9c --- /dev/null +++ b/tests/esm-invalid-tsconfig/tsconfig.json @@ -0,0 +1 @@ +{ 1 }