From 026f8358006eea5651961070d77105111e1a37a2 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 3 Nov 2023 12:06:13 -0600 Subject: [PATCH 1/4] fix: support jsonc tsconfig --- src/config/ts-node.ts | 41 +++++++++++++++++++++++++++--------- test/config/ts-node.test.ts | 42 ++++++++++++++----------------------- 2 files changed, 47 insertions(+), 36 deletions(-) diff --git a/src/config/ts-node.ts b/src/config/ts-node.ts index 28d2ed7a0..eded54e39 100644 --- a/src/config/ts-node.ts +++ b/src/config/ts-node.ts @@ -1,10 +1,11 @@ +import {readFile} from 'node:fs/promises' import {join, relative as pathRelative, sep} from 'node:path' import * as TSNode from 'ts-node' import {memoizedWarn} from '../errors' import {Plugin, TSConfig} from '../interfaces' import {settings} from '../settings' -import {existsSync, readJson} from '../util/fs' +import {existsSync} from '../util/fs' import {isProd} from '../util/util' import Cache from './cache' import {Debug} from './util' @@ -15,17 +16,39 @@ const debug = Debug('ts-node') export const TS_CONFIGS: Record = {} const REGISTERED = new Set() +function importTypescript(root: string) { + let typescript: typeof import('typescript') | undefined + try { + typescript = require('typescript') + } catch { + try { + typescript = require(require.resolve('typescript', {paths: [root, __dirname]})) + } catch { + debug(`Could not find typescript dependency. Skipping ts-node registration for ${root}.`) + memoizedWarn( + 'Could not find typescript. Please ensure that typescript is a devDependency. Falling back to compiled source.', + ) + return + } + } + + return typescript +} + async function loadTSConfig(root: string): Promise { try { if (TS_CONFIGS[root]) return TS_CONFIGS[root] const tsconfigPath = join(root, 'tsconfig.json') - const tsconfig = await readJson(tsconfigPath) + const typescript = importTypescript(root) + if (!typescript) return - if (!tsconfig || Object.keys(tsconfig.compilerOptions).length === 0) return + const {config} = typescript.parseConfigFileTextToJson(tsconfigPath, await readFile(tsconfigPath, 'utf8')) - TS_CONFIGS[root] = tsconfig + if (!config || Object.keys(config.compilerOptions).length === 0) return - if (tsconfig.extends) { + TS_CONFIGS[root] = config + + if (config.extends) { const {parse} = await import('tsconfck') const result = await parse(tsconfigPath) const tsNodeOpts = Object.fromEntries( @@ -36,11 +59,9 @@ async function loadTSConfig(root: string): Promise { } return TS_CONFIGS[root] - } catch (error) { - if (error instanceof SyntaxError) { - debug(`Could not parse tsconfig.json. Skipping ts-node registration for ${root}.`) - memoizedWarn(`Could not parse tsconfig.json for ${root}. Falling back to compiled source.`) - } + } catch { + debug(`Could not parse tsconfig.json. Skipping ts-node registration for ${root}.`) + memoizedWarn(`Could not parse tsconfig.json for ${root}. Falling back to compiled source.`) } } diff --git a/test/config/ts-node.test.ts b/test/config/ts-node.test.ts index 83ba9861b..4ef4fdc19 100644 --- a/test/config/ts-node.test.ts +++ b/test/config/ts-node.test.ts @@ -1,13 +1,11 @@ import {expect} from 'chai' +import fs from 'node:fs/promises' import {join, resolve} from 'node:path' import {SinonSandbox, createSandbox} from 'sinon' -import stripAnsi from 'strip-ansi' import * as tsNode from 'ts-node' -import write from '../../src/cli-ux/write' import * as configTsNode from '../../src/config/ts-node' import {Interfaces, settings} from '../../src/index' -import * as util from '../../src/util/fs' const root = resolve(__dirname, 'fixtures/typescript') const tsSource = 'src/hooks/init.ts' @@ -44,48 +42,50 @@ describe('tsPath', () => { }) it('should resolve a .js file to ts src', async () => { - sandbox.stub(util, 'readJson').resolves(DEFAULT_TS_CONFIG) + sandbox.stub(fs, 'readFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) const result = await configTsNode.tsPath(root, jsCompiled) expect(result).to.equal(join(root, tsModule)) }) it('should resolve a module file to ts src', async () => { - sandbox.stub(util, 'readJson').resolves(DEFAULT_TS_CONFIG) + sandbox.stub(fs, 'readFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) const result = await configTsNode.tsPath(root, jsCompiledModule) expect(result).to.equal(join(root, tsModule)) }) it('should resolve a .ts file', async () => { - sandbox.stub(util, 'readJson').resolves(DEFAULT_TS_CONFIG) + sandbox.stub(fs, 'readFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) const result = await configTsNode.tsPath(root, tsSource) expect(result).to.equal(join(root, tsSource)) }) it('should resolve a .ts file using baseUrl', async () => { - sandbox.stub(util, 'readJson').resolves({ - compilerOptions: { - baseUrl: '.src/', - outDir: 'lib', - }, - }) + sandbox.stub(fs, 'readFile').resolves( + JSON.stringify({ + compilerOptions: { + baseUrl: '.src/', + outDir: 'lib', + }, + }), + ) const result = await configTsNode.tsPath(root, tsSource) expect(result).to.equal(join(root, tsSource)) }) it('should resolve .ts with no outDir', async () => { - sandbox.stub(util, 'readJson').resolves({compilerOptions: {rootDir: 'src'}}) + sandbox.stub(fs, 'readFile').resolves(JSON.stringify({compilerOptions: {rootDir: 'src'}})) const result = await configTsNode.tsPath(root, tsSource) expect(result).to.equal(join(root, tsSource)) }) it('should resolve .js with no rootDir and outDir', async () => { - sandbox.stub(util, 'readJson').resolves({compilerOptions: {strict: true}}) + sandbox.stub(fs, 'readFile').resolves(JSON.stringify({compilerOptions: {strict: true}})) const result = await configTsNode.tsPath(root, jsCompiled) expect(result).to.equal(join(root, jsCompiled)) }) it('should resolve to .ts file if enabled and prod', async () => { - sandbox.stub(util, 'readJson').resolves(DEFAULT_TS_CONFIG) + sandbox.stub(fs, 'readFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) settings.tsnodeEnabled = true const originalNodeEnv = process.env.NODE_ENV delete process.env.NODE_ENV @@ -98,21 +98,11 @@ describe('tsPath', () => { }) it('should resolve to js if disabled', async () => { - sandbox.stub(util, 'readJson').resolves(DEFAULT_TS_CONFIG) + sandbox.stub(fs, 'readFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) settings.tsnodeEnabled = false const result = await configTsNode.tsPath(root, jsCompiled) expect(result).to.equal(join(root, jsCompiled)) delete settings.tsnodeEnabled }) - - it('should handle SyntaxError', async () => { - sandbox.stub(util, 'readJson').throws(new SyntaxError('Unexpected token } in JSON at position 0')) - const stderrStub = sandbox.stub(write, 'stderr') - const result = await configTsNode.tsPath(root, tsSource) - expect(result).to.equal(join(root, tsSource)) - expect(stripAnsi(stderrStub.firstCall.firstArg).split('\n').join(' ')).to.include( - 'Warning: Could not parse tsconfig.json', - ) - }) }) From 0b15536b1b021eec182b18e99d5ab2b6c9deacd3 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 3 Nov 2023 12:41:15 -0600 Subject: [PATCH 2/4] fix: handle non-existent tsconfig --- src/config/ts-node.ts | 11 +++++++---- src/util/fs.ts | 12 ++++++++++++ test/config/ts-node.test.ts | 18 +++++++++--------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/config/ts-node.ts b/src/config/ts-node.ts index eded54e39..6affb0d06 100644 --- a/src/config/ts-node.ts +++ b/src/config/ts-node.ts @@ -1,11 +1,10 @@ -import {readFile} from 'node:fs/promises' import {join, relative as pathRelative, sep} from 'node:path' import * as TSNode from 'ts-node' import {memoizedWarn} from '../errors' import {Plugin, TSConfig} from '../interfaces' import {settings} from '../settings' -import {existsSync} from '../util/fs' +import {existsSync, safeReadFile} from '../util/fs' import {isProd} from '../util/util' import Cache from './cache' import {Debug} from './util' @@ -38,11 +37,15 @@ function importTypescript(root: string) { async function loadTSConfig(root: string): Promise { try { if (TS_CONFIGS[root]) return TS_CONFIGS[root] - const tsconfigPath = join(root, 'tsconfig.json') + const typescript = importTypescript(root) if (!typescript) return - const {config} = typescript.parseConfigFileTextToJson(tsconfigPath, await readFile(tsconfigPath, 'utf8')) + const tsconfigPath = join(root, 'tsconfig.json') + const raw = await safeReadFile(tsconfigPath) + if (!raw) return + + const {config} = typescript.parseConfigFileTextToJson(tsconfigPath, raw) if (!config || Object.keys(config.compilerOptions).length === 0) return diff --git a/src/util/fs.ts b/src/util/fs.ts index 50e04a99f..667eb6753 100644 --- a/src/util/fs.ts +++ b/src/util/fs.ts @@ -60,12 +60,24 @@ export function readJsonSync(path: string, parse = true): T | strin return parse ? (JSON.parse(contents) as T) : contents } +/** + * Read a JSON file, returning undefined if the file does not exist. + */ export async function safeReadJson(path: string): Promise { try { return await readJson(path) } catch {} } +/** + * Read a file, returning undefined if the file does not exist. + */ +export async function safeReadFile(path: string): Promise { + try { + return await readFile(path, 'utf8') + } catch {} +} + export function existsSync(path: string): boolean { return fsExistsSync(path) } diff --git a/test/config/ts-node.test.ts b/test/config/ts-node.test.ts index 4ef4fdc19..6ccbe50ff 100644 --- a/test/config/ts-node.test.ts +++ b/test/config/ts-node.test.ts @@ -1,11 +1,11 @@ import {expect} from 'chai' -import fs from 'node:fs/promises' import {join, resolve} from 'node:path' import {SinonSandbox, createSandbox} from 'sinon' import * as tsNode from 'ts-node' import * as configTsNode from '../../src/config/ts-node' import {Interfaces, settings} from '../../src/index' +import * as util from '../../src/util/fs' const root = resolve(__dirname, 'fixtures/typescript') const tsSource = 'src/hooks/init.ts' @@ -42,25 +42,25 @@ describe('tsPath', () => { }) it('should resolve a .js file to ts src', async () => { - sandbox.stub(fs, 'readFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) + sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) const result = await configTsNode.tsPath(root, jsCompiled) expect(result).to.equal(join(root, tsModule)) }) it('should resolve a module file to ts src', async () => { - sandbox.stub(fs, 'readFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) + sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) const result = await configTsNode.tsPath(root, jsCompiledModule) expect(result).to.equal(join(root, tsModule)) }) it('should resolve a .ts file', async () => { - sandbox.stub(fs, 'readFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) + sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) const result = await configTsNode.tsPath(root, tsSource) expect(result).to.equal(join(root, tsSource)) }) it('should resolve a .ts file using baseUrl', async () => { - sandbox.stub(fs, 'readFile').resolves( + sandbox.stub(util, 'safeReadFile').resolves( JSON.stringify({ compilerOptions: { baseUrl: '.src/', @@ -73,19 +73,19 @@ describe('tsPath', () => { }) it('should resolve .ts with no outDir', async () => { - sandbox.stub(fs, 'readFile').resolves(JSON.stringify({compilerOptions: {rootDir: 'src'}})) + sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify({compilerOptions: {rootDir: 'src'}})) const result = await configTsNode.tsPath(root, tsSource) expect(result).to.equal(join(root, tsSource)) }) it('should resolve .js with no rootDir and outDir', async () => { - sandbox.stub(fs, 'readFile').resolves(JSON.stringify({compilerOptions: {strict: true}})) + sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify({compilerOptions: {strict: true}})) const result = await configTsNode.tsPath(root, jsCompiled) expect(result).to.equal(join(root, jsCompiled)) }) it('should resolve to .ts file if enabled and prod', async () => { - sandbox.stub(fs, 'readFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) + sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) settings.tsnodeEnabled = true const originalNodeEnv = process.env.NODE_ENV delete process.env.NODE_ENV @@ -98,7 +98,7 @@ describe('tsPath', () => { }) it('should resolve to js if disabled', async () => { - sandbox.stub(fs, 'readFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) + sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) settings.tsnodeEnabled = false const result = await configTsNode.tsPath(root, jsCompiled) expect(result).to.equal(join(root, jsCompiled)) From 7f0c9f0a78c9d4f78bbdcc8b174aef01a007a918 Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 3 Nov 2023 13:11:06 -0600 Subject: [PATCH 3/4] fix: use tsconfck to handle json-c --- src/config/ts-node.ts | 49 ++++++------------------------------- src/util/fs.ts | 21 +++++++--------- test/config/ts-node.test.ts | 28 ++++++++++----------- 3 files changed, 29 insertions(+), 69 deletions(-) diff --git a/src/config/ts-node.ts b/src/config/ts-node.ts index 6affb0d06..5c72f6eea 100644 --- a/src/config/ts-node.ts +++ b/src/config/ts-node.ts @@ -4,65 +4,30 @@ import * as TSNode from 'ts-node' import {memoizedWarn} from '../errors' import {Plugin, TSConfig} from '../interfaces' import {settings} from '../settings' -import {existsSync, safeReadFile} from '../util/fs' +import {existsSync, readTSConfig} from '../util/fs' import {isProd} from '../util/util' import Cache from './cache' import {Debug} from './util' - // eslint-disable-next-line new-cap const debug = Debug('ts-node') export const TS_CONFIGS: Record = {} const REGISTERED = new Set() -function importTypescript(root: string) { - let typescript: typeof import('typescript') | undefined - try { - typescript = require('typescript') - } catch { - try { - typescript = require(require.resolve('typescript', {paths: [root, __dirname]})) - } catch { - debug(`Could not find typescript dependency. Skipping ts-node registration for ${root}.`) - memoizedWarn( - 'Could not find typescript. Please ensure that typescript is a devDependency. Falling back to compiled source.', - ) - return - } - } - - return typescript +function isErrno(error: any): error is NodeJS.ErrnoException { + return 'code' in error } async function loadTSConfig(root: string): Promise { try { if (TS_CONFIGS[root]) return TS_CONFIGS[root] - const typescript = importTypescript(root) - if (!typescript) return - - const tsconfigPath = join(root, 'tsconfig.json') - const raw = await safeReadFile(tsconfigPath) - if (!raw) return - - const {config} = typescript.parseConfigFileTextToJson(tsconfigPath, raw) - - if (!config || Object.keys(config.compilerOptions).length === 0) return - - TS_CONFIGS[root] = config - - if (config.extends) { - const {parse} = await import('tsconfck') - const result = await parse(tsconfigPath) - const tsNodeOpts = Object.fromEntries( - (result.extended ?? []).flatMap((e) => Object.entries(e.tsconfig['ts-node'] ?? {})).reverse(), - ) - - TS_CONFIGS[root] = {...result.tsconfig, 'ts-node': tsNodeOpts} - } + TS_CONFIGS[root] = await readTSConfig(join(root, 'tsconfig.json')) return TS_CONFIGS[root] - } catch { + } catch (error) { + if (isErrno(error)) return + debug(`Could not parse tsconfig.json. Skipping ts-node registration for ${root}.`) memoizedWarn(`Could not parse tsconfig.json for ${root}. Falling back to compiled source.`) } diff --git a/src/util/fs.ts b/src/util/fs.ts index 667eb6753..2afc49591 100644 --- a/src/util/fs.ts +++ b/src/util/fs.ts @@ -60,24 +60,21 @@ export function readJsonSync(path: string, parse = true): T | strin return parse ? (JSON.parse(contents) as T) : contents } -/** - * Read a JSON file, returning undefined if the file does not exist. - */ export async function safeReadJson(path: string): Promise { try { return await readJson(path) } catch {} } -/** - * Read a file, returning undefined if the file does not exist. - */ -export async function safeReadFile(path: string): Promise { - try { - return await readFile(path, 'utf8') - } catch {} -} - export function existsSync(path: string): boolean { return fsExistsSync(path) } + +export async function readTSConfig(path: string) { + const {parse} = await import('tsconfck') + const result = await parse(path) + const tsNodeOpts = Object.fromEntries( + (result.extended ?? []).flatMap((e) => Object.entries(e.tsconfig['ts-node'] ?? {})).reverse(), + ) + return {...result.tsconfig, 'ts-node': tsNodeOpts} +} diff --git a/test/config/ts-node.test.ts b/test/config/ts-node.test.ts index 6ccbe50ff..e8470b1b7 100644 --- a/test/config/ts-node.test.ts +++ b/test/config/ts-node.test.ts @@ -42,50 +42,48 @@ describe('tsPath', () => { }) it('should resolve a .js file to ts src', async () => { - sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) + sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG) const result = await configTsNode.tsPath(root, jsCompiled) expect(result).to.equal(join(root, tsModule)) }) it('should resolve a module file to ts src', async () => { - sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) + sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG) const result = await configTsNode.tsPath(root, jsCompiledModule) expect(result).to.equal(join(root, tsModule)) }) it('should resolve a .ts file', async () => { - sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) + sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG) const result = await configTsNode.tsPath(root, tsSource) expect(result).to.equal(join(root, tsSource)) }) it('should resolve a .ts file using baseUrl', async () => { - sandbox.stub(util, 'safeReadFile').resolves( - JSON.stringify({ - compilerOptions: { - baseUrl: '.src/', - outDir: 'lib', - }, - }), - ) + sandbox.stub(util, 'readTSConfig').resolves({ + compilerOptions: { + baseUrl: '.src/', + outDir: 'lib', + }, + }) const result = await configTsNode.tsPath(root, tsSource) expect(result).to.equal(join(root, tsSource)) }) it('should resolve .ts with no outDir', async () => { - sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify({compilerOptions: {rootDir: 'src'}})) + sandbox.stub(util, 'readTSConfig').resolves({compilerOptions: {rootDir: 'src'}}) const result = await configTsNode.tsPath(root, tsSource) expect(result).to.equal(join(root, tsSource)) }) it('should resolve .js with no rootDir and outDir', async () => { - sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify({compilerOptions: {strict: true}})) + sandbox.stub(util, 'readTSConfig').resolves({compilerOptions: {strict: true}}) const result = await configTsNode.tsPath(root, jsCompiled) expect(result).to.equal(join(root, jsCompiled)) }) it('should resolve to .ts file if enabled and prod', async () => { - sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) + sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG) settings.tsnodeEnabled = true const originalNodeEnv = process.env.NODE_ENV delete process.env.NODE_ENV @@ -98,7 +96,7 @@ describe('tsPath', () => { }) it('should resolve to js if disabled', async () => { - sandbox.stub(util, 'safeReadFile').resolves(JSON.stringify(DEFAULT_TS_CONFIG)) + sandbox.stub(util, 'readTSConfig').resolves(DEFAULT_TS_CONFIG) settings.tsnodeEnabled = false const result = await configTsNode.tsPath(root, jsCompiled) expect(result).to.equal(join(root, jsCompiled)) From 44fb9dc40a307ea1656e726806b62c9d165ae32f Mon Sep 17 00:00:00 2001 From: Mike Donnalley Date: Fri, 3 Nov 2023 13:31:58 -0600 Subject: [PATCH 4/4] fix: check for ENOENT code --- src/config/ts-node.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/ts-node.ts b/src/config/ts-node.ts index 5c72f6eea..15c52678b 100644 --- a/src/config/ts-node.ts +++ b/src/config/ts-node.ts @@ -15,7 +15,7 @@ export const TS_CONFIGS: Record = {} const REGISTERED = new Set() function isErrno(error: any): error is NodeJS.ErrnoException { - return 'code' in error + return 'code' in error && error.code === 'ENOENT' } async function loadTSConfig(root: string): Promise {