From abc616ece2a8cdb066439a4e5c6fc7d493aa4612 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sun, 13 Feb 2022 11:16:43 -0500 Subject: [PATCH 01/17] fix (#1642) --- src/test/esm-loader.spec.ts | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts index 0de0482c0..42dce0f0d 100644 --- a/src/test/esm-loader.spec.ts +++ b/src/test/esm-loader.spec.ts @@ -117,7 +117,23 @@ if (nodeSupportsImportAssertions) { 'parentURL', ]); } else if (json.loadContextKeys) { - expect(json.loadContextKeys).toEqual(['format', 'importAssertions']); + try { + expect(json.loadContextKeys).toEqual([ + 'format', + 'importAssertions', + ]); + } catch (e) { + // HACK for https://github.com/TypeStrong/ts-node/issues/1641 + if (process.version.includes('nightly')) { + expect(json.loadContextKeys).toEqual([ + 'format', + 'importAssertions', + 'parentURL', + ]); + } else { + throw e; + } + } } else { throw new Error('Unexpected stdout in test.'); } From 63a2f8329d45850680e5140675ab774a096b214f Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 19 Feb 2022 00:33:43 -0500 Subject: [PATCH 02/17] Fix #1647 (#1648) * fix * Fix * fix --- package-lock.json | 85 +++++++++++++----------------------- package.json | 4 +- src/index.ts | 2 +- src/resolver-functions.ts | 90 +++++++++++++++++++++++---------------- src/ts-compiler-types.ts | 47 +++++++++++++++++++- 5 files changed, 132 insertions(+), 96 deletions(-) diff --git a/package-lock.json b/package-lock.json index 632b7c965..9147467b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3016,15 +3016,6 @@ "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", "dev": true }, - "json-stable-stringify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", - "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", - "dev": true, - "requires": { - "jsonify": "~0.0.0" - } - }, "json5": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.1.3.tgz", @@ -3040,12 +3031,6 @@ "integrity": "sha512-fQzRfAbIBnR0IQvftw9FJveWiHp72Fg20giDrHz6TdfB12UH/uue0D3hm57UB5KgAVuniLMCaS8P1IMj9NR7cA==", "dev": true }, - "jsonify": { - "version": "0.0.0", - "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", - "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", - "dev": true - }, "keyv": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-3.1.0.tgz", @@ -4206,6 +4191,12 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "dev": true }, + "safe-stable-stringify": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.3.1.tgz", + "integrity": "sha512-kYBSfT+troD9cDA85VDnHZ1rpHC50O0g1e6WlGHVCz/g+JS+9WKLj+XwFYyR8UbrZN8ll9HUpDAAddY58MGisg==", + "dev": true + }, "semver": { "version": "7.1.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.1.3.tgz", @@ -4648,12 +4639,12 @@ "dev": true }, "ts-node": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.2.1.tgz", - "integrity": "sha512-hCnyOyuGmD5wHleOQX6NIjJtYVIO8bPP8F2acWkB4W06wdlkgyvJtubO/I9NkI88hCFECbsEgoLc0VNkYmcSfw==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.5.0.tgz", + "integrity": "sha512-6kEJKwVxAJ35W4akuiysfKwKmjkbYxwQMTBaAxo9KKAx/Yd26mPUyhGz3ji+EsJoAgrLqVsYHNuuYwQe22lbtw==", "dev": true, "requires": { - "@cspotcode/source-map-support": "0.6.1", + "@cspotcode/source-map-support": "0.7.0", "@tsconfig/node10": "^1.0.7", "@tsconfig/node12": "^1.0.7", "@tsconfig/node14": "^1.0.0", @@ -4664,18 +4655,8 @@ "create-require": "^1.1.0", "diff": "^4.0.1", "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.0", "yn": "3.1.1" - }, - "dependencies": { - "@cspotcode/source-map-support": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.6.1.tgz", - "integrity": "sha512-DX3Z+T5dt1ockmPdobJS/FAsQPW4V4SrWEhD2iYQT2Cb2tQsiMnYxrcUH9By/Z3B+v0S5LMBkQtV/XOBbpLEOg==", - "dev": true, - "requires": { - "@cspotcode/source-map-consumer": "0.8.0" - } - } } }, "tslib": { @@ -4735,30 +4716,30 @@ } }, "typescript": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.2.tgz", - "integrity": "sha512-5BlMof9H1yGt0P8/WF+wPNw6GfctgGjXp5hkblpyT+8rkASSmkUKMXrxR0Xg8ThVCi/JnHQiKXeBaEwCeQwMFw==", + "version": "4.5.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.5.5.tgz", + "integrity": "sha512-TCTIul70LyWe6IJWT8QSYeA54WQe8EjQFU4wY52Fasj5UKx88LNYKCgBEHcOMOrFF1rKGbD8v/xcNWVUq9SymA==", "dev": true }, "typescript-json-schema": { - "version": "0.51.0", - "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.51.0.tgz", - "integrity": "sha512-POhWbUNs2oaBti1W9k/JwS+uDsaZD9J/KQiZ/iXRQEOD0lTn9VmshIls9tn+A9X6O+smPjeEz5NEy6WTkCCzrQ==", + "version": "0.53.0", + "resolved": "https://registry.npmjs.org/typescript-json-schema/-/typescript-json-schema-0.53.0.tgz", + "integrity": "sha512-BcFxC9nipQQOXxrBGI/jOWU31BwzVh6vqJR008G8VHKJtQ8YrZX6veriXfTK1l+L0/ff0yKl3mZigMLA6ZqkHg==", "dev": true, "requires": { "@types/json-schema": "^7.0.9", "@types/node": "^16.9.2", "glob": "^7.1.7", - "json-stable-stringify": "^1.0.1", + "safe-stable-stringify": "^2.2.0", "ts-node": "^10.2.1", - "typescript": "~4.2.3", + "typescript": "~4.5.0", "yargs": "^17.1.1" }, "dependencies": { "@types/node": { - "version": "16.10.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-16.10.1.tgz", - "integrity": "sha512-4/Z9DMPKFexZj/Gn3LylFgamNKHm4K3QDi0gz9B26Uk0c8izYf97B5fxfpspMNkWlFupblKM/nV8+NA9Ffvr+w==", + "version": "16.11.25", + "resolved": "https://registry.npmjs.org/@types/node/-/node-16.11.25.tgz", + "integrity": "sha512-NrTwfD7L1RTc2qrHQD4RTTy4p0CO2LatKBEKEds3CaVuhoM/+DJzmWZl5f+ikR8cm8F5mfJxK+9rQq07gRiSjQ==", "dev": true }, "glob": { @@ -4774,12 +4755,6 @@ "once": "^1.3.0", "path-is-absolute": "^1.0.0" } - }, - "typescript": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.2.4.tgz", - "integrity": "sha512-V+evlYHZnQkaz8TRBuxTA92yZBPotr5H+WhQ7bD3hZUndx5tGOa1fuCgeSjxAzM1RiN5IzvadIXTVefuuwZCRg==", - "dev": true } } }, @@ -5047,18 +5022,18 @@ "dev": true }, "yargs": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.2.1.tgz", - "integrity": "sha512-XfR8du6ua4K6uLGm5S6fA+FIJom/MdJcFNVY8geLlp2v8GYbOXD4EB1tPNZsRn4vBzKGMgb5DRZMeWuFc2GO8Q==", + "version": "17.3.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.3.1.tgz", + "integrity": "sha512-WUANQeVgjLbNsEmGk20f+nlHgOqzRFpiGWVaBrYGYIGANIIu3lWjoyi0fNlFmJkvfhCZ6BXINe7/W2O2bV4iaA==", "dev": true, "requires": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "string-width": "^4.2.0", + "string-width": "^4.2.3", "y18n": "^5.0.5", - "yargs-parser": "^20.2.2" + "yargs-parser": "^21.0.0" }, "dependencies": { "y18n": { @@ -5070,9 +5045,9 @@ } }, "yargs-parser": { - "version": "20.2.9", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", - "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", + "version": "21.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.0.0.tgz", + "integrity": "sha512-z9kApYUOCwoeZ78rfRYYWdiU/iNL6mwwYlkkZfJoyMR1xps+NEBX5X7XmRpxkZHhXJ6+Ey00IwKxBBSW9FIjyA==", "dev": true }, "yn": { diff --git a/package.json b/package.json index 5e6323f45..9e8bbdb61 100644 --- a/package.json +++ b/package.json @@ -134,8 +134,8 @@ "semver": "^7.1.3", "throat": "^6.0.1", "typedoc": "^0.22.10", - "typescript": "4.5.2", - "typescript-json-schema": "^0.51.0", + "typescript": "4.5.5", + "typescript-json-schema": "^0.53.0", "util.promisify": "^1.0.1" }, "peerDependencies": { diff --git a/src/index.ts b/src/index.ts index 0ffeea3fc..0544b2d98 100644 --- a/src/index.ts +++ b/src/index.ts @@ -605,7 +605,7 @@ export function create(rawOptions: CreateOptions = {}): Service { const projectLocalResolveHelper = createProjectLocalResolveHelper(relativeToPath); const compiler = projectLocalResolveHelper(name || 'typescript', true); - const ts: typeof _ts = attemptRequireWithV8CompileCache(require, compiler); + const ts: TSCommon = attemptRequireWithV8CompileCache(require, compiler); return { compiler, ts, projectLocalResolveHelper }; } diff --git a/src/resolver-functions.ts b/src/resolver-functions.ts index f032bf0a8..ecb5d98e2 100644 --- a/src/resolver-functions.ts +++ b/src/resolver-functions.ts @@ -1,5 +1,5 @@ import { resolve } from 'path'; -import type * as _ts from 'typescript'; +import type { TSCommon, TSInternal } from './ts-compiler-types'; import type { ProjectLocalResolveHelper } from './util'; /** @@ -7,11 +7,11 @@ import type { ProjectLocalResolveHelper } from './util'; * In a factory because these are shared across both CompilerHost and LanguageService codepaths */ export function createResolverFunctions(kwargs: { - ts: typeof _ts; - host: _ts.ModuleResolutionHost; + ts: TSCommon; + host: TSCommon.ModuleResolutionHost; cwd: string; getCanonicalFileName: (filename: string) => string; - config: _ts.ParsedCommandLine; + config: TSCommon.ParsedCommandLine; projectLocalResolveHelper: ProjectLocalResolveHelper; }) { const { @@ -58,7 +58,9 @@ export function createResolverFunctions(kwargs: { * If we need to emit JS for a file, force TS to consider it non-external */ const fixupResolvedModule = ( - resolvedModule: _ts.ResolvedModule | _ts.ResolvedTypeReferenceDirective + resolvedModule: + | TSCommon.ResolvedModule + | TSCommon.ResolvedTypeReferenceDirective ) => { const { resolvedFileName } = resolvedModule; if (resolvedFileName === undefined) return; @@ -82,35 +84,36 @@ export function createResolverFunctions(kwargs: { * Older ts versions do not pass `redirectedReference` nor `options`. * We must pass `redirectedReference` to newer ts versions, but cannot rely on `options`, hence the weird argument name */ - const resolveModuleNames: _ts.LanguageServiceHost['resolveModuleNames'] = ( - moduleNames: string[], - containingFile: string, - reusedNames: string[] | undefined, - redirectedReference: _ts.ResolvedProjectReference | undefined, - optionsOnlyWithNewerTsVersions: _ts.CompilerOptions - ): (_ts.ResolvedModule | undefined)[] => { - return moduleNames.map((moduleName) => { - const { resolvedModule } = ts.resolveModuleName( - moduleName, - containingFile, - config.options, - host, - moduleResolutionCache, - redirectedReference - ); - if (resolvedModule) { - fixupResolvedModule(resolvedModule); - } - return resolvedModule; - }); - }; + const resolveModuleNames: TSCommon.LanguageServiceHost['resolveModuleNames'] = + ( + moduleNames: string[], + containingFile: string, + reusedNames: string[] | undefined, + redirectedReference: TSCommon.ResolvedProjectReference | undefined, + optionsOnlyWithNewerTsVersions: TSCommon.CompilerOptions + ): (TSCommon.ResolvedModule | undefined)[] => { + return moduleNames.map((moduleName) => { + const { resolvedModule } = ts.resolveModuleName( + moduleName, + containingFile, + config.options, + host, + moduleResolutionCache, + redirectedReference + ); + if (resolvedModule) { + fixupResolvedModule(resolvedModule); + } + return resolvedModule; + }); + }; // language service never calls this, but TS docs recommend that we implement it - const getResolvedModuleWithFailedLookupLocationsFromCache: _ts.LanguageServiceHost['getResolvedModuleWithFailedLookupLocationsFromCache'] = + const getResolvedModuleWithFailedLookupLocationsFromCache: TSCommon.LanguageServiceHost['getResolvedModuleWithFailedLookupLocationsFromCache'] = ( moduleName, containingFile - ): _ts.ResolvedModuleWithFailedLookupLocations | undefined => { + ): TSCommon.ResolvedModuleWithFailedLookupLocations | undefined => { const ret = ts.resolveModuleNameFromCache( moduleName, containingFile, @@ -122,22 +125,37 @@ export function createResolverFunctions(kwargs: { return ret; }; - const resolveTypeReferenceDirectives: _ts.LanguageServiceHost['resolveTypeReferenceDirectives'] = + const resolveTypeReferenceDirectives: TSCommon.LanguageServiceHost['resolveTypeReferenceDirectives'] = ( - typeDirectiveNames: string[], + typeDirectiveNames: string[] | readonly TSCommon.FileReference[], containingFile: string, - redirectedReference: _ts.ResolvedProjectReference | undefined, - options: _ts.CompilerOptions - ): (_ts.ResolvedTypeReferenceDirective | undefined)[] => { + redirectedReference: TSCommon.ResolvedProjectReference | undefined, + options: TSCommon.CompilerOptions, + containingFileMode?: TSCommon.SourceFile['impliedNodeFormat'] | undefined // new impliedNodeFormat is accepted by compilerHost + ): (TSCommon.ResolvedTypeReferenceDirective | undefined)[] => { // Note: seems to be called with empty typeDirectiveNames array for all files. + // TODO consider using `ts.loadWithTypeDirectiveCache` return typeDirectiveNames.map((typeDirectiveName) => { + // Copy-pasted from TS source: + const nameIsString = typeof typeDirectiveName === 'string'; + const mode = nameIsString + ? undefined + : (ts as any as TSInternal).getModeForFileReference!( + typeDirectiveName, + containingFileMode + ); + const strName = nameIsString + ? typeDirectiveName + : typeDirectiveName.fileName.toLowerCase(); let { resolvedTypeReferenceDirective } = ts.resolveTypeReferenceDirective( - typeDirectiveName, + strName, containingFile, config.options, host, - redirectedReference + redirectedReference, + undefined, + mode ); if (typeDirectiveName === 'node' && !resolvedTypeReferenceDirective) { // Resolve @types/node relative to project first, then __dirname (copy logic from elsewhere / refactor into reusable function) diff --git a/src/ts-compiler-types.ts b/src/ts-compiler-types.ts index b17111c3a..0abaaec21 100644 --- a/src/ts-compiler-types.ts +++ b/src/ts-compiler-types.ts @@ -1,7 +1,13 @@ import type * as _ts from 'typescript'; /** - * Common TypeScript interfaces between versions. + * Common TypeScript interfaces between versions. We endeavour to write ts-node's own code against these types instead + * of against `import "typescript"`, though we are not yet doing this consistently. + * + * Sometimes typescript@next adds an API we need to use. But we build ts-node against typescript@latest. + * In these cases, we must declare that API explicitly here. Our declarations include the newer typescript@next APIs. + * Importantly, these re-declarations are *not* TypeScript internals. They are public APIs that only exist in + * pre-release versions of typescript. */ export interface TSCommon { version: typeof _ts.version; @@ -26,7 +32,16 @@ export interface TSCommon { createModuleResolutionCache: typeof _ts.createModuleResolutionCache; resolveModuleName: typeof _ts.resolveModuleName; resolveModuleNameFromCache: typeof _ts.resolveModuleNameFromCache; - resolveTypeReferenceDirective: typeof _ts.resolveTypeReferenceDirective; + // Changed in TS 4.7 + resolveTypeReferenceDirective( + typeReferenceDirectiveName: string, + containingFile: string | undefined, + options: _ts.CompilerOptions, + host: _ts.ModuleResolutionHost, + redirectedReference?: _ts.ResolvedProjectReference, + cache?: _ts.TypeReferenceDirectiveResolutionCache, + resolutionMode?: _ts.SourceFile['impliedNodeFormat'] + ): _ts.ResolvedTypeReferenceDirectiveWithFailedLookupLocations; createIncrementalCompilerHost: typeof _ts.createIncrementalCompilerHost; createSourceFile: typeof _ts.createSourceFile; getDefaultLibFileName: typeof _ts.getDefaultLibFileName; @@ -36,6 +51,29 @@ export interface TSCommon { Extension: typeof _ts.Extension; ModuleResolutionKind: typeof _ts.ModuleResolutionKind; } +export namespace TSCommon { + export interface LanguageServiceHost extends _ts.LanguageServiceHost { + // Modified in 4.7 + resolveTypeReferenceDirectives?( + typeDirectiveNames: string[] | _ts.FileReference[], + containingFile: string, + redirectedReference: _ts.ResolvedProjectReference | undefined, + options: _ts.CompilerOptions, + containingFileMode?: _ts.SourceFile['impliedNodeFormat'] | undefined + ): (_ts.ResolvedTypeReferenceDirective | undefined)[]; + } + export type ModuleResolutionHost = _ts.ModuleResolutionHost; + export type ParsedCommandLine = _ts.ParsedCommandLine; + export type ResolvedModule = _ts.ResolvedModule; + export type ResolvedTypeReferenceDirective = + _ts.ResolvedTypeReferenceDirective; + export type CompilerOptions = _ts.CompilerOptions; + export type ResolvedProjectReference = _ts.ResolvedProjectReference; + export type ResolvedModuleWithFailedLookupLocations = + _ts.ResolvedModuleWithFailedLookupLocations; + export type FileReference = _ts.FileReference; + export type SourceFile = _ts.SourceFile; +} /** * Compiler APIs we use that are marked internal and not included in TypeScript's public API declarations @@ -69,6 +107,11 @@ export interface TSInternal { redirectedReference?: _ts.ResolvedProjectReference, lookupConfig?: boolean ): _ts.ResolvedModuleWithFailedLookupLocations; + // Added in TS 4.7 + getModeForFileReference?: ( + ref: _ts.FileReference | string, + containingFileMode: _ts.SourceFile['impliedNodeFormat'] + ) => _ts.SourceFile['impliedNodeFormat']; } /** @internal */ export namespace TSInternal { From 49341d0315b98001a615aa0707c4bafd841fd865 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 19 Feb 2022 02:00:21 -0500 Subject: [PATCH 03/17] Implement #1510: add test to catch when TS adds new `ModuleKind`s (#1650) * fix * fix * fix * fix --- src/index.ts | 9 ++++++--- src/test/index.spec.ts | 35 +++++++++++++++++++++++++++++++++++ src/transpilers/swc.ts | 2 +- 3 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 0544b2d98..bca7d3bde 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1307,13 +1307,16 @@ export function create(rawOptions: CreateOptions = {}): Service { config.options.module === ts.ModuleKind.CommonJS ? undefined : createTranspileOnlyGetOutputFunction(ts.ModuleKind.CommonJS); + // [MUST_UPDATE_FOR_NEW_MODULEKIND] const getOutputForceESM = config.options.module === ts.ModuleKind.ES2015 || - config.options.module === ts.ModuleKind.ES2020 || + (ts.ModuleKind.ES2020 && config.options.module === ts.ModuleKind.ES2020) || + (ts.ModuleKind.ES2022 && config.options.module === ts.ModuleKind.ES2022) || config.options.module === ts.ModuleKind.ESNext ? undefined - : createTranspileOnlyGetOutputFunction( - ts.ModuleKind.ES2020 || ts.ModuleKind.ES2015 + : // [MUST_UPDATE_FOR_NEW_MODULEKIND] + createTranspileOnlyGetOutputFunction( + ts.ModuleKind.ES2022 || ts.ModuleKind.ES2020 || ts.ModuleKind.ES2015 ); const getOutputTranspileOnly = createTranspileOnlyGetOutputFunction(); diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 8eef6bc0f..19c0913b2 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -1267,3 +1267,38 @@ test('Falls back to transpileOnly when ts compiler returns emitSkipped', async ( expect(err).toBe(null); expect(stdout).toBe('foo\n'); }); + +test('Detect when typescript adds new ModuleKind values; flag as a failure so we can update our code flagged [MUST_UPDATE_FOR_NEW_MODULEKIND]', async () => { + // We have marked a few places in our code with MUST_UPDATE_FOR_NEW_MODULEKIND to make it easier to update them when TS adds new ModuleKinds + const foundKeys: string[] = []; + function check(value: number, name: string, required: boolean) { + if (required) expect(ts.ModuleKind[name]).toBe(value); + if (ts.ModuleKind[value] === undefined) { + expect(ts.ModuleKind[name]).toBeUndefined(); + } else { + expect(ts.ModuleKind[value]).toBe(name); + foundKeys.push(name, `${value}`); + } + } + check(0, 'None', true); + check(1, 'CommonJS', true); + check(2, 'AMD', true); + check(3, 'UMD', true); + check(4, 'System', true); + check(5, 'ES2015', true); + try { + check(6, 'ES2020', false); + check(99, 'ESNext', true); + } catch { + // the value changed: is `99` now, but was `6` in TS 2.7 + check(6, 'ESNext', true); + expect(ts.ModuleKind[99]).toBeUndefined(); + } + check(7, 'ES2022', false); + check(100, 'Node12', false); + check(199, 'NodeNext', false); + const actualKeys = Object.keys(ts.ModuleKind); + actualKeys.sort(); + foundKeys.sort(); + expect(actualKeys).toEqual(foundKeys); +}); diff --git a/src/transpilers/swc.ts b/src/transpilers/swc.ts index fedc6a3af..23949595d 100644 --- a/src/transpilers/swc.ts +++ b/src/transpilers/swc.ts @@ -77,7 +77,7 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { } swcTarget = swcTargets[swcTargetIndex]; const keepClassNames = target! >= /* ts.ScriptTarget.ES2016 */ 3; - // swc only supports these 4x module options + // swc only supports these 4x module options [MUST_UPDATE_FOR_NEW_MODULEKIND] const moduleType = module === ModuleKind.CommonJS ? 'commonjs' From 19429969a8a4a41b3e37713324b2fb4a86ab197b Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 19 Feb 2022 19:45:37 -0500 Subject: [PATCH 04/17] fix (#1652) --- src/index.ts | 25 +++++-- src/test/index.spec.ts | 73 ++++++++++--------- .../override-to-cjs/tsconfig-swc.json | 15 ++++ .../override-to-esm/tsconfig-swc.json | 14 ++++ 4 files changed, 88 insertions(+), 39 deletions(-) create mode 100644 tests/module-types/override-to-cjs/tsconfig-swc.json create mode 100644 tests/module-types/override-to-esm/tsconfig-swc.json diff --git a/src/index.ts b/src/index.ts index bca7d3bde..ffb858101 100644 --- a/src/index.ts +++ b/src/index.ts @@ -751,7 +751,9 @@ export function create(rawOptions: CreateOptions = {}): Service { 'Transformers function is unavailable in "--transpile-only"' ); } - let customTranspiler: Transpiler | undefined = undefined; + let createTranspiler: + | ((compilerOptions: TSCommon.CompilerOptions) => Transpiler) + | undefined; if (transpiler) { if (!transpileOnly) throw new Error( @@ -762,11 +764,21 @@ export function create(rawOptions: CreateOptions = {}): Service { const transpilerOptions = typeof transpiler === 'string' ? {} : transpiler[1] ?? {}; const transpilerPath = projectLocalResolveHelper(transpilerName, true); - const transpilerFactory: TranspilerFactory = require(transpilerPath).create; - customTranspiler = transpilerFactory({ - service: { options, config, projectLocalResolveHelper }, - ...transpilerOptions, - }); + const transpilerFactory = require(transpilerPath) + .create as TranspilerFactory; + createTranspiler = function (compilerOptions) { + return transpilerFactory({ + service: { + options, + config: { + ...config, + options: compilerOptions, + }, + projectLocalResolveHelper, + }, + ...transpilerOptions, + }); + }; } /** @@ -1277,6 +1289,7 @@ export function create(rawOptions: CreateOptions = {}): Service { const compilerOptions = { ...config.options }; if (overrideModuleType !== undefined) compilerOptions.module = overrideModuleType; + let customTranspiler = createTranspiler?.(compilerOptions); return (code: string, fileName: string): SourceOutput => { let result: _ts.TranspileOutput; if (customTranspiler) { diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 19c0913b2..b69670a64 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -1197,39 +1197,46 @@ test.suite('ts-node', (test) => { expect(stdout).toBe(''); }); - async function runModuleTypeTest(project: string, ext: string) { - const { err, stderr, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./module-types/${project}/test.${ext}`, - { - env: { - ...process.env, - TS_NODE_PROJECT: `./module-types/${project}/tsconfig.json`, - }, - } - ); - expect(err).toBe(null); - expect(stdout).toBe(`Failures: 0\n`); - } - - test('moduleTypes should allow importing CJS in an otherwise ESM project', async (t) => { - // A notable case where you can use ts-node's CommonJS loader, not the ESM loader, in an ESM project: - // when loading a webpack.config.ts or similar config - const { err, stderr, stdout } = await exec( - `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} --project ./module-types/override-to-cjs/tsconfig.json ./module-types/override-to-cjs/test-webpack-config.cjs` - ); - expect(err).toBe(null); - expect(stdout).toBe(``); - - await runModuleTypeTest('override-to-cjs', 'cjs'); - if (semver.gte(process.version, '14.13.1')) - await runModuleTypeTest('override-to-cjs', 'mjs'); - }); - - test('moduleTypes should allow importing ESM in an otherwise CJS project', async (t) => { - await runModuleTypeTest('override-to-esm', 'cjs'); - // Node 14.13.0 has a bug(?) where it checks for ESM-only syntax *before* we transform the code. - if (semver.gte(process.version, '14.13.1')) - await runModuleTypeTest('override-to-esm', 'mjs'); + test.suite('moduleTypes', (test) => { + suite('with vanilla ts transpilation', 'tsconfig.json'); + suite('with third-party-transpiler', 'tsconfig-swc.json'); + function suite(name: string, tsconfig: string) { + test.suite(name, (test) => { + test('supports CJS webpack.config.ts in an otherwise ESM project', async (t) => { + // A notable case where you can use ts-node's CommonJS loader, not the ESM loader, in an ESM project: + // when loading a webpack.config.ts or similar config + const { err, stdout } = await exec( + `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} --project ./module-types/override-to-cjs/${tsconfig} ./module-types/override-to-cjs/test-webpack-config.cjs` + ); + expect(err).toBe(null); + expect(stdout).toBe(``); + }); + test('should allow importing CJS in an otherwise ESM project', async (t) => { + await run('override-to-cjs', tsconfig, 'cjs'); + if (semver.gte(process.version, '14.13.1')) + await run('override-to-cjs', tsconfig, 'mjs'); + }); + test('should allow importing ESM in an otherwise CJS project', async (t) => { + await run('override-to-esm', tsconfig, 'cjs'); + // Node 14.13.0 has a bug(?) where it checks for ESM-only syntax *before* we transform the code. + if (semver.gte(process.version, '14.13.1')) + await run('override-to-esm', tsconfig, 'mjs'); + }); + }); + } + async function run(project: string, config: string, ext: string) { + const { err, stderr, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./module-types/${project}/test.${ext}`, + { + env: { + ...process.env, + TS_NODE_PROJECT: `./module-types/${project}/${config}`, + }, + } + ); + expect(err).toBe(null); + expect(stdout).toBe(`Failures: 0\n`); + } }); } diff --git a/tests/module-types/override-to-cjs/tsconfig-swc.json b/tests/module-types/override-to-cjs/tsconfig-swc.json new file mode 100644 index 000000000..ca7aae1eb --- /dev/null +++ b/tests/module-types/override-to-cjs/tsconfig-swc.json @@ -0,0 +1,15 @@ +{ + "ts-node": { + "swc": true, + "moduleTypes": { + "webpack.config.ts": "cjs", + // Test that subsequent patterns override earlier ones + "src/cjs-subdir/**/*": "esm", + "src/cjs-subdir": "cjs", + "src/cjs-subdir/esm-exception.ts": "esm" + } + }, + "compilerOptions": { + "module": "ES2015" + } +} diff --git a/tests/module-types/override-to-esm/tsconfig-swc.json b/tests/module-types/override-to-esm/tsconfig-swc.json new file mode 100644 index 000000000..35a08fc62 --- /dev/null +++ b/tests/module-types/override-to-esm/tsconfig-swc.json @@ -0,0 +1,14 @@ +{ + "ts-node": { + "swc": true, + "moduleTypes": { + // Test that subsequent patterns override earlier ones + "src/esm-subdir/**/*": "cjs", + "src/esm-subdir": "esm", + "src/esm-subdir/cjs-exception.ts": "cjs" + } + }, + "compilerOptions": { + "module": "CommonJS" + } +} From f8b572cb24e41f3f1d41a5290eff1b6a6267716a Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Mon, 21 Feb 2022 22:29:36 -0500 Subject: [PATCH 05/17] Implement #1649: When entrypoint fails to resolve via ESM, fallback to CommonJS resolution (#1654) * WIP * fix * rather than throw our own error, throw the error from node's ESM loader --- src/esm.ts | 90 +++- src/test/esm-loader.spec.ts | 414 ++++++++++++++---- src/test/helpers.ts | 7 + src/test/index.spec.ts | 208 +-------- src/test/repl/node-repl-tla.ts | 13 +- src/test/testlib.ts | 30 +- .../extensionless-entrypoint | 1 + .../relies-upon-cjs-resolution/index.js | 1 + 8 files changed, 448 insertions(+), 316 deletions(-) create mode 100644 tests/esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint create mode 100644 tests/esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution/index.js diff --git a/src/esm.ts b/src/esm.ts index b27be4a0a..b42af64bd 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -15,6 +15,7 @@ import { import { extname } from 'path'; import * as assert from 'assert'; import { normalizeSlashes } from './util'; +import { createRequire } from 'module'; const { createResolve, } = require('../dist-raw/node-esm-resolve-implementation'); @@ -68,7 +69,7 @@ export namespace NodeLoaderHooksAPI2 { parentURL: string; }, defaultResolve: ResolveHook - ) => Promise<{ url: string }>; + ) => Promise<{ url: string; format?: NodeLoaderHooksFormat }>; export type LoadHook = ( url: string, context: { @@ -123,7 +124,6 @@ export function createEsmHooks(tsNodeService: Service) { const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI ? { resolve, load, getFormat: undefined, transformSource: undefined } : { resolve, getFormat, transformSource, load: undefined }; - return hooksAPI; function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) { // We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo` @@ -131,39 +131,86 @@ export function createEsmHooks(tsNodeService: Service) { return protocol === null || protocol === 'file:'; } + /** + * Named "probably" as a reminder that this is a guess. + * node does not explicitly tell us if we're resolving the entrypoint or not. + */ + function isProbablyEntrypoint(specifier: string, parentURL: string) { + return parentURL === undefined && specifier.startsWith('file://'); + } + // Side-channel between `resolve()` and `load()` hooks + const rememberIsProbablyEntrypoint = new Set(); + const rememberResolvedViaCommonjsFallback = new Set(); + async function resolve( specifier: string, context: { parentURL: string }, defaultResolve: typeof resolve - ): Promise<{ url: string }> { + ): Promise<{ url: string; format?: NodeLoaderHooksFormat }> { const defer = async () => { const r = await defaultResolve(specifier, context, defaultResolve); return r; }; + // See: https://github.com/nodejs/node/discussions/41711 + // nodejs will likely implement a similar fallback. Till then, we can do our users a favor and fallback today. + async function entrypointFallback( + cb: () => ReturnType + ): ReturnType { + try { + const resolution = await cb(); + if ( + resolution?.url && + isProbablyEntrypoint(specifier, context.parentURL) + ) + rememberIsProbablyEntrypoint.add(resolution.url); + return resolution; + } catch (esmResolverError) { + if (!isProbablyEntrypoint(specifier, context.parentURL)) + throw esmResolverError; + try { + let cjsSpecifier = specifier; + // Attempt to convert from ESM file:// to CommonJS path + try { + if (specifier.startsWith('file://')) + cjsSpecifier = fileURLToPath(specifier); + } catch {} + const resolution = pathToFileURL( + createRequire(process.cwd()).resolve(cjsSpecifier) + ).toString(); + rememberIsProbablyEntrypoint.add(resolution); + rememberResolvedViaCommonjsFallback.add(resolution); + return { url: resolution, format: 'commonjs' }; + } catch (commonjsResolverError) { + throw esmResolverError; + } + } + } const parsed = parseUrl(specifier); const { pathname, protocol, hostname } = parsed; if (!isFileUrlOrNodeStyleSpecifier(parsed)) { - return defer(); + return entrypointFallback(defer); } if (protocol !== null && protocol !== 'file:') { - return defer(); + return entrypointFallback(defer); } // Malformed file:// URL? We should always see `null` or `''` if (hostname) { // TODO file://./foo sets `hostname` to `'.'`. Perhaps we should special-case this. - return defer(); + return entrypointFallback(defer); } // pathname is the path to be resolved - return nodeResolveImplementation.defaultResolve( - specifier, - context, - defaultResolve + return entrypointFallback(() => + nodeResolveImplementation.defaultResolve( + specifier, + context, + defaultResolve + ) ); } @@ -230,10 +277,23 @@ export function createEsmHooks(tsNodeService: Service) { const defer = (overrideUrl: string = url) => defaultGetFormat(overrideUrl, context, defaultGetFormat); + // See: https://github.com/nodejs/node/discussions/41711 + // nodejs will likely implement a similar fallback. Till then, we can do our users a favor and fallback today. + async function entrypointFallback( + cb: () => ReturnType + ): ReturnType { + try { + return await cb(); + } catch (getFormatError) { + if (!rememberIsProbablyEntrypoint.has(url)) throw getFormatError; + return { format: 'commonjs' }; + } + } + const parsed = parseUrl(url); if (!isFileUrlOrNodeStyleSpecifier(parsed)) { - return defer(); + return entrypointFallback(defer); } const { pathname } = parsed; @@ -248,9 +308,11 @@ export function createEsmHooks(tsNodeService: Service) { const ext = extname(nativePath); let nodeSays: { format: NodeLoaderHooksFormat }; if (ext !== '.js' && !tsNodeService.ignored(nativePath)) { - nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js'))); + nodeSays = await entrypointFallback(() => + defer(formatUrl(pathToFileURL(nativePath + '.js'))) + ); } else { - nodeSays = await defer(); + nodeSays = await entrypointFallback(defer); } // For files compiled by ts-node that node believes are either CJS or ESM, check if we should override that classification if ( @@ -300,4 +362,6 @@ export function createEsmHooks(tsNodeService: Service) { return { source: emittedJs }; } + + return hooksAPI; } diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts index 42dce0f0d..d4a943798 100644 --- a/src/test/esm-loader.spec.ts +++ b/src/test/esm-loader.spec.ts @@ -5,9 +5,14 @@ import { context } from './testlib'; import semver = require('semver'); import { + BIN_PATH, CMD_ESM_LOADER_WITHOUT_PROJECT, + CMD_TS_NODE_WITHOUT_PROJECT_FLAG, contextTsNodeUnderTest, EXPERIMENTAL_MODULES_FLAG, + nodeSupportsEsmHooks, + nodeSupportsImportAssertions, + nodeUsesNewHooksApi, resetNodeEnvironment, TEST_DIR, } from './helpers'; @@ -15,9 +20,7 @@ import { createExec } from './exec-helpers'; import { join, resolve } from 'path'; import * as expect from 'expect'; import type { NodeLoaderHooksAPI2 } from '../'; - -const nodeUsesNewHooksApi = semver.gte(process.version, '16.12.0'); -const nodeSupportsImportAssertions = semver.gte(process.version, '17.1.0'); +import { pathToFileURL } from 'url'; const test = context(contextTsNodeUnderTest); @@ -25,119 +28,354 @@ const exec = createExec({ cwd: TEST_DIR, }); -test.suite('createEsmHooks', (test) => { - if (semver.gte(process.version, '12.16.0')) { - test('should create proper hooks with provided instance', async () => { - const { err } = await exec( - `node ${EXPERIMENTAL_MODULES_FLAG} --loader ./loader.mjs index.ts`, +test.suite('esm', (test) => { + test.suite('when node supports loader hooks', (test) => { + test.runIf(nodeSupportsEsmHooks); + test('should compile and execute as ESM', async () => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`, { - cwd: join(TEST_DIR, './esm-custom-loader'), + cwd: join(TEST_DIR, './esm'), } ); + expect(err).toBe(null); + expect(stdout).toBe('foo bar baz biff libfoo\n'); + }); + test('should use source maps', async () => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} "throw error.ts"`, + { + cwd: join(TEST_DIR, './esm'), + } + ); + expect(err).not.toBe(null); + expect(err!.message).toMatch( + [ + `${pathToFileURL(join(TEST_DIR, './esm/throw error.ts')) + .toString() + .replace(/%20/g, ' ')}:100`, + " bar() { throw new Error('this is a demo'); }", + ' ^', + 'Error: this is a demo', + ].join('\n') + ); + }); - if (err === null) { - throw new Error('Command was expected to fail, but it succeeded.'); - } - - expect(err.message).toMatch(/TS6133:\s+'unusedVar'/); + test.suite('supports experimental-specifier-resolution=node', (test) => { + test('via --experimental-specifier-resolution', async () => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-specifier-resolution=node index.ts`, + { cwd: join(TEST_DIR, './esm-node-resolver') } + ); + expect(err).toBe(null); + expect(stdout).toBe('foo bar baz biff libfoo\n'); + }); + test('via --es-module-specifier-resolution alias', async () => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} ${EXPERIMENTAL_MODULES_FLAG} --es-module-specifier-resolution=node index.ts`, + { cwd: join(TEST_DIR, './esm-node-resolver') } + ); + expect(err).toBe(null); + expect(stdout).toBe('foo bar baz biff libfoo\n'); + }); + test('via NODE_OPTIONS', async () => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`, + { + cwd: join(TEST_DIR, './esm-node-resolver'), + env: { + ...process.env, + NODE_OPTIONS: `${EXPERIMENTAL_MODULES_FLAG} --experimental-specifier-resolution=node`, + }, + } + ); + expect(err).toBe(null); + expect(stdout).toBe('foo bar baz biff libfoo\n'); + }); }); - } -}); -test.suite('hooks', (_test) => { - const test = _test.context(async (t) => { - const service = t.context.tsNodeUnderTest.create({ - cwd: TEST_DIR, + test('throws ERR_REQUIRE_ESM when attempting to require() an ESM script when ESM loader is enabled', async () => { + const { err, stderr } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./index.js`, + { + cwd: join(TEST_DIR, './esm-err-require-esm'), + } + ); + expect(err).not.toBe(null); + expect(stderr).toMatch( + 'Error [ERR_REQUIRE_ESM]: Must use import to load ES Module:' + ); }); - t.teardown(() => { - resetNodeEnvironment(); + + test('defers to fallback loaders when URL should not be handled by ts-node', async () => { + const { err, stdout, stderr } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.mjs`, + { + cwd: join(TEST_DIR, './esm-import-http-url'), + } + ); + expect(err).not.toBe(null); + // expect error from node's default resolver + expect(stderr).toMatch( + /Error \[ERR_UNSUPPORTED_ESM_URL_SCHEME\]:.*(?:\n.*){0,2}\n *at defaultResolve/ + ); }); - return { - service, - hooks: t.context.tsNodeUnderTest.createEsmHooks(service), - }; - }); - if (nodeUsesNewHooksApi) { - test('Correctly determines format of data URIs', async (t) => { - const { hooks } = t.context; - const url = 'data:text/javascript,console.log("hello world");'; - const result = await (hooks as NodeLoaderHooksAPI2).load( - url, - { format: undefined }, - async (url, context, _ignored) => { - return { format: context.format!, source: '' }; + test('should bypass import cache when changing search params', async () => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`, + { + cwd: join(TEST_DIR, './esm-import-cache'), } ); - expect(result.format).toBe('module'); + expect(err).toBe(null); + expect(stdout).toBe('log1\nlog2\nlog2\n'); }); - } -}); -if (nodeSupportsImportAssertions) { - test.suite('Supports import assertions', (test) => { - test('Can import JSON using the appropriate flag and assertion', async (t) => { + test('should support transpile only mode via dedicated loader entrypoint', async () => { const { err, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-json-modules ./importJson.ts`, + `${CMD_ESM_LOADER_WITHOUT_PROJECT}/transpile-only index.ts`, { - cwd: resolve(TEST_DIR, 'esm-import-assertions'), + cwd: join(TEST_DIR, './esm-transpile-only'), } ); expect(err).toBe(null); - expect(stdout.trim()).toBe( - 'A fuchsia car has 2 seats and the doors are open.\nDone!' + expect(stdout).toBe(''); + }); + test('should throw type errors without transpile-only enabled', async () => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`, + { + cwd: join(TEST_DIR, './esm-transpile-only'), + } + ); + if (err === null) { + throw new Error('Command was expected to fail, but it succeeded.'); + } + + expect(err.message).toMatch('Unable to compile TypeScript'); + expect(err.message).toMatch( + new RegExp( + "TS2345: Argument of type '(?:number|1101)' is not assignable to parameter of type 'string'\\." + ) + ); + expect(err.message).toMatch( + new RegExp( + "TS2322: Type '(?:\"hello world\"|string)' is not assignable to type 'number'\\." + ) ); + expect(stdout).toBe(''); }); + + test.suite('moduleTypes', (test) => { + suite('with vanilla ts transpilation', 'tsconfig.json'); + suite('with third-party-transpiler', 'tsconfig-swc.json'); + function suite(name: string, tsconfig: string) { + test.suite(name, (test) => { + test('supports CJS webpack.config.ts in an otherwise ESM project', async (t) => { + // A notable case where you can use ts-node's CommonJS loader, not the ESM loader, in an ESM project: + // when loading a webpack.config.ts or similar config + const { err, stdout } = await exec( + `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} --project ./module-types/override-to-cjs/${tsconfig} ./module-types/override-to-cjs/test-webpack-config.cjs` + ); + expect(err).toBe(null); + expect(stdout).toBe(``); + }); + test('should allow importing CJS in an otherwise ESM project', async (t) => { + await run('override-to-cjs', tsconfig, 'cjs'); + if (semver.gte(process.version, '14.13.1')) + await run('override-to-cjs', tsconfig, 'mjs'); + }); + test('should allow importing ESM in an otherwise CJS project', async (t) => { + await run('override-to-esm', tsconfig, 'cjs'); + // Node 14.13.0 has a bug(?) where it checks for ESM-only syntax *before* we transform the code. + if (semver.gte(process.version, '14.13.1')) + await run('override-to-esm', tsconfig, 'mjs'); + }); + }); + } + async function run(project: string, config: string, ext: string) { + const { err, stderr, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./module-types/${project}/test.${ext}`, + { + env: { + ...process.env, + TS_NODE_PROJECT: `./module-types/${project}/${config}`, + }, + } + ); + expect(err).toBe(null); + expect(stdout).toBe(`Failures: 0\n`); + } + }); + + test.suite('createEsmHooks()', (test) => { + test('should create proper hooks with provided instance', async () => { + const { err } = await exec( + `node ${EXPERIMENTAL_MODULES_FLAG} --loader ./loader.mjs index.ts`, + { + cwd: join(TEST_DIR, './esm-custom-loader'), + } + ); + + if (err === null) { + throw new Error('Command was expected to fail, but it succeeded.'); + } + + expect(err.message).toMatch(/TS6133:\s+'unusedVar'/); + }); + }); + + test.suite('unit test hooks', (_test) => { + const test = _test.context(async (t) => { + const service = t.context.tsNodeUnderTest.create({ + cwd: TEST_DIR, + }); + t.teardown(() => { + resetNodeEnvironment(); + }); + return { + service, + hooks: t.context.tsNodeUnderTest.createEsmHooks(service), + }; + }); + + test.suite('data URIs', (test) => { + test.runIf(nodeUsesNewHooksApi); + + test('Correctly determines format of data URIs', async (t) => { + const { hooks } = t.context; + const url = 'data:text/javascript,console.log("hello world");'; + const result = await (hooks as NodeLoaderHooksAPI2).load( + url, + { format: undefined }, + async (url, context, _ignored) => { + return { format: context.format!, source: '' }; + } + ); + expect(result.format).toBe('module'); + }); + }); + }); + + test.suite('supports import assertions', (test) => { + test.runIf(nodeSupportsImportAssertions); + + test('Can import JSON using the appropriate flag and assertion', async (t) => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-json-modules ./importJson.ts`, + { + cwd: resolve(TEST_DIR, 'esm-import-assertions'), + } + ); + expect(err).toBe(null); + expect(stdout.trim()).toBe( + 'A fuchsia car has 2 seats and the doors are open.\nDone!' + ); + }); + }); + + test.suite( + 'Entrypoint resolution falls back to CommonJS resolver and format', + (test) => { + test('extensionless entrypoint', async (t) => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint` + ); + expect(err).toBe(null); + expect(stdout.trim()).toBe('Hello world!'); + }); + test('relies upon CommonJS resolution', async (t) => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution` + ); + expect(err).toBe(null); + expect(stdout.trim()).toBe('Hello world!'); + }); + test('fails as expected when entrypoint does not exist at all', async (t) => { + const { err, stderr } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./esm-loader-entrypoint-cjs-fallback/does-not-exist` + ); + expect(err).toBeDefined(); + expect(stderr).toContain(`Cannot find module `); + }); + } + ); }); - test.suite("Catch unexpected changes to node's loader context", (test) => { - /* - * This does not test ts-node. - * Rather, it is meant to alert us to potentially breaking changes in node's - * loader API. If node starts returning more or less properties on `context` - * objects, we want to know, because it may indicate that our loader code - * should be updated to accomodate the new properties, either by proxying them, - * modifying them, or suppressing them. - */ - test('Ensure context passed to loader by node has only expected properties', async (t) => { - const { stdout, stderr } = await exec( - `node --loader ./esm-loader-context/loader.mjs --experimental-json-modules ./esm-loader-context/index.mjs` + test.suite('node >= 12.x.x', (test) => { + test.runIf(semver.gte(process.version, '12.0.0')); + test('throws ERR_REQUIRE_ESM when attempting to require() an ESM script when ESM loader is *not* enabled and node version is >= 12', async () => { + // Node versions >= 12 support package.json "type" field and so will throw an error when attempting to load ESM as CJS + const { err, stderr } = await exec(`${BIN_PATH} ./index.js`, { + cwd: join(TEST_DIR, './esm-err-require-esm'), + }); + expect(err).not.toBe(null); + expect(stderr).toMatch( + 'Error [ERR_REQUIRE_ESM]: Must use import to load ES Module:' ); - const rows = stdout.split('\n').filter((v) => v[0] === '{'); - expect(rows.length).toBe(14); - rows.forEach((row) => { - const json = JSON.parse(row) as { - resolveContextKeys?: string[]; - loadContextKeys?: string; - }; - if (json.resolveContextKeys) { - expect(json.resolveContextKeys).toEqual([ - 'conditions', - 'importAssertions', - 'parentURL', - ]); - } else if (json.loadContextKeys) { - try { + }); + }); + test.suite('node < 12.x.x', (test) => { + test.runIf(semver.lt(process.version, '12.0.0')); + test('Loads as CommonJS when attempting to require() an ESM script when ESM loader is *not* enabled and node version is < 12', async () => { + // Node versions less than 12 do not support package.json "type" field and so will load ESM as CommonJS + const { err, stdout } = await exec(`${BIN_PATH} ./index.js`, { + cwd: join(TEST_DIR, './esm-err-require-esm'), + }); + expect(err).toBe(null); + expect(stdout).toMatch('CommonJS'); + }); + }); +}); + +test.suite("Catch unexpected changes to node's loader context", (test) => { + // loader context includes import assertions, therefore this test requires support for import assertions + test.runIf(nodeSupportsImportAssertions); + + /* + * This does not test ts-node. + * Rather, it is meant to alert us to potentially breaking changes in node's + * loader API. If node starts returning more or less properties on `context` + * objects, we want to know, because it may indicate that our loader code + * should be updated to accomodate the new properties, either by proxying them, + * modifying them, or suppressing them. + */ + test('Ensure context passed to loader by node has only expected properties', async (t) => { + const { stdout, stderr } = await exec( + `node --loader ./esm-loader-context/loader.mjs --experimental-json-modules ./esm-loader-context/index.mjs` + ); + const rows = stdout.split('\n').filter((v) => v[0] === '{'); + expect(rows.length).toBe(14); + rows.forEach((row) => { + const json = JSON.parse(row) as { + resolveContextKeys?: string[]; + loadContextKeys?: string; + }; + if (json.resolveContextKeys) { + expect(json.resolveContextKeys).toEqual([ + 'conditions', + 'importAssertions', + 'parentURL', + ]); + } else if (json.loadContextKeys) { + try { + expect(json.loadContextKeys).toEqual(['format', 'importAssertions']); + } catch (e) { + // HACK for https://github.com/TypeStrong/ts-node/issues/1641 + if (process.version.includes('nightly')) { expect(json.loadContextKeys).toEqual([ 'format', 'importAssertions', + 'parentURL', ]); - } catch (e) { - // HACK for https://github.com/TypeStrong/ts-node/issues/1641 - if (process.version.includes('nightly')) { - expect(json.loadContextKeys).toEqual([ - 'format', - 'importAssertions', - 'parentURL', - ]); - } else { - throw e; - } + } else { + throw e; } - } else { - throw new Error('Unexpected stdout in test.'); } - }); + } else { + throw new Error('Unexpected stdout in test.'); + } }); }); -} +}); diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 83d45d3f9..2ff36f157 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -17,6 +17,13 @@ import semver = require('semver'); const createRequire: typeof _createRequire = require('create-require'); export { tsNodeTypes }; +export const nodeSupportsEsmHooks = semver.gte(process.version, '12.16.0'); +export const nodeUsesNewHooksApi = semver.gte(process.version, '16.12.0'); +export const nodeSupportsImportAssertions = semver.gte( + process.version, + '17.1.0' +); + export const ROOT_DIR = resolve(__dirname, '../..'); export const DIST_DIR = resolve(__dirname, '..'); export const TEST_DIR = join(__dirname, '../../tests'); diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index b69670a64..a709e4e8a 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -3,7 +3,7 @@ import * as expect from 'expect'; import { join, resolve, sep as pathSep } from 'path'; import { tmpdir } from 'os'; import semver = require('semver'); -import { ts } from './helpers'; +import { nodeSupportsEsmHooks, ts } from './helpers'; import { lstatSync, mkdtempSync } from 'fs'; import { npath } from '@yarnpkg/fslib'; import type _createRequire from 'create-require'; @@ -359,7 +359,7 @@ test.suite('ts-node', (test) => { }); }); - if (semver.gte(process.version, '12.16.0')) { + if (nodeSupportsEsmHooks) { test('swc transpiler supports native ESM emit', async () => { const { err, stdout } = await exec( `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./index.ts`, @@ -1058,210 +1058,6 @@ test.suite('ts-node', (test) => { ); }); }); - - test.suite('esm', (test) => { - if (semver.gte(process.version, '12.16.0')) { - test('should compile and execute as ESM', async () => { - const { err, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`, - { - cwd: join(TEST_DIR, './esm'), - } - ); - expect(err).toBe(null); - expect(stdout).toBe('foo bar baz biff libfoo\n'); - }); - test('should use source maps', async () => { - const { err, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} "throw error.ts"`, - { - cwd: join(TEST_DIR, './esm'), - } - ); - expect(err).not.toBe(null); - expect(err!.message).toMatch( - [ - `${pathToFileURL(join(TEST_DIR, './esm/throw error.ts')) - .toString() - .replace(/%20/g, ' ')}:100`, - " bar() { throw new Error('this is a demo'); }", - ' ^', - 'Error: this is a demo', - ].join('\n') - ); - }); - - test.suite('supports experimental-specifier-resolution=node', (test) => { - test('via --experimental-specifier-resolution', async () => { - const { err, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-specifier-resolution=node index.ts`, - { cwd: join(TEST_DIR, './esm-node-resolver') } - ); - expect(err).toBe(null); - expect(stdout).toBe('foo bar baz biff libfoo\n'); - }); - test('via --es-module-specifier-resolution alias', async () => { - const { err, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} ${EXPERIMENTAL_MODULES_FLAG} --es-module-specifier-resolution=node index.ts`, - { cwd: join(TEST_DIR, './esm-node-resolver') } - ); - expect(err).toBe(null); - expect(stdout).toBe('foo bar baz biff libfoo\n'); - }); - test('via NODE_OPTIONS', async () => { - const { err, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`, - { - cwd: join(TEST_DIR, './esm-node-resolver'), - env: { - ...process.env, - NODE_OPTIONS: `${EXPERIMENTAL_MODULES_FLAG} --experimental-specifier-resolution=node`, - }, - } - ); - expect(err).toBe(null); - expect(stdout).toBe('foo bar baz biff libfoo\n'); - }); - }); - - test('throws ERR_REQUIRE_ESM when attempting to require() an ESM script when ESM loader is enabled', async () => { - const { err, stderr } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./index.js`, - { - cwd: join(TEST_DIR, './esm-err-require-esm'), - } - ); - expect(err).not.toBe(null); - expect(stderr).toMatch( - 'Error [ERR_REQUIRE_ESM]: Must use import to load ES Module:' - ); - }); - - test('defers to fallback loaders when URL should not be handled by ts-node', async () => { - const { err, stdout, stderr } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.mjs`, - { - cwd: join(TEST_DIR, './esm-import-http-url'), - } - ); - expect(err).not.toBe(null); - // expect error from node's default resolver - expect(stderr).toMatch( - /Error \[ERR_UNSUPPORTED_ESM_URL_SCHEME\]:.*(?:\n.*){0,2}\n *at defaultResolve/ - ); - }); - - test('should bypass import cache when changing search params', async () => { - const { err, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`, - { - cwd: join(TEST_DIR, './esm-import-cache'), - } - ); - expect(err).toBe(null); - expect(stdout).toBe('log1\nlog2\nlog2\n'); - }); - - test('should support transpile only mode via dedicated loader entrypoint', async () => { - const { err, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT}/transpile-only index.ts`, - { - cwd: join(TEST_DIR, './esm-transpile-only'), - } - ); - expect(err).toBe(null); - expect(stdout).toBe(''); - }); - test('should throw type errors without transpile-only enabled', async () => { - const { err, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} index.ts`, - { - cwd: join(TEST_DIR, './esm-transpile-only'), - } - ); - if (err === null) { - throw new Error('Command was expected to fail, but it succeeded.'); - } - - expect(err.message).toMatch('Unable to compile TypeScript'); - expect(err.message).toMatch( - new RegExp( - "TS2345: Argument of type '(?:number|1101)' is not assignable to parameter of type 'string'\\." - ) - ); - expect(err.message).toMatch( - new RegExp( - "TS2322: Type '(?:\"hello world\"|string)' is not assignable to type 'number'\\." - ) - ); - expect(stdout).toBe(''); - }); - - test.suite('moduleTypes', (test) => { - suite('with vanilla ts transpilation', 'tsconfig.json'); - suite('with third-party-transpiler', 'tsconfig-swc.json'); - function suite(name: string, tsconfig: string) { - test.suite(name, (test) => { - test('supports CJS webpack.config.ts in an otherwise ESM project', async (t) => { - // A notable case where you can use ts-node's CommonJS loader, not the ESM loader, in an ESM project: - // when loading a webpack.config.ts or similar config - const { err, stdout } = await exec( - `${CMD_TS_NODE_WITHOUT_PROJECT_FLAG} --project ./module-types/override-to-cjs/${tsconfig} ./module-types/override-to-cjs/test-webpack-config.cjs` - ); - expect(err).toBe(null); - expect(stdout).toBe(``); - }); - test('should allow importing CJS in an otherwise ESM project', async (t) => { - await run('override-to-cjs', tsconfig, 'cjs'); - if (semver.gte(process.version, '14.13.1')) - await run('override-to-cjs', tsconfig, 'mjs'); - }); - test('should allow importing ESM in an otherwise CJS project', async (t) => { - await run('override-to-esm', tsconfig, 'cjs'); - // Node 14.13.0 has a bug(?) where it checks for ESM-only syntax *before* we transform the code. - if (semver.gte(process.version, '14.13.1')) - await run('override-to-esm', tsconfig, 'mjs'); - }); - }); - } - async function run(project: string, config: string, ext: string) { - const { err, stderr, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./module-types/${project}/test.${ext}`, - { - env: { - ...process.env, - TS_NODE_PROJECT: `./module-types/${project}/${config}`, - }, - } - ); - expect(err).toBe(null); - expect(stdout).toBe(`Failures: 0\n`); - } - }); - } - - if (semver.gte(process.version, '12.0.0')) { - test('throws ERR_REQUIRE_ESM when attempting to require() an ESM script when ESM loader is *not* enabled and node version is >= 12', async () => { - // Node versions >= 12 support package.json "type" field and so will throw an error when attempting to load ESM as CJS - const { err, stderr } = await exec(`${BIN_PATH} ./index.js`, { - cwd: join(TEST_DIR, './esm-err-require-esm'), - }); - expect(err).not.toBe(null); - expect(stderr).toMatch( - 'Error [ERR_REQUIRE_ESM]: Must use import to load ES Module:' - ); - }); - } else { - test('Loads as CommonJS when attempting to require() an ESM script when ESM loader is *not* enabled and node version is < 12', async () => { - // Node versions less than 12 do not support package.json "type" field and so will load ESM as CommonJS - const { err, stdout } = await exec(`${BIN_PATH} ./index.js`, { - cwd: join(TEST_DIR, './esm-err-require-esm'), - }); - expect(err).toBe(null); - expect(stdout).toMatch('CommonJS'); - }); - } - }); }); test('Falls back to transpileOnly when ts compiler returns emitSkipped', async () => { diff --git a/src/test/repl/node-repl-tla.ts b/src/test/repl/node-repl-tla.ts index 5c4962e78..c3926566f 100644 --- a/src/test/repl/node-repl-tla.ts +++ b/src/test/repl/node-repl-tla.ts @@ -4,6 +4,7 @@ import { Stream } from 'stream'; import semver = require('semver'); import { ts } from '../helpers'; import type { ContextWithTsNodeUnderTest } from './helpers'; +import { nodeSupportsEsmHooks } from '../helpers'; interface SharedObjects extends ContextWithTsNodeUnderTest { TEST_DIR: string; @@ -127,12 +128,12 @@ export async function upstreamTopLevelAwaitTests({ [ 'Bar', // Adjusted since ts-node supports older versions of node - semver.gte(process.version, '12.16.0') + nodeSupportsEsmHooks ? 'Uncaught ReferenceError: Bar is not defined' : 'ReferenceError: Bar is not defined', // Line increased due to TS added lines { - line: semver.gte(process.version, '12.16.0') ? 4 : 5, + line: nodeSupportsEsmHooks ? 4 : 5, }, ], @@ -144,12 +145,12 @@ export async function upstreamTopLevelAwaitTests({ [ 'j', // Adjusted since ts-node supports older versions of node - semver.gte(process.version, '12.16.0') + nodeSupportsEsmHooks ? 'Uncaught ReferenceError: j is not defined' : 'ReferenceError: j is not defined', // Line increased due to TS added lines { - line: semver.gte(process.version, '12.16.0') ? 4 : 5, + line: nodeSupportsEsmHooks ? 4 : 5, }, ], @@ -158,12 +159,12 @@ export async function upstreamTopLevelAwaitTests({ [ 'return 42; await 5;', // Adjusted since ts-node supports older versions of node - semver.gte(process.version, '12.16.0') + nodeSupportsEsmHooks ? 'Uncaught SyntaxError: Illegal return statement' : 'SyntaxError: Illegal return statement', // Line increased due to TS added lines { - line: semver.gte(process.version, '12.16.0') ? 4 : 5, + line: nodeSupportsEsmHooks ? 4 : 5, }, ], diff --git a/src/test/testlib.ts b/src/test/testlib.ts index 4ce806dd5..377d93ef3 100644 --- a/src/test/testlib.ts +++ b/src/test/testlib.ts @@ -34,6 +34,7 @@ export const test = createTestInterface({ beforeEachFunctions: [], mustDoSerial: false, automaticallyDoSerial: false, + automaticallySkip: false, separator: ' > ', titlePrefix: undefined, }); @@ -96,6 +97,11 @@ export interface TestInterface< runSerially(): void; + /** Skip tests unless this condition is met */ + skipUnless(conditional: boolean): void; + /** If conditional is true, run tests, otherwise skip them */ + runIf(conditional: boolean): void; + // TODO add teardownEach } function createTestInterface(opts: { @@ -103,11 +109,12 @@ function createTestInterface(opts: { separator: string | undefined; mustDoSerial: boolean; automaticallyDoSerial: boolean; + automaticallySkip: boolean; beforeEachFunctions: Function[]; }): TestInterface { const { titlePrefix, separator = ' > ' } = opts; const beforeEachFunctions = [...(opts.beforeEachFunctions ?? [])]; - let { mustDoSerial, automaticallyDoSerial } = opts; + let { mustDoSerial, automaticallyDoSerial, automaticallySkip } = opts; let hookDeclared = false; let suiteOrTestDeclared = false; function computeTitle(title: string | undefined) { @@ -142,13 +149,20 @@ function createTestInterface(opts: { } hookDeclared = true; } + function assertOrderingForDeclaringSkipUnless() { + if (suiteOrTestDeclared) { + throw new Error( + 'skipUnless or runIf must be declared before declaring sub-suites or tests' + ); + } + } /** * @param avaDeclareFunction either test or test.serial */ function declareTest( title: string | undefined, macros: Function[], - avaDeclareFunction: Function, + avaDeclareFunction: Function & { skip: Function }, args: any[] ) { const wrappedMacros = macros.map((macro) => { @@ -164,7 +178,11 @@ function createTestInterface(opts: { }; }); const computedTitle = computeTitle(title); - avaDeclareFunction(computedTitle, wrappedMacros, ...args); + (automaticallySkip ? avaDeclareFunction.skip : avaDeclareFunction)( + computedTitle, + wrappedMacros, + ...args + ); } function test(...inputArgs: any[]) { assertOrderingForDeclaringTest(); @@ -234,9 +252,11 @@ function createTestInterface(opts: { title: string, cb: (test: TestInterface) => void ) { + suiteOrTestDeclared = true; const newApi = createTestInterface({ mustDoSerial, automaticallyDoSerial, + automaticallySkip, separator, titlePrefix: computeTitle(title), beforeEachFunctions, @@ -246,5 +266,9 @@ function createTestInterface(opts: { test.runSerially = function () { automaticallyDoSerial = true; }; + test.skipUnless = test.runIf = function (runIfTrue: boolean) { + assertOrderingForDeclaringSkipUnless(); + automaticallySkip = automaticallySkip || !runIfTrue; + }; return test as any; } diff --git a/tests/esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint b/tests/esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint new file mode 100644 index 000000000..b9d3e23cb --- /dev/null +++ b/tests/esm-loader-entrypoint-cjs-fallback/extensionless-entrypoint @@ -0,0 +1 @@ +console.log('Hello world!'); diff --git a/tests/esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution/index.js b/tests/esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution/index.js new file mode 100644 index 000000000..b9d3e23cb --- /dev/null +++ b/tests/esm-loader-entrypoint-cjs-fallback/relies-upon-cjs-resolution/index.js @@ -0,0 +1 @@ +console.log('Hello world!'); From 3426db1d11f147bd68f3b636543100d978ac5a43 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Wed, 23 Feb 2022 00:41:23 -0500 Subject: [PATCH 06/17] Update continuous-integration.yml (#1659) --- .github/workflows/continuous-integration.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 3d2a5417f..fba007e2d 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -9,6 +9,9 @@ on: # nightly schedule: - cron: '0 0 * * *' +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true jobs: lint-build: name: "Lint & Build" From 7025abf879e6155bd78ffb75b08b09ad6f314314 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Wed, 23 Feb 2022 01:16:16 -0500 Subject: [PATCH 07/17] Fix #1657: bug where node flags were not correctly preserved in `execArgv` (#1658) * Fix bug * Fix for windows --- src/bin.ts | 2 +- src/test/helpers.ts | 1 + src/test/index.spec.ts | 40 +++++++++++++++++++++++++++++- tests/recursive-fork/index.ts | 11 ++++++++ tests/recursive-fork/package.json | 1 + tests/recursive-fork/tsconfig.json | 5 ++++ 6 files changed, 58 insertions(+), 2 deletions(-) create mode 100644 tests/recursive-fork/index.ts create mode 100644 tests/recursive-fork/package.json create mode 100644 tests/recursive-fork/tsconfig.json diff --git a/src/bin.ts b/src/bin.ts index 0194f3a3c..13907f39e 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -376,7 +376,7 @@ Options: } // Prepend `ts-node` arguments to CLI for child processes. - process.execArgv.unshift( + process.execArgv.push( __filename, ...process.argv.slice(2, process.argv.length - args._.length) ); diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 2ff36f157..5327459be 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -29,6 +29,7 @@ export const DIST_DIR = resolve(__dirname, '..'); export const TEST_DIR = join(__dirname, '../../tests'); export const PROJECT = join(TEST_DIR, 'tsconfig.json'); export const BIN_PATH = join(TEST_DIR, 'node_modules/.bin/ts-node'); +export const BIN_PATH_JS = join(TEST_DIR, 'node_modules/ts-node/dist/bin.js'); export const BIN_SCRIPT_PATH = join( TEST_DIR, 'node_modules/.bin/ts-node-script' diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index a709e4e8a..5487a7b64 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -3,7 +3,7 @@ import * as expect from 'expect'; import { join, resolve, sep as pathSep } from 'path'; import { tmpdir } from 'os'; import semver = require('semver'); -import { nodeSupportsEsmHooks, ts } from './helpers'; +import { BIN_PATH_JS, nodeSupportsEsmHooks, ts } from './helpers'; import { lstatSync, mkdtempSync } from 'fs'; import { npath } from '@yarnpkg/fslib'; import type _createRequire from 'create-require'; @@ -1071,6 +1071,44 @@ test('Falls back to transpileOnly when ts compiler returns emitSkipped', async ( expect(stdout).toBe('foo\n'); }); +test.suite('node environment', (test) => { + test.suite('Sets argv and execArgv correctly in forked processes', (test) => { + forkTest(`node --no-warnings ${BIN_PATH_JS}`, BIN_PATH_JS, '--no-warnings'); + forkTest( + `${BIN_PATH}`, + process.platform === 'win32' ? BIN_PATH_JS : BIN_PATH + ); + + function forkTest( + command: string, + expectParentArgv0: string, + nodeFlag?: string + ) { + test(command, async (t) => { + const { err, stderr, stdout } = await exec( + `${command} --skipIgnore ./recursive-fork/index.ts argv2` + ); + expect(err).toBeNull(); + expect(stderr).toBe(''); + const generations = stdout.split('\n'); + const expectation = { + execArgv: [nodeFlag, BIN_PATH_JS, '--skipIgnore'].filter((v) => v), + argv: [ + // Note: argv[0] is *always* BIN_PATH_JS in child & grandchild + expectParentArgv0, + resolve(TEST_DIR, 'recursive-fork/index.ts'), + 'argv2', + ], + }; + expect(JSON.parse(generations[0])).toMatchObject(expectation); + expectation.argv[0] = BIN_PATH_JS; + expect(JSON.parse(generations[1])).toMatchObject(expectation); + expect(JSON.parse(generations[2])).toMatchObject(expectation); + }); + } + }); +}); + test('Detect when typescript adds new ModuleKind values; flag as a failure so we can update our code flagged [MUST_UPDATE_FOR_NEW_MODULEKIND]', async () => { // We have marked a few places in our code with MUST_UPDATE_FOR_NEW_MODULEKIND to make it easier to update them when TS adds new ModuleKinds const foundKeys: string[] = []; diff --git a/tests/recursive-fork/index.ts b/tests/recursive-fork/index.ts new file mode 100644 index 000000000..ed68a2080 --- /dev/null +++ b/tests/recursive-fork/index.ts @@ -0,0 +1,11 @@ +import { fork } from 'child_process'; + +console.log(JSON.stringify({ execArgv: process.execArgv, argv: process.argv })); +if (process.env.generation !== 'grandchild') { + const nextGeneration = + process.env.generation === 'child' ? 'grandchild' : 'child'; + fork(__filename, process.argv.slice(2), { + env: { ...process.env, generation: nextGeneration }, + stdio: 'inherit', + }); +} diff --git a/tests/recursive-fork/package.json b/tests/recursive-fork/package.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/tests/recursive-fork/package.json @@ -0,0 +1 @@ +{} diff --git a/tests/recursive-fork/tsconfig.json b/tests/recursive-fork/tsconfig.json new file mode 100644 index 000000000..8e881cf9c --- /dev/null +++ b/tests/recursive-fork/tsconfig.json @@ -0,0 +1,5 @@ +{ + "compilerOptions": { + "moduleResolution": "node" + } +} From 89bde5169aefcf1012ae19a5d5f8710bf1916828 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Wed, 23 Feb 2022 02:05:21 -0500 Subject: [PATCH 08/17] Issue #1651 followup (#1660) * cleanup * lint-fix --- .../module-types/override-to-cjs/tsconfig-swc.json | 13 ++----------- .../module-types/override-to-esm/tsconfig-swc.json | 12 ++---------- 2 files changed, 4 insertions(+), 21 deletions(-) diff --git a/tests/module-types/override-to-cjs/tsconfig-swc.json b/tests/module-types/override-to-cjs/tsconfig-swc.json index ca7aae1eb..b634e34df 100644 --- a/tests/module-types/override-to-cjs/tsconfig-swc.json +++ b/tests/module-types/override-to-cjs/tsconfig-swc.json @@ -1,15 +1,6 @@ { + "extends": "./tsconfig.json", "ts-node": { - "swc": true, - "moduleTypes": { - "webpack.config.ts": "cjs", - // Test that subsequent patterns override earlier ones - "src/cjs-subdir/**/*": "esm", - "src/cjs-subdir": "cjs", - "src/cjs-subdir/esm-exception.ts": "esm" - } - }, - "compilerOptions": { - "module": "ES2015" + "swc": true } } diff --git a/tests/module-types/override-to-esm/tsconfig-swc.json b/tests/module-types/override-to-esm/tsconfig-swc.json index 35a08fc62..b634e34df 100644 --- a/tests/module-types/override-to-esm/tsconfig-swc.json +++ b/tests/module-types/override-to-esm/tsconfig-swc.json @@ -1,14 +1,6 @@ { + "extends": "./tsconfig.json", "ts-node": { - "swc": true, - "moduleTypes": { - // Test that subsequent patterns override earlier ones - "src/esm-subdir/**/*": "cjs", - "src/esm-subdir": "esm", - "src/esm-subdir/cjs-exception.ts": "cjs" - } - }, - "compilerOptions": { - "module": "CommonJS" + "swc": true } } From 4e1af52cea2ca80c148f7392eb8d4a294f1dc94f Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 1 Mar 2022 11:54:47 -0500 Subject: [PATCH 09/17] update apiextractor report prior to the release of 10.6.0 --- api-extractor/ts-node.api.md | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/api-extractor/ts-node.api.md b/api-extractor/ts-node.api.md index 67f04f2b9..8f125fb42 100644 --- a/api-extractor/ts-node.api.md +++ b/api-extractor/ts-node.api.md @@ -142,6 +142,7 @@ export namespace NodeLoaderHooksAPI2 { parentURL: string; }, defaultResolve: ResolveHook) => Promise<{ url: string; + format?: NodeLoaderHooksFormat; }>; } @@ -278,7 +279,7 @@ export interface TSCommon { // (undocumented) resolveModuleNameFromCache: typeof _ts.resolveModuleNameFromCache; // (undocumented) - resolveTypeReferenceDirective: typeof _ts.resolveTypeReferenceDirective; + resolveTypeReferenceDirective(typeReferenceDirectiveName: string, containingFile: string | undefined, options: _ts.CompilerOptions, host: _ts.ModuleResolutionHost, redirectedReference?: _ts.ResolvedProjectReference, cache?: _ts.TypeReferenceDirectiveResolutionCache, resolutionMode?: _ts.SourceFile['impliedNodeFormat']): _ts.ResolvedTypeReferenceDirectiveWithFailedLookupLocations; // (undocumented) ScriptSnapshot: typeof _ts.ScriptSnapshot; // (undocumented) @@ -291,6 +292,33 @@ export interface TSCommon { version: typeof _ts.version; } +// @public (undocumented) +export namespace TSCommon { + // (undocumented) + export type CompilerOptions = _ts.CompilerOptions; + // (undocumented) + export type FileReference = _ts.FileReference; + // (undocumented) + export interface LanguageServiceHost extends _ts.LanguageServiceHost { + // (undocumented) + resolveTypeReferenceDirectives?(typeDirectiveNames: string[] | _ts.FileReference[], containingFile: string, redirectedReference: _ts.ResolvedProjectReference | undefined, options: _ts.CompilerOptions, containingFileMode?: _ts.SourceFile['impliedNodeFormat'] | undefined): (_ts.ResolvedTypeReferenceDirective | undefined)[]; + } + // (undocumented) + export type ModuleResolutionHost = _ts.ModuleResolutionHost; + // (undocumented) + export type ParsedCommandLine = _ts.ParsedCommandLine; + // (undocumented) + export type ResolvedModule = _ts.ResolvedModule; + // (undocumented) + export type ResolvedModuleWithFailedLookupLocations = _ts.ResolvedModuleWithFailedLookupLocations; + // (undocumented) + export type ResolvedProjectReference = _ts.ResolvedProjectReference; + // (undocumented) + export type ResolvedTypeReferenceDirective = _ts.ResolvedTypeReferenceDirective; + // (undocumented) + export type SourceFile = _ts.SourceFile; +} + // @public export interface TsConfigOptions extends Omit { } From 30f03e1439685c732e6323cb4dc2e6321fb18a7b Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Tue, 1 Mar 2022 11:59:38 -0500 Subject: [PATCH 10/17] 10.6.0 --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 9147467b0..3ad590ffe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "ts-node", - "version": "10.5.0", + "version": "10.6.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index 9e8bbdb61..d303ae90d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ts-node", - "version": "10.5.0", + "version": "10.6.0", "description": "TypeScript execution environment and REPL for node.js, with source map support", "main": "dist/index.js", "exports": { From 20cbbf58565b028bfb123fade6114c7f67824d7d Mon Sep 17 00:00:00 2001 From: James Browning Date: Thu, 3 Mar 2022 12:57:35 +1300 Subject: [PATCH 11/17] Allow json modules to be resolved in Node >=17.5.0 without flag (#1665) * Enable JSON modules by default in Node >=17.5.0 * Added tests for experimental json modules no longer requiring flag * bump node version used for development; just more convenient this way Co-authored-by: Andrew Bradley --- dist-raw/node-esm-default-get-format.js | 6 +++- package.json | 2 +- src/test/esm-loader.spec.ts | 43 ++++++++++++++++++------- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/dist-raw/node-esm-default-get-format.js b/dist-raw/node-esm-default-get-format.js index d8af956f3..65b0bf6dd 100644 --- a/dist-raw/node-esm-default-get-format.js +++ b/dist-raw/node-esm-default-get-format.js @@ -11,7 +11,11 @@ const { const { extname } = require('path'); const { getOptionValue } = require('./node-options'); -const experimentalJsonModules = getOptionValue('--experimental-json-modules'); +const [nodeMajor, nodeMinor] = process.versions.node.split('.').map(s => parseInt(s, 10)); +const experimentalJsonModules = + nodeMajor > 17 + || (nodeMajor === 17 && nodeMinor >= 5) + || getOptionValue('--experimental-json-modules'); const experimentalSpeciferResolution = getOptionValue('--experimental-specifier-resolution'); const experimentalWasmModules = getOptionValue('--experimental-wasm-modules'); diff --git a/package.json b/package.json index d303ae90d..9ae86a3b9 100644 --- a/package.json +++ b/package.json @@ -171,7 +171,7 @@ "singleQuote": true }, "volta": { - "node": "16.9.1", + "node": "17.5.0", "npm": "6.14.15" } } diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts index d4a943798..76cb320d5 100644 --- a/src/test/esm-loader.spec.ts +++ b/src/test/esm-loader.spec.ts @@ -261,17 +261,38 @@ test.suite('esm', (test) => { test.suite('supports import assertions', (test) => { test.runIf(nodeSupportsImportAssertions); - test('Can import JSON using the appropriate flag and assertion', async (t) => { - const { err, stdout } = await exec( - `${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-json-modules ./importJson.ts`, - { - cwd: resolve(TEST_DIR, 'esm-import-assertions'), - } - ); - expect(err).toBe(null); - expect(stdout.trim()).toBe( - 'A fuchsia car has 2 seats and the doors are open.\nDone!' - ); + test.suite('node >=17.5.0', (test) => { + test.runIf(semver.gte(process.version, '17.5.0')); + + test('Can import JSON modules with appropriate assertion', async (t) => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} ./importJson.ts`, + { + cwd: resolve(TEST_DIR, 'esm-import-assertions'), + } + ); + expect(err).toBe(null); + expect(stdout.trim()).toBe( + 'A fuchsia car has 2 seats and the doors are open.\nDone!' + ); + }); + }); + + test.suite('node <17.5.0', (test) => { + test.runIf(semver.lt(process.version, '17.5.0')); + + test('Can import JSON using the appropriate flag and assertion', async (t) => { + const { err, stdout } = await exec( + `${CMD_ESM_LOADER_WITHOUT_PROJECT} --experimental-json-modules ./importJson.ts`, + { + cwd: resolve(TEST_DIR, 'esm-import-assertions'), + } + ); + expect(err).toBe(null); + expect(stdout.trim()).toBe( + 'A fuchsia car has 2 seats and the doors are open.\nDone!' + ); + }); }); }); From f35a1201445b004723bff6c1445e012861bde2a3 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Fri, 4 Mar 2022 21:09:44 -0500 Subject: [PATCH 12/17] lazy-load dependencies to improve responsiveness when they aren't used (#1676) * lazy-load diff and @cspotcode/source-map-support to improve responsiveness for use-cases that do not require them * lint fix --- src/index.ts | 4 +++- src/repl.ts | 11 +++++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/index.ts b/src/index.ts index ffb858101..719a3e88d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,7 +3,7 @@ import { Module } from 'module'; import * as util from 'util'; import { fileURLToPath } from 'url'; -import sourceMapSupport = require('@cspotcode/source-map-support'); +import type * as _sourceMapSupport from '@cspotcode/source-map-support'; import { BaseError } from 'make-error'; import type * as _ts from 'typescript'; @@ -793,6 +793,8 @@ export function create(rawOptions: CreateOptions = {}): Service { // Install source map support and read from memory cache. installSourceMapSupport(); function installSourceMapSupport() { + const sourceMapSupport = + require('@cspotcode/source-map-support') as typeof _sourceMapSupport; sourceMapSupport.install({ environment: 'node', retrieveFile(pathOrUrl: string) { diff --git a/src/repl.ts b/src/repl.ts index 41776e12e..c6371bdbb 100644 --- a/src/repl.ts +++ b/src/repl.ts @@ -1,4 +1,4 @@ -import { diffLines } from 'diff'; +import type * as _diff from 'diff'; import { homedir } from 'os'; import { join } from 'path'; import { @@ -26,6 +26,13 @@ function getProcessTopLevelAwait() { } return _processTopLevelAwait; } +let diff: typeof _diff; +function getDiffLines() { + if (diff === undefined) { + diff = require('diff'); + } + return diff.diffLines; +} /** @internal */ export const EVAL_FILENAME = `[eval].ts`; @@ -544,7 +551,7 @@ function appendCompileAndEvalInput(options: { ); // Use `diff` to check for new JavaScript to execute. - const changes = diffLines( + const changes = getDiffLines()( oldOutputWithoutSourcemapComment, outputWithoutSourcemapComment ); From 079206730fd75e6ff18f6998b15d08b9503cc000 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 5 Mar 2022 14:31:45 -0500 Subject: [PATCH 13/17] `ts-node-esm` / `--esm` to spawn a child process; decouple config loading from `create()`; fix pluggable dep resolution (#1655) * WIP * lint-fix * WIP * it works! * Update index.ts * Move `preferTsExts` from `RegisterOptions` to `CreateOptions` * fix * fix * fix tests * fix? * fix * fix? * fix? * fix! * fix * fix!! * fix * fix... * tweak test lib's suite delimiter to match ava's * fix * fix * docs, fixes * fix #1662 and add tests * lint-fix * test cleanup, remove or cleanup version checks, skip failing tsconfig "extends" tests on TS 2.7 * ensure tests are forced to install and use most recent ts-node tarball and cannot accidentally use project-root nor outdated tarball * fix absence of fs method on old node --- ava.config.cjs | 40 +++ ava.config.js | 14 - child-loader.mjs | 7 + dist-raw/node-primordials.js | 1 - package.json | 11 +- src/bin-esm.ts | 5 + src/bin.ts | 252 +++++++++++++++--- src/child/child-entrypoint.ts | 16 ++ src/child/child-loader.ts | 34 +++ src/child/child-require.ts | 27 ++ src/child/spawn-child.ts | 51 ++++ src/configuration.ts | 101 ++++++- src/esm.ts | 43 ++- src/index.ts | 124 ++++----- src/test/esm-loader.spec.ts | 84 +++++- src/test/exec-helpers.ts | 59 +++- src/test/helpers.ts | 41 ++- src/test/index.spec.ts | 133 +++++---- src/test/pluggable-dep-resolution.spec.ts | 98 +++++++ src/test/testlib.ts | 39 ++- src/transpilers/swc.ts | 7 +- src/transpilers/types.ts | 6 + tests/esm-child-process/via-flag/index.ts | 3 + tests/esm-child-process/via-flag/package.json | 3 + .../esm-child-process/via-flag/tsconfig.json | 9 + tests/esm-child-process/via-tsconfig/index.ts | 3 + .../via-tsconfig/package.json | 3 + tests/esm-child-process/via-tsconfig/sleep.ts | 13 + .../via-tsconfig/tsconfig.json | 10 + .../node_modules/@swc/core/index.js | 5 + .../node_modules/@swc/wasm/index.js | 5 + .../node_modules/custom-compiler/index.js | 9 + .../node_modules/custom-swc/index.js | 5 + .../node_modules/custom-transpiler/index.js | 10 + .../node_modules/@swc/core/index.js | 5 + .../node_modules/@swc/wasm/index.js | 5 + .../node_modules/custom-compiler/index.js | 9 + .../node_modules/custom-swc/index.js | 5 + .../node_modules/custom-transpiler/index.js | 10 + .../tsconfig-custom-compiler.json | 1 + .../tsconfig-custom-transpiler.json | 1 + .../shared-config/tsconfig-swc-core.json | 1 + .../tsconfig-swc-custom-backend.json | 1 + .../shared-config/tsconfig-swc-wasm.json | 1 + .../shared-config/tsconfig-swc.json | 1 + .../tsconfig-custom-compiler.json | 1 + .../tsconfig-custom-transpiler.json | 1 + .../tsconfig-extend-custom-compiler.json | 1 + .../tsconfig-extend-custom-transpiler.json | 1 + .../tsconfig-extend-swc-core.json | 1 + .../tsconfig-extend-swc-custom-backend.json | 1 + .../tsconfig-extend-swc-wasm.json | 1 + .../tsconfig-extend-swc.json | 1 + .../tsconfig-swc-core.json | 6 + .../tsconfig-swc-custom-backend.json | 6 + .../tsconfig-swc-wasm.json | 6 + .../tsconfig-swc.json | 1 + website/docs/imports.md | 28 +- website/docs/options.md | 1 + website/docs/usage.md | 3 + 60 files changed, 1139 insertions(+), 231 deletions(-) create mode 100644 ava.config.cjs delete mode 100644 ava.config.js create mode 100644 child-loader.mjs create mode 100644 src/bin-esm.ts create mode 100644 src/child/child-entrypoint.ts create mode 100644 src/child/child-loader.ts create mode 100644 src/child/child-require.ts create mode 100644 src/child/spawn-child.ts create mode 100644 src/test/pluggable-dep-resolution.spec.ts create mode 100644 tests/esm-child-process/via-flag/index.ts create mode 100644 tests/esm-child-process/via-flag/package.json create mode 100644 tests/esm-child-process/via-flag/tsconfig.json create mode 100644 tests/esm-child-process/via-tsconfig/index.ts create mode 100644 tests/esm-child-process/via-tsconfig/package.json create mode 100644 tests/esm-child-process/via-tsconfig/sleep.ts create mode 100644 tests/esm-child-process/via-tsconfig/tsconfig.json create mode 100644 tests/pluggable-dep-resolution/node_modules/@swc/core/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/@swc/wasm/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/custom-swc/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/custom-transpiler/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/core/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/wasm/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-swc/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-transpiler/index.js create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-compiler.json create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-transpiler.json create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-core.json create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-custom-backend.json create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-wasm.json create mode 100644 tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-custom-compiler.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-custom-transpiler.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-custom-compiler.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-custom-transpiler.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-swc-core.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-swc-custom-backend.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-swc-wasm.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-extend-swc.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-swc-core.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-swc-custom-backend.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-swc-wasm.json create mode 100644 tests/pluggable-dep-resolution/tsconfig-swc.json diff --git a/ava.config.cjs b/ava.config.cjs new file mode 100644 index 000000000..aa04b33bf --- /dev/null +++ b/ava.config.cjs @@ -0,0 +1,40 @@ +const expect = require('expect'); +const { createRequire } = require('module'); + +module.exports = { + files: ['dist/test/**/*.spec.js'], + failWithoutAssertions: false, + environmentVariables: { + ts_node_install_lock: `id-${Math.floor(Math.random() * 10e9)}`, + // Force jest expect() errors to generate colorized strings, makes output more readable. + // Delete the env var within ava processes via `require` option below. + // This avoids passing it to spawned processes under test, which would negatively affect + // their behavior. + FORCE_COLOR: '3', + }, + require: ['./src/test/remove-env-var-force-color.js'], + timeout: '300s', + concurrency: 1, +}; + +{ + /* + * Tests *must* install and use our most recent ts-node tarball. + * We must prevent them from accidentally require-ing a different version of + * ts-node, from either node_modules or tests/node_modules + */ + + const { existsSync } = require('fs'); + const rimraf = require('rimraf'); + const { resolve } = require('path'); + + remove(resolve(__dirname, 'node_modules/ts-node')); + remove(resolve(__dirname, 'tests/node_modules/ts-node')); + + // Prove that we did it correctly + expect(() => {createRequire(resolve(__dirname, 'tests/foo.js')).resolve('ts-node')}).toThrow(); + + function remove(p) { + if(existsSync(p)) rimraf.sync(p, {recursive: true}) + } +} diff --git a/ava.config.js b/ava.config.js deleted file mode 100644 index 6181565d3..000000000 --- a/ava.config.js +++ /dev/null @@ -1,14 +0,0 @@ -export default { - files: ['dist/test/**/*.spec.js'], - failWithoutAssertions: false, - environmentVariables: { - ts_node_install_lock: `id-${Math.floor(Math.random() * 10e9)}`, - // Force jest expect() errors to generate colorized strings, makes output more readable. - // Delete the env var within ava processes via `require` option below. - // This avoids passing it to spawned processes under test, which would negatively affect - // their behavior. - FORCE_COLOR: '3', - }, - require: ['./src/test/remove-env-var-force-color.js'], - timeout: '300s', -}; diff --git a/child-loader.mjs b/child-loader.mjs new file mode 100644 index 000000000..3a96eeea4 --- /dev/null +++ b/child-loader.mjs @@ -0,0 +1,7 @@ +import { fileURLToPath } from 'url'; +import { createRequire } from 'module'; +const require = createRequire(fileURLToPath(import.meta.url)); + +/** @type {import('./dist/child-loader')} */ +const childLoader = require('./dist/child/child-loader'); +export const { resolve, load, getFormat, transformSource } = childLoader; diff --git a/dist-raw/node-primordials.js b/dist-raw/node-primordials.js index ae3b8b911..21d8cfd19 100644 --- a/dist-raw/node-primordials.js +++ b/dist-raw/node-primordials.js @@ -1,7 +1,6 @@ module.exports = { ArrayFrom: Array.from, ArrayIsArray: Array.isArray, - ArrayPrototypeJoin: (obj, separator) => Array.prototype.join.call(obj, separator), ArrayPrototypeShift: (obj) => Array.prototype.shift.call(obj), ArrayPrototypeForEach: (arr, ...rest) => Array.prototype.forEach.apply(arr, rest), ArrayPrototypeIncludes: (arr, ...rest) => Array.prototype.includes.apply(arr, rest), diff --git a/package.json b/package.json index 9ae86a3b9..84a5ad16d 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,8 @@ "./dist/bin-script.js": "./dist/bin-script.js", "./dist/bin-cwd": "./dist/bin-cwd.js", "./dist/bin-cwd.js": "./dist/bin-cwd.js", + "./dist/bin-esm": "./dist/bin-esm.js", + "./dist/bin-esm.js": "./dist/bin-esm.js", "./register": "./register/index.js", "./register/files": "./register/files.js", "./register/transpile-only": "./register/transpile-only.js", @@ -23,6 +25,7 @@ "./esm.mjs": "./esm.mjs", "./esm/transpile-only": "./esm/transpile-only.mjs", "./esm/transpile-only.mjs": "./esm/transpile-only.mjs", + "./child-loader.mjs": "./child-loader.mjs", "./transpilers/swc": "./transpilers/swc.js", "./transpilers/swc-experimental": "./transpilers/swc-experimental.js", "./node10/tsconfig.json": "./node10/tsconfig.json", @@ -33,10 +36,11 @@ "types": "dist/index.d.ts", "bin": { "ts-node": "dist/bin.js", - "ts-script": "dist/bin-script-deprecated.js", - "ts-node-script": "dist/bin-script.js", "ts-node-cwd": "dist/bin-cwd.js", - "ts-node-transpile-only": "dist/bin-transpile.js" + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" }, "files": [ "/transpilers/", @@ -46,6 +50,7 @@ "/register/", "/esm/", "/esm.mjs", + "/child-loader.mjs", "/LICENSE", "/tsconfig.schema.json", "/tsconfig.schemastore-schema.json", diff --git a/src/bin-esm.ts b/src/bin-esm.ts new file mode 100644 index 000000000..3bc6bbbd2 --- /dev/null +++ b/src/bin-esm.ts @@ -0,0 +1,5 @@ +#!/usr/bin/env node + +import { main } from './bin'; + +main(undefined, { '--esm': true }); diff --git a/src/bin.ts b/src/bin.ts index 13907f39e..3e972dd97 100644 --- a/src/bin.ts +++ b/src/bin.ts @@ -3,7 +3,7 @@ import { join, resolve, dirname, parse as parsePath, relative } from 'path'; import { inspect } from 'util'; import Module = require('module'); -import arg = require('arg'); +let arg: typeof import('arg'); import { parse, createRequire, hasOwnProperty } from './util'; import { EVAL_FILENAME, @@ -17,17 +17,76 @@ import { STDIN_NAME, REPL_FILENAME, } from './repl'; -import { VERSION, TSError, register, versionGteLt } from './index'; +import { + VERSION, + TSError, + register, + versionGteLt, + createEsmHooks, + createFromPreloadedConfig, + DEFAULTS, +} from './index'; import type { TSInternal } from './ts-compiler-types'; import { addBuiltinLibsToObject } from '../dist-raw/node-cjs-helpers'; +import { callInChild } from './child/spawn-child'; +import { findAndReadConfig } from './configuration'; /** * Main `bin` functionality. + * + * This file is split into a chain of functions (phases), each one adding to a shared state object. + * This is done so that the next function can either be invoked in-process or, if necessary, invoked in a child process. + * + * The functions are intentionally given uncreative names and left in the same order as the original code, to make a + * smaller git diff. */ export function main( argv: string[] = process.argv.slice(2), entrypointArgs: Record = {} ) { + const args = parseArgv(argv, entrypointArgs); + const state: BootstrapState = { + shouldUseChildProcess: false, + isInChildProcess: false, + entrypoint: __filename, + parseArgvResult: args, + }; + return bootstrap(state); +} + +/** + * @internal + * Describes state of CLI bootstrapping. + * Can be marshalled when necessary to resume bootstrapping in a child process. + */ +export interface BootstrapState { + isInChildProcess: boolean; + shouldUseChildProcess: boolean; + entrypoint: string; + parseArgvResult: ReturnType; + phase2Result?: ReturnType; + phase3Result?: ReturnType; +} + +/** @internal */ +export function bootstrap(state: BootstrapState) { + if (!state.phase2Result) { + state.phase2Result = phase2(state); + if (state.shouldUseChildProcess && !state.isInChildProcess) { + return callInChild(state); + } + } + if (!state.phase3Result) { + state.phase3Result = phase3(state); + if (state.shouldUseChildProcess && !state.isInChildProcess) { + return callInChild(state); + } + } + return phase4(state); +} + +function parseArgv(argv: string[], entrypointArgs: Record) { + arg ??= require('arg'); // HACK: technically, this function is not marked @internal so it's possible // that libraries in the wild are doing `require('ts-node/dist/bin').main({'--transpile-only': true})` // We can mark this function @internal in next major release. @@ -58,6 +117,7 @@ export function main( '--scriptMode': Boolean, '--version': arg.COUNT, '--showConfig': Boolean, + '--esm': Boolean, // Project options. '--cwd': String, @@ -156,7 +216,51 @@ export function main( '--scope': scope = undefined, '--scopeDir': scopeDir = undefined, '--noExperimentalReplAwait': noExperimentalReplAwait, + '--esm': esm, + _: restArgs, } = args; + return { + // Note: argv and restArgs may be overwritten by child process + argv: process.argv, + restArgs, + + cwdArg, + help, + scriptMode, + cwdMode, + version, + showConfig, + argsRequire, + code, + print, + interactive, + files, + compiler, + compilerOptions, + project, + ignoreDiagnostics, + ignore, + transpileOnly, + typeCheck, + transpiler, + swc, + compilerHost, + pretty, + skipProject, + skipIgnore, + preferTsExts, + logError, + emit, + scope, + scopeDir, + noExperimentalReplAwait, + esm, + }; +} + +function phase2(payload: BootstrapState) { + const { help, version, code, interactive, cwdArg, restArgs, esm } = + payload.parseArgvResult; if (help) { console.log(` @@ -169,13 +273,14 @@ Options: -r, --require [path] Require a node module before execution -i, --interactive Opens the REPL even if stdin does not appear to be a terminal + --esm Bootstrap with the ESM loader, enabling full ESM support + --swc Use the faster swc transpiler + -h, --help Print CLI usage - -v, --version Print module version information - --cwdMode Use current directory instead of for config resolution + -v, --version Print module version information. -vvv to print additional information --showConfig Print resolved configuration and exit -T, --transpileOnly Use TypeScript's faster \`transpileModule\` or a third-party transpiler - --swc Use the swc transpiler -H, --compilerHost Use TypeScript's compiler host API -I, --ignore [pattern] Override the path patterns to skip compilation -P, --project [path] Path to TypeScript JSON project file @@ -187,6 +292,7 @@ Options: --cwd Behave as if invoked within this working directory. --files Load \`files\`, \`include\` and \`exclude\` from \`tsconfig.json\` on startup --pretty Use pretty diagnostic formatter (usually enabled by default) + --cwdMode Use current directory instead of for config resolution --skipProject Skip reading \`tsconfig.json\` --skipIgnore Skip \`--ignore\` checks --emit Emit output files into \`.ts-node\` directory @@ -209,8 +315,8 @@ Options: // Figure out which we are executing: piped stdin, --eval, REPL, and/or entrypoint // This is complicated because node's behavior is complicated // `node -e code -i ./script.js` ignores -e - const executeEval = code != null && !(interactive && args._.length); - const executeEntrypoint = !executeEval && args._.length > 0; + const executeEval = code != null && !(interactive && restArgs.length); + const executeEntrypoint = !executeEval && restArgs.length > 0; const executeRepl = !executeEntrypoint && (interactive || (process.stdin.isTTY && !executeEval)); @@ -218,8 +324,90 @@ Options: const cwd = cwdArg || process.cwd(); /** Unresolved. May point to a symlink, not realpath. May be missing file extension */ - const scriptPath = executeEntrypoint ? resolve(cwd, args._[0]) : undefined; + const scriptPath = executeEntrypoint ? resolve(cwd, restArgs[0]) : undefined; + + if (esm) payload.shouldUseChildProcess = true; + return { + executeEval, + executeEntrypoint, + executeRepl, + executeStdin, + cwd, + scriptPath, + }; +} +function phase3(payload: BootstrapState) { + const { + emit, + files, + pretty, + transpileOnly, + transpiler, + noExperimentalReplAwait, + typeCheck, + swc, + compilerHost, + ignore, + preferTsExts, + logError, + scriptMode, + cwdMode, + project, + skipProject, + skipIgnore, + compiler, + ignoreDiagnostics, + compilerOptions, + argsRequire, + scope, + scopeDir, + } = payload.parseArgvResult; + const { cwd, scriptPath } = payload.phase2Result!; + + const preloadedConfig = findAndReadConfig({ + cwd, + emit, + files, + pretty, + transpileOnly: transpileOnly ?? transpiler != null ? true : undefined, + experimentalReplAwait: noExperimentalReplAwait ? false : undefined, + typeCheck, + transpiler, + swc, + compilerHost, + ignore, + logError, + projectSearchDir: getProjectSearchDir(cwd, scriptMode, cwdMode, scriptPath), + project, + skipProject, + skipIgnore, + compiler, + ignoreDiagnostics, + compilerOptions, + require: argsRequire, + scope, + scopeDir, + preferTsExts, + }); + + if (preloadedConfig.options.esm) payload.shouldUseChildProcess = true; + return { preloadedConfig }; +} + +function phase4(payload: BootstrapState) { + const { isInChildProcess, entrypoint } = payload; + const { version, showConfig, restArgs, code, print, argv } = + payload.parseArgvResult; + const { + executeEval, + cwd, + executeStdin, + executeRepl, + executeEntrypoint, + scriptPath, + } = payload.phase2Result!; + const { preloadedConfig } = payload.phase3Result!; /** * , [stdin], and [eval] are all essentially virtual files that do not exist on disc and are backed by a REPL * service to handle eval-ing of code. @@ -278,33 +466,22 @@ Options: } // Register the TypeScript compiler instance. - const service = register({ - cwd, - emit, - files, - pretty, - transpileOnly: transpileOnly ?? transpiler != null ? true : undefined, - experimentalReplAwait: noExperimentalReplAwait ? false : undefined, - typeCheck, - transpiler, - swc, - compilerHost, - ignore, - preferTsExts, - logError, - projectSearchDir: getProjectSearchDir(cwd, scriptMode, cwdMode, scriptPath), - project, - skipProject, - skipIgnore, - compiler, - ignoreDiagnostics, - compilerOptions, - require: argsRequire, - readFile: evalAwarePartialHost?.readFile ?? undefined, - fileExists: evalAwarePartialHost?.fileExists ?? undefined, - scope, - scopeDir, + const service = createFromPreloadedConfig({ + // Since this struct may have been marshalled across thread or process boundaries, we must restore + // un-marshall-able values. + ...preloadedConfig, + options: { + ...preloadedConfig.options, + readFile: evalAwarePartialHost?.readFile ?? undefined, + fileExists: evalAwarePartialHost?.fileExists ?? undefined, + tsTrace: DEFAULTS.tsTrace, + }, }); + register(service); + if (isInChildProcess) + ( + require('./child/child-loader') as typeof import('./child/child-loader') + ).lateBindHooks(createEsmHooks(service)); // Bind REPL service to ts-node compiler service (chicken-and-egg problem) replStuff?.repl.setService(service); @@ -377,12 +554,13 @@ Options: // Prepend `ts-node` arguments to CLI for child processes. process.execArgv.push( - __filename, - ...process.argv.slice(2, process.argv.length - args._.length) + entrypoint, + ...argv.slice(2, argv.length - restArgs.length) ); + // TODO this comes from BoostrapState process.argv = [process.argv[1]] .concat(executeEntrypoint ? ([scriptPath] as string[]) : []) - .concat(args._.slice(executeEntrypoint ? 1 : 0)); + .concat(restArgs.slice(executeEntrypoint ? 1 : 0)); // Execute the main contents (either eval, script or piped). if (executeEntrypoint) { diff --git a/src/child/child-entrypoint.ts b/src/child/child-entrypoint.ts new file mode 100644 index 000000000..03a02d2e9 --- /dev/null +++ b/src/child/child-entrypoint.ts @@ -0,0 +1,16 @@ +import { BootstrapState, bootstrap } from '../bin'; +import { brotliDecompressSync } from 'zlib'; + +const base64ConfigArg = process.argv[2]; +const argPrefix = '--brotli-base64-config='; +if (!base64ConfigArg.startsWith(argPrefix)) throw new Error('unexpected argv'); +const base64Payload = base64ConfigArg.slice(argPrefix.length); +const payload = JSON.parse( + brotliDecompressSync(Buffer.from(base64Payload, 'base64')).toString() +) as BootstrapState; +payload.isInChildProcess = true; +payload.entrypoint = __filename; +payload.parseArgvResult.argv = process.argv; +payload.parseArgvResult.restArgs = process.argv.slice(3); + +bootstrap(payload); diff --git a/src/child/child-loader.ts b/src/child/child-loader.ts new file mode 100644 index 000000000..0ac018132 --- /dev/null +++ b/src/child/child-loader.ts @@ -0,0 +1,34 @@ +// TODO same version check as ESM loader, but export stubs +// Also export a binder function that allows re-binding where the stubs +// delegate. + +import type { NodeLoaderHooksAPI1, NodeLoaderHooksAPI2 } from '..'; +import { filterHooksByAPIVersion } from '../esm'; + +let hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2; + +/** @internal */ +export function lateBindHooks( + _hooks: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 +) { + hooks = _hooks as NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2; +} + +const proxy: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2 = { + resolve(...args: Parameters) { + return (hooks?.resolve ?? args[2])(...args); + }, + load(...args: Parameters) { + return (hooks?.load ?? args[2])(...args); + }, + getFormat(...args: Parameters) { + return (hooks?.getFormat ?? args[2])(...args); + }, + transformSource(...args: Parameters) { + return (hooks?.transformSource ?? args[2])(...args); + }, +}; + +/** @internal */ +export const { resolve, load, getFormat, transformSource } = + filterHooksByAPIVersion(proxy) as NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2; diff --git a/src/child/child-require.ts b/src/child/child-require.ts new file mode 100644 index 000000000..2ee155221 --- /dev/null +++ b/src/child/child-require.ts @@ -0,0 +1,27 @@ +interface EventEmitterInternals { + _events: Record>; +} +const _process = process as any as EventEmitterInternals; + +// Not shown here: Additional logic to correctly interact with process's events, either using this direct manipulation, or via the API + +let originalOnWarning: Function | undefined; +if (Array.isArray(_process._events.warning)) { + originalOnWarning = _process._events.warning[0]; + _process._events.warning[0] = onWarning; +} else { + originalOnWarning = _process._events.warning; + _process._events.warning = onWarning; +} + +const messageMatch = /--(?:experimental-)?loader\b/; +function onWarning(this: any, warning: Error, ...rest: any[]) { + // Suppress warning about how `--loader` is experimental + if ( + warning?.name === 'ExperimentalWarning' && + messageMatch.test(warning?.message) + ) + return; + // Will be undefined if `--no-warnings` + return originalOnWarning?.call(this, warning, ...rest); +} diff --git a/src/child/spawn-child.ts b/src/child/spawn-child.ts new file mode 100644 index 000000000..74bf4017c --- /dev/null +++ b/src/child/spawn-child.ts @@ -0,0 +1,51 @@ +import type { BootstrapState } from '../bin'; +import { spawn } from 'child_process'; +import { brotliCompressSync } from 'zlib'; +import { pathToFileURL } from 'url'; +import { versionGteLt } from '..'; + +const argPrefix = '--brotli-base64-config='; + +/** @internal */ +export function callInChild(state: BootstrapState) { + if (!versionGteLt(process.versions.node, '12.17.0')) { + throw new Error( + '`ts-node-esm` and `ts-node --esm` require node version 12.17.0 or newer.' + ); + } + const child = spawn( + process.execPath, + [ + '--require', + require.resolve('./child-require.js'), + '--loader', + // Node on Windows doesn't like `c:\` absolute paths here; must be `file:///c:/` + pathToFileURL(require.resolve('../../child-loader.mjs')).toString(), + require.resolve('./child-entrypoint.js'), + `${argPrefix}${brotliCompressSync( + Buffer.from(JSON.stringify(state), 'utf8') + ).toString('base64')}`, + ...state.parseArgvResult.restArgs, + ], + { + stdio: 'inherit', + argv0: process.argv0, + } + ); + child.on('error', (error) => { + console.error(error); + process.exit(1); + }); + child.on('exit', (code) => { + child.removeAllListeners(); + process.off('SIGINT', sendSignalToChild); + process.off('SIGTERM', sendSignalToChild); + process.exitCode = code === null ? 1 : code; + }); + // Ignore sigint and sigterm in parent; pass them to child + process.on('SIGINT', sendSignalToChild); + process.on('SIGTERM', sendSignalToChild); + function sendSignalToChild(signal: string) { + process.kill(child.pid, signal); + } +} diff --git a/src/configuration.ts b/src/configuration.ts index ff38ddd44..13b7ad28f 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -4,13 +4,19 @@ import { CreateOptions, DEFAULTS, OptionBasePaths, + RegisterOptions, TSCommon, TsConfigOptions, } from './index'; import type { TSInternal } from './ts-compiler-types'; import { createTsInternals } from './ts-internals'; import { getDefaultTsconfigJsonForNodeVersion } from './tsconfigs'; -import { assign, createProjectLocalResolveHelper } from './util'; +import { + assign, + attemptRequireWithV8CompileCache, + createProjectLocalResolveHelper, + getBasePathForProjectLocalDependencyResolution, +} from './util'; /** * TypeScript compiler option values required by `ts-node` which cannot be overridden. @@ -49,6 +55,68 @@ function fixConfig(ts: TSCommon, config: _ts.ParsedCommandLine) { return config; } +/** @internal */ +export function findAndReadConfig(rawOptions: CreateOptions) { + const cwd = resolve( + rawOptions.cwd ?? rawOptions.dir ?? DEFAULTS.cwd ?? process.cwd() + ); + const compilerName = rawOptions.compiler ?? DEFAULTS.compiler; + + // Compute minimum options to read the config file. + let projectLocalResolveDir = getBasePathForProjectLocalDependencyResolution( + undefined, + rawOptions.projectSearchDir, + rawOptions.project, + cwd + ); + let { compiler, ts } = resolveAndLoadCompiler( + compilerName, + projectLocalResolveDir + ); + + // Read config file and merge new options between env and CLI options. + const { configFilePath, config, tsNodeOptionsFromTsconfig, optionBasePaths } = + readConfig(cwd, ts, rawOptions); + + const options = assign( + {}, + DEFAULTS, + tsNodeOptionsFromTsconfig || {}, + { optionBasePaths }, + rawOptions + ); + options.require = [ + ...(tsNodeOptionsFromTsconfig.require || []), + ...(rawOptions.require || []), + ]; + + // Re-resolve the compiler in case it has changed. + // Compiler is loaded relative to tsconfig.json, so tsconfig discovery may cause us to load a + // different compiler than we did above, even if the name has not changed. + if (configFilePath) { + projectLocalResolveDir = getBasePathForProjectLocalDependencyResolution( + configFilePath, + rawOptions.projectSearchDir, + rawOptions.project, + cwd + ); + ({ compiler } = resolveCompiler( + options.compiler, + optionBasePaths.compiler ?? projectLocalResolveDir + )); + } + + return { + options, + config, + projectLocalResolveDir, + optionBasePaths, + configFilePath, + cwd, + compiler, + }; +} + /** * Load TypeScript configuration. Returns the parsed TypeScript config and * any `ts-node` options specified in the config file. @@ -193,6 +261,9 @@ export function readConfig( if (options.compiler != null) { optionBasePaths.compiler = basePath; } + if (options.swc != null) { + optionBasePaths.swc = basePath; + } assign(tsNodeOptionsFromTsconfig, options); } @@ -255,6 +326,32 @@ export function readConfig( }; } +/** + * Load the typescript compiler. It is required to load the tsconfig but might + * be changed by the tsconfig, so we have to do this twice. + * @internal + */ +export function resolveAndLoadCompiler( + name: string | undefined, + relativeToPath: string +) { + const { compiler } = resolveCompiler(name, relativeToPath); + const ts = loadCompiler(compiler); + return { compiler, ts }; +} + +function resolveCompiler(name: string | undefined, relativeToPath: string) { + const projectLocalResolveHelper = + createProjectLocalResolveHelper(relativeToPath); + const compiler = projectLocalResolveHelper(name || 'typescript', true); + return { compiler }; +} + +/** @internal */ +export function loadCompiler(compiler: string): TSCommon { + return attemptRequireWithV8CompileCache(require, compiler); +} + /** * Given the raw "ts-node" sub-object from a tsconfig, return an object with only the properties * recognized by "ts-node" @@ -286,6 +383,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): { experimentalReplAwait, swc, experimentalResolverFeatures, + esm, ...unrecognized } = jsonObject as TsConfigOptions; const filteredTsConfigOptions = { @@ -310,6 +408,7 @@ function filterRecognizedTsConfigTsNodeOptions(jsonObject: any): { moduleTypes, swc, experimentalResolverFeatures, + esm, }; // Use the typechecker to make sure this implementation has the correct set of properties const catchExtraneousProps: keyof TsConfigOptions = diff --git a/src/esm.ts b/src/esm.ts index b42af64bd..38f4f0d58 100644 --- a/src/esm.ts +++ b/src/esm.ts @@ -95,6 +95,31 @@ export type NodeLoaderHooksFormat = | 'module' | 'wasm'; +export type NodeImportConditions = unknown; +export interface NodeImportAssertions { + type?: 'json'; +} + +// The hooks API changed in node version X so we need to check for backwards compatibility. +// TODO: When the new API is backported to v12, v14, update these version checks accordingly. +const newHooksAPI = + versionGteLt(process.versions.node, '17.0.0') || + versionGteLt(process.versions.node, '16.12.0', '17.0.0') || + versionGteLt(process.versions.node, '14.999.999', '15.0.0') || + versionGteLt(process.versions.node, '12.999.999', '13.0.0'); + +/** @internal */ +export function filterHooksByAPIVersion( + hooks: NodeLoaderHooksAPI1 & NodeLoaderHooksAPI2 +): NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 { + const { getFormat, load, resolve, transformSource } = hooks; + // Explicit return type to avoid TS's non-ideal inferred type + const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI + ? { resolve, load, getFormat: undefined, transformSource: undefined } + : { resolve, getFormat, transformSource, load: undefined }; + return hooksAPI; +} + /** @internal */ export function registerAndCreateEsmHooks(opts?: RegisterOptions) { // Automatically performs registration just like `-r ts-node/register` @@ -112,18 +137,12 @@ export function createEsmHooks(tsNodeService: Service) { preferTsExts: tsNodeService.options.preferTsExts, }); - // The hooks API changed in node version X so we need to check for backwards compatibility. - // TODO: When the new API is backported to v12, v14, update these version checks accordingly. - const newHooksAPI = - versionGteLt(process.versions.node, '17.0.0') || - versionGteLt(process.versions.node, '16.12.0', '17.0.0') || - versionGteLt(process.versions.node, '14.999.999', '15.0.0') || - versionGteLt(process.versions.node, '12.999.999', '13.0.0'); - - // Explicit return type to avoid TS's non-ideal inferred type - const hooksAPI: NodeLoaderHooksAPI1 | NodeLoaderHooksAPI2 = newHooksAPI - ? { resolve, load, getFormat: undefined, transformSource: undefined } - : { resolve, getFormat, transformSource, load: undefined }; + const hooksAPI = filterHooksByAPIVersion({ + resolve, + load, + getFormat, + transformSource, + }); function isFileUrlOrNodeStyleSpecifier(parsed: UrlWithStringQuery) { // We only understand file:// URLs, but in node, the specifier can be a node-style `./foo` or `foo` diff --git a/src/index.ts b/src/index.ts index 719a3e88d..996556a7a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,4 @@ -import { relative, basename, extname, resolve, dirname, join } from 'path'; +import { relative, basename, extname, dirname, join } from 'path'; import { Module } from 'module'; import * as util from 'util'; import { fileURLToPath } from 'url'; @@ -9,18 +9,15 @@ import type * as _ts from 'typescript'; import type { Transpiler, TranspilerFactory } from './transpilers/types'; import { - assign, - attemptRequireWithV8CompileCache, cachedLookup, createProjectLocalResolveHelper, - getBasePathForProjectLocalDependencyResolution, normalizeSlashes, parse, ProjectLocalResolveHelper, split, yn, } from './util'; -import { readConfig } from './configuration'; +import { findAndReadConfig, loadCompiler } from './configuration'; import type { TSCommon, TSInternal } from './ts-compiler-types'; import { createModuleTypeClassifier, @@ -369,6 +366,18 @@ export interface CreateOptions { * @default console.log */ tsTrace?: (str: string) => void; + /** + * TODO DOCS YAY + */ + esm?: boolean; + /** + * Re-order file extensions so that TypeScript imports are preferred. + * + * For example, when both `index.js` and `index.ts` exist, enabling this option causes `require('./index')` to resolve to `index.ts` instead of `index.js` + * + * @default false + */ + preferTsExts?: boolean; } export type ModuleTypes = Record; @@ -378,21 +387,13 @@ export interface OptionBasePaths { moduleTypes?: string; transpiler?: string; compiler?: string; + swc?: string; } /** * Options for registering a TypeScript compiler instance globally. */ export interface RegisterOptions extends CreateOptions { - /** - * Re-order file extensions so that TypeScript imports are preferred. - * - * For example, when both `index.js` and `index.ts` exist, enabling this option causes `require('./index')` to resolve to `index.ts` instead of `index.js` - * - * @default false - */ - preferTsExts?: boolean; - /** * Enable experimental features that re-map imports and require calls to support: * `baseUrl`, `paths`, `rootDirs`, `.js` to `.ts` file extension mappings, @@ -592,63 +593,29 @@ export function register( * Create TypeScript compiler instance. */ export function create(rawOptions: CreateOptions = {}): Service { - const cwd = resolve( - rawOptions.cwd ?? rawOptions.dir ?? DEFAULTS.cwd ?? process.cwd() - ); - const compilerName = rawOptions.compiler ?? DEFAULTS.compiler; - - /** - * Load the typescript compiler. It is required to load the tsconfig but might - * be changed by the tsconfig, so we have to do this twice. - */ - function loadCompiler(name: string | undefined, relativeToPath: string) { - const projectLocalResolveHelper = - createProjectLocalResolveHelper(relativeToPath); - const compiler = projectLocalResolveHelper(name || 'typescript', true); - const ts: TSCommon = attemptRequireWithV8CompileCache(require, compiler); - return { compiler, ts, projectLocalResolveHelper }; - } + const foundConfigResult = findAndReadConfig(rawOptions); + return createFromPreloadedConfig(foundConfigResult); +} - // Compute minimum options to read the config file. - let { compiler, ts, projectLocalResolveHelper } = loadCompiler( - compilerName, - getBasePathForProjectLocalDependencyResolution( - undefined, - rawOptions.projectSearchDir, - rawOptions.project, - cwd - ) - ); +/** @internal */ +export function createFromPreloadedConfig( + foundConfigResult: ReturnType +): Service { + const { + configFilePath, + cwd, + options, + config, + compiler, + projectLocalResolveDir, + optionBasePaths, + } = foundConfigResult; - // Read config file and merge new options between env and CLI options. - const { configFilePath, config, tsNodeOptionsFromTsconfig, optionBasePaths } = - readConfig(cwd, ts, rawOptions); - const options = assign( - {}, - DEFAULTS, - tsNodeOptionsFromTsconfig || {}, - { optionBasePaths }, - rawOptions + const projectLocalResolveHelper = createProjectLocalResolveHelper( + projectLocalResolveDir ); - options.require = [ - ...(tsNodeOptionsFromTsconfig.require || []), - ...(rawOptions.require || []), - ]; - // Re-load the compiler in case it has changed. - // Compiler is loaded relative to tsconfig.json, so tsconfig discovery may cause us to load a - // different compiler than we did above, even if the name has not changed. - if (configFilePath) { - ({ compiler, ts, projectLocalResolveHelper } = loadCompiler( - options.compiler, - getBasePathForProjectLocalDependencyResolution( - configFilePath, - rawOptions.projectSearchDir, - rawOptions.project, - cwd - ) - )); - } + const ts = loadCompiler(compiler); // Experimental REPL await is not compatible targets lower than ES2018 const targetSupportsTla = config.options.target! >= ts.ScriptTarget.ES2018; @@ -692,11 +659,15 @@ export function create(rawOptions: CreateOptions = {}): Service { const transpileOnly = (options.transpileOnly === true || options.swc === true) && options.typeCheck !== true; - const transpiler = options.transpiler - ? options.transpiler - : options.swc - ? require.resolve('./transpilers/swc.js') - : undefined; + let transpiler: RegisterOptions['transpiler'] | undefined = undefined; + let transpilerBasePath: string | undefined = undefined; + if (options.transpiler) { + transpiler = options.transpiler; + transpilerBasePath = optionBasePaths.transpiler; + } else if (options.swc) { + transpiler = require.resolve('./transpilers/swc.js'); + transpilerBasePath = optionBasePaths.swc; + } const transformers = options.transformers || undefined; const diagnosticFilters: Array = [ { @@ -763,7 +734,13 @@ export function create(rawOptions: CreateOptions = {}): Service { typeof transpiler === 'string' ? transpiler : transpiler[0]; const transpilerOptions = typeof transpiler === 'string' ? {} : transpiler[1] ?? {}; - const transpilerPath = projectLocalResolveHelper(transpilerName, true); + const transpilerConfigLocalResolveHelper = transpilerBasePath + ? createProjectLocalResolveHelper(transpilerBasePath) + : projectLocalResolveHelper; + const transpilerPath = transpilerConfigLocalResolveHelper( + transpilerName, + true + ); const transpilerFactory = require(transpilerPath) .create as TranspilerFactory; createTranspiler = function (compilerOptions) { @@ -776,6 +753,7 @@ export function create(rawOptions: CreateOptions = {}): Service { }, projectLocalResolveHelper, }, + transpilerConfigLocalResolveHelper, ...transpilerOptions, }); }; diff --git a/src/test/esm-loader.spec.ts b/src/test/esm-loader.spec.ts index 76cb320d5..cc673e544 100644 --- a/src/test/esm-loader.spec.ts +++ b/src/test/esm-loader.spec.ts @@ -5,18 +5,22 @@ import { context } from './testlib'; import semver = require('semver'); import { + BIN_ESM_PATH, BIN_PATH, + BIN_PATH_JS, CMD_ESM_LOADER_WITHOUT_PROJECT, CMD_TS_NODE_WITHOUT_PROJECT_FLAG, contextTsNodeUnderTest, + delay, EXPERIMENTAL_MODULES_FLAG, nodeSupportsEsmHooks, nodeSupportsImportAssertions, + nodeSupportsSpawningChildProcess, nodeUsesNewHooksApi, resetNodeEnvironment, TEST_DIR, } from './helpers'; -import { createExec } from './exec-helpers'; +import { createExec, createSpawn, ExecReturn } from './exec-helpers'; import { join, resolve } from 'path'; import * as expect from 'expect'; import type { NodeLoaderHooksAPI2 } from '../'; @@ -27,6 +31,9 @@ const test = context(contextTsNodeUnderTest); const exec = createExec({ cwd: TEST_DIR, }); +const spawn = createSpawn({ + cwd: TEST_DIR, +}); test.suite('esm', (test) => { test.suite('when node supports loader hooks', (test) => { @@ -322,6 +329,81 @@ test.suite('esm', (test) => { }); } ); + + test.suite('spawns child process', async (test) => { + test.runIf(nodeSupportsSpawningChildProcess); + + basic('ts-node-esm executable', () => + exec(`${BIN_ESM_PATH} ./esm-child-process/via-flag/index.ts foo bar`) + ); + basic('ts-node --esm flag', () => + exec(`${BIN_PATH} --esm ./esm-child-process/via-flag/index.ts foo bar`) + ); + basic('ts-node w/tsconfig esm:true', () => + exec( + `${BIN_PATH} --esm ./esm-child-process/via-tsconfig/index.ts foo bar` + ) + ); + + function basic(title: string, cb: () => ExecReturn) { + test(title, async (t) => { + const { err, stdout, stderr } = await cb(); + expect(err).toBe(null); + expect(stdout.trim()).toBe('CLI args: foo bar'); + expect(stderr).toBe(''); + }); + } + + test.suite('parent passes signals to child', (test) => { + test.runSerially(); + + signalTest('SIGINT'); + signalTest('SIGTERM'); + + function signalTest(signal: string) { + test(signal, async (t) => { + const childP = spawn([ + // exec lets us run the shims on windows; spawn does not + process.execPath, + BIN_PATH_JS, + `./esm-child-process/via-tsconfig/sleep.ts`, + ]); + let code: number | null | undefined = undefined; + childP.child.on('exit', (_code) => (code = _code)); + await delay(6e3); + const codeAfter6Seconds = code; + process.kill(childP.child.pid, signal); + await delay(2e3); + const codeAfter8Seconds = code; + const { stdoutP, stderrP } = await childP; + const stdout = await stdoutP; + const stderr = await stderrP; + t.log({ + stdout, + stderr, + codeAfter6Seconds, + codeAfter8Seconds, + code, + }); + expect(codeAfter6Seconds).toBeUndefined(); + if (process.platform === 'win32') { + // Windows doesn't have signals, and node attempts an imperfect facsimile. + // In Windows, SIGINT and SIGTERM kill the process immediately with exit + // code 1, and the process can't catch or prevent this. + expect(codeAfter8Seconds).toBe(1); + expect(code).toBe(1); + } else { + expect(codeAfter8Seconds).toBe(undefined); + expect(code).toBe(123); + expect(stdout.trim()).toBe( + `child registered signal handlers\nchild received signal: ${signal}\nchild exiting` + ); + } + expect(stderr).toBe(''); + }); + } + }); + }); }); test.suite('node >= 12.x.x', (test) => { diff --git a/src/test/exec-helpers.ts b/src/test/exec-helpers.ts index fc70f0e3f..bf0766475 100644 --- a/src/test/exec-helpers.ts +++ b/src/test/exec-helpers.ts @@ -1,5 +1,14 @@ -import type { ChildProcess, ExecException, ExecOptions } from 'child_process'; -import { exec as childProcessExec } from 'child_process'; +import type { + ChildProcess, + ExecException, + ExecOptions, + SpawnOptions, +} from 'child_process'; +import { + exec as childProcessExec, + spawn as childProcessSpawn, +} from 'child_process'; +import { getStream } from './helpers'; import { expect } from './testlib'; export type ExecReturn = Promise & { child: ChildProcess }; @@ -44,6 +53,52 @@ export function createExec>( }; } +export type SpawnReturn = Promise & { child: ChildProcess }; +export interface SpawnResult { + stdoutP: Promise; + stderrP: Promise; + code: number | null; + child: ChildProcess; +} + +export function createSpawn>( + preBoundOptions?: T +) { + /** + * Helper to spawn a child process. + * Returns a Promise and a reference to the child process to suite multiple situations. + * + * Should almost always avoid this helper, and instead use `createExec` / `exec`. `spawn` + * may be necessary if you need to avoid `exec`'s intermediate shell. + */ + return function spawn( + cmd: string[], + opts?: Pick> & + Partial> + ) { + let child!: ChildProcess; + return Object.assign( + new Promise((resolve, reject) => { + child = childProcessSpawn(cmd[0], cmd.slice(1), { + ...preBoundOptions, + ...opts, + }); + const stdoutP = getStream(child.stdout!); + const stderrP = getStream(child.stderr!); + child.on('exit', (code) => { + resolve({ stdoutP, stderrP, code, child }); + }); + child.on('error', (error) => { + reject(error); + }); + }), + { + child, + } + ); + }; +} + const defaultExec = createExec(); export interface ExecTesterOptions { diff --git a/src/test/helpers.ts b/src/test/helpers.ts index 5327459be..cddf7575d 100644 --- a/src/test/helpers.ts +++ b/src/test/helpers.ts @@ -17,13 +17,7 @@ import semver = require('semver'); const createRequire: typeof _createRequire = require('create-require'); export { tsNodeTypes }; -export const nodeSupportsEsmHooks = semver.gte(process.version, '12.16.0'); -export const nodeUsesNewHooksApi = semver.gte(process.version, '16.12.0'); -export const nodeSupportsImportAssertions = semver.gte( - process.version, - '17.1.0' -); - +//#region Paths export const ROOT_DIR = resolve(__dirname, '../..'); export const DIST_DIR = resolve(__dirname, '..'); export const TEST_DIR = join(__dirname, '../../tests'); @@ -35,6 +29,10 @@ export const BIN_SCRIPT_PATH = join( 'node_modules/.bin/ts-node-script' ); export const BIN_CWD_PATH = join(TEST_DIR, 'node_modules/.bin/ts-node-cwd'); +export const BIN_ESM_PATH = join(TEST_DIR, 'node_modules/.bin/ts-node-esm'); +//#endregion + +//#region command lines /** Default `ts-node --project` invocation */ export const CMD_TS_NODE_WITH_PROJECT_FLAG = `"${BIN_PATH}" --project "${PROJECT}"`; /** Default `ts-node` invocation without `--project` */ @@ -43,12 +41,33 @@ export const EXPERIMENTAL_MODULES_FLAG = semver.gte(process.version, '12.17.0') ? '' : '--experimental-modules'; export const CMD_ESM_LOADER_WITHOUT_PROJECT = `node ${EXPERIMENTAL_MODULES_FLAG} --loader ts-node/esm`; +//#endregion // `createRequire` does not exist on older node versions export const testsDirRequire = createRequire(join(TEST_DIR, 'index.js')); export const ts = testsDirRequire('typescript'); +//#region version checks +export const nodeSupportsEsmHooks = semver.gte(process.version, '12.16.0'); +export const nodeSupportsSpawningChildProcess = semver.gte( + process.version, + '12.17.0' +); +export const nodeUsesNewHooksApi = semver.gte(process.version, '16.12.0'); +export const nodeSupportsImportAssertions = semver.gte( + process.version, + '17.1.0' +); +/** Supports tsconfig "extends" >= v3.2.0 */ +export const tsSupportsTsconfigInheritanceViaNodePackages = semver.gte( + ts.version, + '3.2.0' +); +/** Supports --showConfig: >= v3.2.0 */ +export const tsSupportsShowConfig = semver.gte(ts.version, '3.2.0'); +//#endregion + export const xfs = new NodeFS(fs); /** Pass to `test.context()` to get access to the ts-node API under test */ @@ -60,6 +79,7 @@ export const contextTsNodeUnderTest = once(async () => { }; }); +//#region install ts-node tarball const ts_node_install_lock = process.env.ts_node_install_lock as string; const lockPath = join(__dirname, ts_node_install_lock); @@ -128,6 +148,7 @@ async function lockedMemoizedOperation( releaseLock(); } } +//#endregion /** * Get a stream into a string. @@ -165,6 +186,8 @@ export function getStream(stream: Readable, waitForPattern?: string | RegExp) { } } +//#region Reset node environment + const defaultRequireExtensions = captureObjectState(require.extensions); const defaultProcess = captureObjectState(process); const defaultModule = captureObjectState(require('module')); @@ -224,3 +247,7 @@ function resetObject( // Reset descriptors Object.defineProperties(object, state.descriptors); } + +//#endregion + +export const delay = promisify(setTimeout); diff --git a/src/test/index.spec.ts b/src/test/index.spec.ts index 5487a7b64..72faf86ea 100644 --- a/src/test/index.spec.ts +++ b/src/test/index.spec.ts @@ -3,7 +3,13 @@ import * as expect from 'expect'; import { join, resolve, sep as pathSep } from 'path'; import { tmpdir } from 'os'; import semver = require('semver'); -import { BIN_PATH_JS, nodeSupportsEsmHooks, ts } from './helpers'; +import { + BIN_PATH_JS, + nodeSupportsEsmHooks, + ts, + tsSupportsShowConfig, + tsSupportsTsconfigInheritanceViaNodePackages, +} from './helpers'; import { lstatSync, mkdtempSync } from 'fs'; import { npath } from '@yarnpkg/fslib'; import type _createRequire from 'create-require'; @@ -167,31 +173,29 @@ test.suite('ts-node', (test) => { expect(stdout).toBe('object\n'); }); - if (semver.gte(ts.version, '1.8.0')) { - test('should allow js', async () => { - const { err, stdout } = await exec( - [ - CMD_TS_NODE_WITH_PROJECT_FLAG, - '-O "{\\"allowJs\\":true}"', - '-pe "import { main } from \'./allow-js/run\';main()"', - ].join(' ') - ); - expect(err).toBe(null); - expect(stdout).toBe('hello world\n'); - }); + test('should allow js', async () => { + const { err, stdout } = await exec( + [ + CMD_TS_NODE_WITH_PROJECT_FLAG, + '-O "{\\"allowJs\\":true}"', + '-pe "import { main } from \'./allow-js/run\';main()"', + ].join(' ') + ); + expect(err).toBe(null); + expect(stdout).toBe('hello world\n'); + }); - test('should include jsx when `allow-js` true', async () => { - const { err, stdout } = await exec( - [ - CMD_TS_NODE_WITH_PROJECT_FLAG, - '-O "{\\"allowJs\\":true}"', - '-pe "import { Foo2 } from \'./allow-js/with-jsx\'; Foo2.sayHi()"', - ].join(' ') - ); - expect(err).toBe(null); - expect(stdout).toBe('hello world\n'); - }); - } + test('should include jsx when `allow-js` true', async () => { + const { err, stdout } = await exec( + [ + CMD_TS_NODE_WITH_PROJECT_FLAG, + '-O "{\\"allowJs\\":true}"', + '-pe "import { Foo2 } from \'./allow-js/with-jsx\'; Foo2.sayHi()"', + ].join(' ') + ); + expect(err).toBe(null); + expect(stdout).toBe('hello world\n'); + }); test('should eval code', async () => { const { err, stdout } = await exec( @@ -501,21 +505,16 @@ test.suite('ts-node', (test) => { }); test.suite('issue #884', (test) => { + // TODO disabled because it consistently fails on Windows on TS 2.7 + test.skipIf( + process.platform === 'win32' && semver.satisfies(ts.version, '2.7') + ); test('should compile', async (t) => { - // TODO disabled because it consistently fails on Windows on TS 2.7 - if ( - process.platform === 'win32' && - semver.satisfies(ts.version, '2.7') - ) { - t.log('Skipping'); - return; - } else { - const { err, stdout } = await exec( - `"${BIN_PATH}" --project issue-884/tsconfig.json issue-884` - ); - expect(err).toBe(null); - expect(stdout).toBe(''); - } + const { err, stdout } = await exec( + `"${BIN_PATH}" --project issue-884/tsconfig.json issue-884` + ); + expect(err).toBe(null); + expect(stdout).toBe(''); }); }); @@ -706,7 +705,7 @@ test.suite('ts-node', (test) => { ]); }); - if (semver.gte(ts.version, '3.2.0')) { + if (tsSupportsTsconfigInheritanceViaNodePackages) { test('should pull ts-node options from extended `tsconfig.json`', async () => { const { err, stdout } = await exec( `${BIN_PATH} --show-config --project ./tsconfig-extends/tsconfig.json` @@ -810,33 +809,33 @@ test.suite('ts-node', (test) => { } ); - if (semver.gte(ts.version, '3.2.0')) { - test.suite( - 'should bundle @tsconfig/bases to be used in your own tsconfigs', - (test) => { - const macro = test.macro((nodeVersion: string) => async (t) => { - const config = require(`@tsconfig/${nodeVersion}/tsconfig.json`); - const { err, stdout, stderr } = await exec( - `${BIN_PATH} --showConfig -e 10n`, - { - cwd: join(TEST_DIR, 'tsconfig-bases', nodeVersion), - } - ); - expect(err).toBe(null); - t.like(JSON.parse(stdout), { - compilerOptions: { - target: config.compilerOptions.target, - lib: config.compilerOptions.lib, - }, - }); + test.suite( + 'should bundle @tsconfig/bases to be used in your own tsconfigs', + (test) => { + test.runIf(tsSupportsTsconfigInheritanceViaNodePackages); + + const macro = test.macro((nodeVersion: string) => async (t) => { + const config = require(`@tsconfig/${nodeVersion}/tsconfig.json`); + const { err, stdout, stderr } = await exec( + `${BIN_PATH} --showConfig -e 10n`, + { + cwd: join(TEST_DIR, 'tsconfig-bases', nodeVersion), + } + ); + expect(err).toBe(null); + t.like(JSON.parse(stdout), { + compilerOptions: { + target: config.compilerOptions.target, + lib: config.compilerOptions.lib, + }, }); - test(`ts-node/node10/tsconfig.json`, macro, 'node10'); - test(`ts-node/node12/tsconfig.json`, macro, 'node12'); - test(`ts-node/node14/tsconfig.json`, macro, 'node14'); - test(`ts-node/node16/tsconfig.json`, macro, 'node16'); - } - ); - } + }); + test(`ts-node/node10/tsconfig.json`, macro, 'node10'); + test(`ts-node/node12/tsconfig.json`, macro, 'node12'); + test(`ts-node/node14/tsconfig.json`, macro, 'node14'); + test(`ts-node/node16/tsconfig.json`, macro, 'node16'); + } + ); test.suite('compiler host', (test) => { test('should execute cli', async () => { @@ -896,7 +895,7 @@ test.suite('ts-node', (test) => { }); }); - if (semver.gte(ts.version, '3.2.0')) { + if (tsSupportsShowConfig) { test('--showConfig should log resolved configuration', async (t) => { function native(path: string) { return path.replace(/\/|\\/g, pathSep); diff --git a/src/test/pluggable-dep-resolution.spec.ts b/src/test/pluggable-dep-resolution.spec.ts new file mode 100644 index 000000000..95504351b --- /dev/null +++ b/src/test/pluggable-dep-resolution.spec.ts @@ -0,0 +1,98 @@ +import { context } from './testlib'; +import { + contextTsNodeUnderTest, + resetNodeEnvironment, + tsSupportsTsconfigInheritanceViaNodePackages, +} from './helpers'; +import * as expect from 'expect'; +import { resolve } from 'path'; + +const test = context(contextTsNodeUnderTest); + +test.suite( + 'Pluggable dependency (compiler, transpiler, swc backend) is require()d relative to the tsconfig file that declares it', + (test) => { + test.runSerially(); + + // The use-case we want to support: + // + // User shares their tsconfig across multiple projects as an npm module named "shared-config", similar to @tsconfig/bases + // In their npm module + // They have tsconfig.json with `swc: true` or `compiler: "ts-patch"` or something like that + // The module declares a dependency on a known working version of @swc/core, or ts-patch, or something like that. + // They use this reusable config via `npm install shared-config` and `"extends": "shared-config/tsconfig.json"` + // + // ts-node should resolve ts-patch or @swc/core relative to the extended tsconfig + // to ensure we use the known working versions. + + const macro = _macro.bind(null, test); + + macro('tsconfig-custom-compiler.json', 'root custom compiler'); + macro('tsconfig-custom-transpiler.json', 'root custom transpiler'); + macro('tsconfig-swc-custom-backend.json', 'root custom swc backend'); + macro('tsconfig-swc-core.json', 'root @swc/core'); + macro('tsconfig-swc-wasm.json', 'root @swc/wasm'); + macro('tsconfig-swc.json', 'root @swc/core'); + + macro( + 'node_modules/shared-config/tsconfig-custom-compiler.json', + 'shared-config custom compiler' + ); + macro( + 'node_modules/shared-config/tsconfig-custom-transpiler.json', + 'shared-config custom transpiler' + ); + macro( + 'node_modules/shared-config/tsconfig-swc-custom-backend.json', + 'shared-config custom swc backend' + ); + macro( + 'node_modules/shared-config/tsconfig-swc-core.json', + 'shared-config @swc/core' + ); + macro( + 'node_modules/shared-config/tsconfig-swc-wasm.json', + 'shared-config @swc/wasm' + ); + macro( + 'node_modules/shared-config/tsconfig-swc.json', + 'shared-config @swc/core' + ); + + test.suite('"extends"', (test) => { + test.runIf(tsSupportsTsconfigInheritanceViaNodePackages); + + const macro = _macro.bind(null, test); + + macro( + 'tsconfig-extend-custom-compiler.json', + 'shared-config custom compiler' + ); + macro( + 'tsconfig-extend-custom-transpiler.json', + 'shared-config custom transpiler' + ); + macro( + 'tsconfig-extend-swc-custom-backend.json', + 'shared-config custom swc backend' + ); + macro('tsconfig-extend-swc-core.json', 'shared-config @swc/core'); + macro('tsconfig-extend-swc-wasm.json', 'shared-config @swc/wasm'); + macro('tsconfig-extend-swc.json', 'shared-config @swc/core'); + }); + + function _macro(_test: typeof test, config: string, expected: string) { + _test(`${config} uses ${expected}`, async (t) => { + t.teardown(resetNodeEnvironment); + + const output = t.context.tsNodeUnderTest + .create({ + project: resolve('tests/pluggable-dep-resolution', config), + }) + .compile('', 'index.ts'); + + expect(output).toContain(`emit from ${expected}\n`); + }); + } + } +); diff --git a/src/test/testlib.ts b/src/test/testlib.ts index 377d93ef3..6304164bb 100644 --- a/src/test/testlib.ts +++ b/src/test/testlib.ts @@ -19,6 +19,19 @@ export { ExecutionContext, expect }; // each .spec file in its own process, so actual concurrency is higher. const concurrencyLimiter = throat(16); +function errorPostprocessor(fn: T): T { + return async function (this: any) { + try { + return await fn.call(this, arguments); + } catch (error: any) { + delete error?.matcherResult; + // delete error?.matcherResult?.message; + if (error?.message) error.message = `\n${error.message}\n`; + throw error; + } + } as any; +} + function once(func: T): T { let run = false; let ret: any = undefined; @@ -35,7 +48,8 @@ export const test = createTestInterface({ mustDoSerial: false, automaticallyDoSerial: false, automaticallySkip: false, - separator: ' > ', + // The little right chevron used by ava + separator: ' \u203a ', titlePrefix: undefined, }); // In case someone wants to `const test = _test.context()` @@ -101,6 +115,8 @@ export interface TestInterface< skipUnless(conditional: boolean): void; /** If conditional is true, run tests, otherwise skip them */ runIf(conditional: boolean): void; + /** If conditional is false, skip tests */ + skipIf(conditional: boolean): void; // TODO add teardownEach } @@ -167,14 +183,16 @@ function createTestInterface(opts: { ) { const wrappedMacros = macros.map((macro) => { return async function (t: ExecutionContext, ...args: any[]) { - return concurrencyLimiter(async () => { - let i = 0; - for (const func of beforeEachFunctions) { - await func(t); - i++; - } - return macro(t, ...args); - }); + return concurrencyLimiter( + errorPostprocessor(async () => { + let i = 0; + for (const func of beforeEachFunctions) { + await func(t); + i++; + } + return macro(t, ...args); + }) + ); }; }); const computedTitle = computeTitle(title); @@ -270,5 +288,8 @@ function createTestInterface(opts: { assertOrderingForDeclaringSkipUnless(); automaticallySkip = automaticallySkip || !runIfTrue; }; + test.skipIf = function (skipIfTrue: boolean) { + test.runIf(!skipIfTrue); + }; return test as any; } diff --git a/src/transpilers/swc.ts b/src/transpilers/swc.ts index 23949595d..1d8d1c441 100644 --- a/src/transpilers/swc.ts +++ b/src/transpilers/swc.ts @@ -16,22 +16,23 @@ export function create(createOptions: SwcTranspilerOptions): Transpiler { const { swc, service: { config, projectLocalResolveHelper }, + transpilerConfigLocalResolveHelper, } = createOptions; // Load swc compiler let swcInstance: typeof swcWasm; if (typeof swc === 'string') { - swcInstance = require(projectLocalResolveHelper( + swcInstance = require(transpilerConfigLocalResolveHelper( swc, true )) as typeof swcWasm; } else if (swc == null) { let swcResolved; try { - swcResolved = projectLocalResolveHelper('@swc/core', true); + swcResolved = transpilerConfigLocalResolveHelper('@swc/core', true); } catch (e) { try { - swcResolved = projectLocalResolveHelper('@swc/wasm', true); + swcResolved = transpilerConfigLocalResolveHelper('@swc/wasm', true); } catch (e) { throw new Error( 'swc compiler requires either @swc/core or @swc/wasm to be installed as a dependency. See https://typestrong.org/ts-node/docs/transpilers' diff --git a/src/transpilers/types.ts b/src/transpilers/types.ts index ab524cbdc..f5eeff5bd 100644 --- a/src/transpilers/types.ts +++ b/src/transpilers/types.ts @@ -1,5 +1,6 @@ import type * as ts from 'typescript'; import type { Service } from '../index'; +import type { ProjectLocalResolveHelper } from '../util'; /** * Third-party transpilers are implemented as a CommonJS module with a @@ -21,6 +22,11 @@ export interface CreateTranspilerOptions { Service, Extract<'config' | 'options' | 'projectLocalResolveHelper', keyof Service> >; + /** + * If `"transpiler"` option is declared in an "extends" tsconfig, this path might be different than + * the `projectLocalResolveHelper` + */ + transpilerConfigLocalResolveHelper: ProjectLocalResolveHelper; } export interface Transpiler { // TODOs diff --git a/tests/esm-child-process/via-flag/index.ts b/tests/esm-child-process/via-flag/index.ts new file mode 100644 index 000000000..939272be8 --- /dev/null +++ b/tests/esm-child-process/via-flag/index.ts @@ -0,0 +1,3 @@ +import { strictEqual } from 'assert'; +strictEqual(import.meta.url.includes('index.ts'), true); +console.log(`CLI args: ${process.argv.slice(2).join(' ')}`); diff --git a/tests/esm-child-process/via-flag/package.json b/tests/esm-child-process/via-flag/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/esm-child-process/via-flag/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm-child-process/via-flag/tsconfig.json b/tests/esm-child-process/via-flag/tsconfig.json new file mode 100644 index 000000000..25a7642af --- /dev/null +++ b/tests/esm-child-process/via-flag/tsconfig.json @@ -0,0 +1,9 @@ +{ + "compilerOptions": { + "module": "ESNext", + "esModuleInterop": true + }, + "ts-node": { + "swc": true + } +} diff --git a/tests/esm-child-process/via-tsconfig/index.ts b/tests/esm-child-process/via-tsconfig/index.ts new file mode 100644 index 000000000..939272be8 --- /dev/null +++ b/tests/esm-child-process/via-tsconfig/index.ts @@ -0,0 +1,3 @@ +import { strictEqual } from 'assert'; +strictEqual(import.meta.url.includes('index.ts'), true); +console.log(`CLI args: ${process.argv.slice(2).join(' ')}`); diff --git a/tests/esm-child-process/via-tsconfig/package.json b/tests/esm-child-process/via-tsconfig/package.json new file mode 100644 index 000000000..3dbc1ca59 --- /dev/null +++ b/tests/esm-child-process/via-tsconfig/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/tests/esm-child-process/via-tsconfig/sleep.ts b/tests/esm-child-process/via-tsconfig/sleep.ts new file mode 100644 index 000000000..f45b9dadc --- /dev/null +++ b/tests/esm-child-process/via-tsconfig/sleep.ts @@ -0,0 +1,13 @@ +setTimeout(function () { + console.log('Slept 30 seconds'); +}, 30e3); +process.on('SIGTERM', onSignal); +process.on('SIGINT', onSignal); +console.log('child registered signal handlers'); +function onSignal(signal: string) { + console.log(`child received signal: ${signal}`); + setTimeout(() => { + console.log(`child exiting`); + process.exit(123); + }, 5e3); +} diff --git a/tests/esm-child-process/via-tsconfig/tsconfig.json b/tests/esm-child-process/via-tsconfig/tsconfig.json new file mode 100644 index 000000000..31f702b87 --- /dev/null +++ b/tests/esm-child-process/via-tsconfig/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "module": "ESNext", + "esModuleInterop": true + }, + "ts-node": { + "esm": true, + "swc": true + } +} diff --git a/tests/pluggable-dep-resolution/node_modules/@swc/core/index.js b/tests/pluggable-dep-resolution/node_modules/@swc/core/index.js new file mode 100644 index 000000000..b9924d5f4 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/@swc/core/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from root @swc/core', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/@swc/wasm/index.js b/tests/pluggable-dep-resolution/node_modules/@swc/wasm/index.js new file mode 100644 index 000000000..f149018fb --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/@swc/wasm/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from root @swc/wasm', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js b/tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js new file mode 100644 index 000000000..806376ab1 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/custom-compiler/index.js @@ -0,0 +1,9 @@ +module.exports = { + ...require('typescript'), + transpileModule() { + return { + outputText: 'emit from root custom compiler', + sourceMapText: '{}', + }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/custom-swc/index.js b/tests/pluggable-dep-resolution/node_modules/custom-swc/index.js new file mode 100644 index 000000000..e23907430 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/custom-swc/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from root custom swc backend', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/custom-transpiler/index.js b/tests/pluggable-dep-resolution/node_modules/custom-transpiler/index.js new file mode 100644 index 000000000..ed3d1cb28 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/custom-transpiler/index.js @@ -0,0 +1,10 @@ +module.exports.create = function () { + return { + transpile() { + return { + outputText: 'emit from root custom transpiler', + sourceMapText: '{}', + }; + }, + }; + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/core/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/core/index.js new file mode 100644 index 000000000..ee65ccdd9 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/core/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from shared-config @swc/core', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/wasm/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/wasm/index.js new file mode 100644 index 000000000..7b4a479ea --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/@swc/wasm/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from shared-config @swc/wasm', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js new file mode 100644 index 000000000..b1a45e628 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-compiler/index.js @@ -0,0 +1,9 @@ +module.exports = { + ...require('typescript'), + transpileModule() { + return { + outputText: 'emit from shared-config custom compiler', + sourceMapText: '{}', + }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-swc/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-swc/index.js new file mode 100644 index 000000000..9d69e702a --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-swc/index.js @@ -0,0 +1,5 @@ +module.exports = { + transformSync() { + return { code: 'emit from shared-config custom swc backend', map: '{}' }; + }, + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-transpiler/index.js b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-transpiler/index.js new file mode 100644 index 000000000..d8ca0d3f6 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/node_modules/custom-transpiler/index.js @@ -0,0 +1,10 @@ +module.exports.create = function () { + return { + transpile() { + return { + outputText: 'emit from shared-config custom transpiler', + sourceMapText: '{}', + }; + }, + }; + }; diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-compiler.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-compiler.json new file mode 100644 index 000000000..926d54985 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-compiler.json @@ -0,0 +1 @@ +{"ts-node":{"transpileOnly":true,"compiler":"custom-compiler"}} diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-transpiler.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-transpiler.json new file mode 100644 index 000000000..bb64bd1f2 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-custom-transpiler.json @@ -0,0 +1 @@ +{"ts-node":{"transpileOnly":true,"transpiler":"custom-transpiler"}} diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-core.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-core.json new file mode 100644 index 000000000..c4191aec0 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-core.json @@ -0,0 +1 @@ +{"ts-node":{"transpileOnly":true,"transpiler":["ts-node/transpilers/swc",{"swc":"@swc/core"}]}} diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-custom-backend.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-custom-backend.json new file mode 100644 index 000000000..c23cd162e --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-custom-backend.json @@ -0,0 +1 @@ +{"ts-node":{"transpileOnly":true,"transpiler":["ts-node/transpilers/swc",{"swc":"custom-swc"}]}} diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-wasm.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-wasm.json new file mode 100644 index 000000000..94d91973a --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc-wasm.json @@ -0,0 +1 @@ +{"ts-node":{"transpileOnly":true,"transpiler":["ts-node/transpilers/swc",{"swc":"@swc/wasm"}]}} diff --git a/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc.json b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc.json new file mode 100644 index 000000000..430482e84 --- /dev/null +++ b/tests/pluggable-dep-resolution/node_modules/shared-config/tsconfig-swc.json @@ -0,0 +1 @@ +{"ts-node":{"swc":true}} diff --git a/tests/pluggable-dep-resolution/tsconfig-custom-compiler.json b/tests/pluggable-dep-resolution/tsconfig-custom-compiler.json new file mode 100644 index 000000000..12f1bfe6d --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-custom-compiler.json @@ -0,0 +1 @@ +{ "ts-node": { "transpileOnly": true, "compiler": "custom-compiler" } } diff --git a/tests/pluggable-dep-resolution/tsconfig-custom-transpiler.json b/tests/pluggable-dep-resolution/tsconfig-custom-transpiler.json new file mode 100644 index 000000000..c2339a1ea --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-custom-transpiler.json @@ -0,0 +1 @@ +{ "ts-node": { "transpileOnly": true, "transpiler": "custom-transpiler" } } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-custom-compiler.json b/tests/pluggable-dep-resolution/tsconfig-extend-custom-compiler.json new file mode 100644 index 000000000..674b908e2 --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-custom-compiler.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-custom-compiler.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-custom-transpiler.json b/tests/pluggable-dep-resolution/tsconfig-extend-custom-transpiler.json new file mode 100644 index 000000000..afe9b5d7e --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-custom-transpiler.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-custom-transpiler.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-swc-core.json b/tests/pluggable-dep-resolution/tsconfig-extend-swc-core.json new file mode 100644 index 000000000..4ad6e1a89 --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-swc-core.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-swc-core.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-swc-custom-backend.json b/tests/pluggable-dep-resolution/tsconfig-extend-swc-custom-backend.json new file mode 100644 index 000000000..c28b49a1a --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-swc-custom-backend.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-swc-custom-backend.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-swc-wasm.json b/tests/pluggable-dep-resolution/tsconfig-extend-swc-wasm.json new file mode 100644 index 000000000..8acee2395 --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-swc-wasm.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-swc-wasm.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-extend-swc.json b/tests/pluggable-dep-resolution/tsconfig-extend-swc.json new file mode 100644 index 000000000..29827a78a --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-extend-swc.json @@ -0,0 +1 @@ +{ "extends": "shared-config/tsconfig-swc.json" } diff --git a/tests/pluggable-dep-resolution/tsconfig-swc-core.json b/tests/pluggable-dep-resolution/tsconfig-swc-core.json new file mode 100644 index 000000000..8e33432ef --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-swc-core.json @@ -0,0 +1,6 @@ +{ + "ts-node": { + "transpileOnly": true, + "transpiler": ["ts-node/transpilers/swc", { "swc": "@swc/core" }] + } +} diff --git a/tests/pluggable-dep-resolution/tsconfig-swc-custom-backend.json b/tests/pluggable-dep-resolution/tsconfig-swc-custom-backend.json new file mode 100644 index 000000000..7a3d24429 --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-swc-custom-backend.json @@ -0,0 +1,6 @@ +{ + "ts-node": { + "transpileOnly": true, + "transpiler": ["ts-node/transpilers/swc", { "swc": "custom-swc" }] + } +} diff --git a/tests/pluggable-dep-resolution/tsconfig-swc-wasm.json b/tests/pluggable-dep-resolution/tsconfig-swc-wasm.json new file mode 100644 index 000000000..bfa5a0ebe --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-swc-wasm.json @@ -0,0 +1,6 @@ +{ + "ts-node": { + "transpileOnly": true, + "transpiler": ["ts-node/transpilers/swc", { "swc": "@swc/wasm" }] + } +} diff --git a/tests/pluggable-dep-resolution/tsconfig-swc.json b/tests/pluggable-dep-resolution/tsconfig-swc.json new file mode 100644 index 000000000..9f1295318 --- /dev/null +++ b/tests/pluggable-dep-resolution/tsconfig-swc.json @@ -0,0 +1 @@ +{ "ts-node": { "swc": true } } diff --git a/website/docs/imports.md b/website/docs/imports.md index 6b04f776f..4a0ea5c7b 100644 --- a/website/docs/imports.md +++ b/website/docs/imports.md @@ -11,7 +11,7 @@ Here is a brief comparison of the two. | Write native `import` syntax | Write native `import` syntax | | Transforms `import` into `require()` | Does not transform `import` | | Node executes scripts using the classic [CommonJS loader](https://nodejs.org/dist/latest-v16.x/docs/api/modules.html) | Node executes scripts using the new [ESM loader](https://nodejs.org/dist/latest-v16.x/docs/api/esm.html) | -| Use any of:
ts-node CLI
`node -r ts-node/register`
`NODE_OPTIONS="ts-node/register" node`
`require('ts-node').register({/* options */})` | Must use the ESM loader via:
`node --loader ts-node/esm`
`NODE_OPTIONS="--loader ts-node/esm" node` | +| Use any of:
`ts-node`
`node -r ts-node/register`
`NODE_OPTIONS="ts-node/register" node`
`require('ts-node').register({/* options */})` | Use any of:
`ts-node --esm`
`ts-node-esm`
Set `"esm": true` in `tsconfig.json`
`node --loader ts-node/esm`
`NODE_OPTIONS="--loader ts-node/esm" node` | ## CommonJS @@ -65,6 +65,32 @@ You must set [`"type": "module"`](https://nodejs.org/api/packages.html#packages_ { "compilerOptions": { "module": "ESNext" // or ES2015, ES2020 + }, + "ts-node": { + // Tell ts-node CLI to install the --loader automatically, explained below + "esm": true } } ``` + +You must also ensure node is passed `--loader`. The ts-node CLI will do this automatically with our `esm` option. + +> Note: `--esm` must spawn a child process to pass it `--loader`. This may change if node adds the ability to install loader hooks +into the current process. + +```shell +# pass the flag +ts-node --esm +# Use the convenience binary +ts-node-esm +# or add `"esm": true` to your tsconfig.json to make it automatic +ts-node +``` + +If you are not using our CLI, pass the loader flag to node. + +```shell +node --loader ts-node/esm ./index.ts +# Or via environment variable +NODE_OPTIONS="--loader ts-node/esm" node ./index.ts +``` diff --git a/website/docs/options.md b/website/docs/options.md index aadf36da9..96e49c1eb 100644 --- a/website/docs/options.md +++ b/website/docs/options.md @@ -15,6 +15,7 @@ _Environment variables, where available, are in `ALL_CAPS`_ - `-e, --eval` Evaluate code - `-p, --print` Print result of `--eval` - `-i, --interactive` Opens the REPL even if stdin does not appear to be a terminal +- `--esm` Bootstrap with the ESM loader, enabling full ESM support ## TSConfig diff --git a/website/docs/usage.md b/website/docs/usage.md index d988e606b..1e6e563e8 100644 --- a/website/docs/usage.md +++ b/website/docs/usage.md @@ -25,6 +25,9 @@ ts-node-transpile-only script.ts # Equivalent to ts-node --cwdMode ts-node-cwd script.ts + +# Equivalent to ts-node --esm +ts-node-esm script.ts ``` ## Shebang From 216339845e9b1d19a45cf0823e3913a4ae4be5e5 Mon Sep 17 00:00:00 2001 From: Andrew Bradley Date: Sat, 5 Mar 2022 22:10:38 -0500 Subject: [PATCH 14/17] Merge docs into main for 10.7.0 * chore(website): upgrade Docusaurus, format files * remove external link icon * upgrade Docusaurus to beta.16 * fix lock * add shiki-twoslash, pick code highlighting themes, update build-readme.mjs to strip twoslash prefixes, fix a couple typos and docs mistakes found due to the above (yay), pin yarn version * linting * beta.17 * point breadcrumb home to /docs * fix prettier config * remove twoslash tsconfig renderer due to rendering bug on dark theme, and it also adds erroneous links to ts-node sub-config Co-authored-by: Andrew Bradley Co-authored-by: Joshua Chen --- .prettierignore | 5 + website/docs/module-type-overrides.md | 2 +- website/docs/recipes/ava.md | 4 +- website/docs/troubleshooting.md | 5 +- website/docs/types.md | 12 +- website/docs/usage.md | 6 +- website/docusaurus.config.js | 99 +- website/package.json | 17 +- website/scripts/build-readme.mjs | 108 +- website/sidebars.js | 102 +- website/src/css/custom.css | 21 + website/src/pages/index.js | 40 +- website/src/theme/DocBreadcrumbs/index.tsx | 86 + .../theme/DocBreadcrumbs/styles.module.css | 26 + website/types/untyped_js_lib.d.ts | 7 + website/yarn.lock | 9097 +++++++---------- 16 files changed, 3971 insertions(+), 5666 deletions(-) create mode 100644 website/src/theme/DocBreadcrumbs/index.tsx create mode 100644 website/src/theme/DocBreadcrumbs/styles.module.css create mode 100644 website/types/untyped_js_lib.d.ts diff --git a/.prettierignore b/.prettierignore index 492a816ce..b830eb822 100644 --- a/.prettierignore +++ b/.prettierignore @@ -6,6 +6,11 @@ !/scripts !/src !/tests +!/website +/website/.docusaurus +/website/docs +/website/readme-sources +/website/static tests/main-realpath/symlink/tsconfig.json tests/throw error.ts tests/throw error react tsx.tsx diff --git a/website/docs/module-type-overrides.md b/website/docs/module-type-overrides.md index 43ab19f55..05222ce2f 100644 --- a/website/docs/module-type-overrides.md +++ b/website/docs/module-type-overrides.md @@ -17,7 +17,7 @@ CommonJS or ESM. Node supports similar overriding via `.cjs` and `.mjs` file ex The following example tells ts-node to execute a webpack config as CommonJS: -```json title=tsconfig.json +```json title="tsconfig.json" { "ts-node": { "transpileOnly": true, diff --git a/website/docs/recipes/ava.md b/website/docs/recipes/ava.md index 945008ec0..83eda7446 100644 --- a/website/docs/recipes/ava.md +++ b/website/docs/recipes/ava.md @@ -8,7 +8,7 @@ Assuming you are configuring AVA via your `package.json`, add one of the followi Use this configuration if your `package.json` does not have `"type": "module"`. -```json title"package.json" +```json title="package.json" { "ava": { "extensions": [ @@ -25,7 +25,7 @@ Use this configuration if your `package.json` does not have `"type": "module"`. This configuration is necessary if your `package.json` has `"type": "module"`. -```json title"package.json" +```json title="package.json" { "ava": { "extensions": { diff --git a/website/docs/troubleshooting.md b/website/docs/troubleshooting.md index 2e2125c16..d007bcac4 100644 --- a/website/docs/troubleshooting.md +++ b/website/docs/troubleshooting.md @@ -71,7 +71,10 @@ the [tsconfig `"target"` option](https://www.typescriptlang.org/tsconfig#target) For example, `node` 12 does not understand the `?.` optional chaining operator. If you use `"target": "esnext"`, then the following TypeScript syntax: -```typescript +```typescript twoslash +export {}; +var foo: {bar: string} | undefined; +// ---cut--- const bar: string | undefined = foo?.bar; ``` diff --git a/website/docs/types.md b/website/docs/types.md index eaf425a93..cb1df0bea 100644 --- a/website/docs/types.md +++ b/website/docs/types.md @@ -28,7 +28,7 @@ Example project structure: Example module declaration file: -```typescript +```typescript twoslash declare module '' { // module definitions go here } @@ -36,7 +36,7 @@ declare module '' { For module definitions, you can use [`paths`](https://www.typescriptlang.org/docs/handbook/module-resolution.html#path-mapping): -```json +```json title="tsconfig.json" { "compilerOptions": { "baseUrl": ".", @@ -49,9 +49,11 @@ For module definitions, you can use [`paths`](https://www.typescriptlang.org/doc An alternative approach for definitions of third-party libraries are [triple-slash directives](https://www.typescriptlang.org/docs/handbook/triple-slash-directives.html). This may be helpful if you prefer not to change your TypeScript `compilerOptions` or structure your custom type definitions when using `typeRoots`. Below is an example of the triple-slash directive as a relative path within your project: -```typescript -/// -import UntypedJsLib from "untyped_js_lib" +```typescript twoslash +/// +import {Greeter} from "untyped_js_lib" +const g = new Greeter(); +g.sayHello(); ``` **Tip:** If you _must_ use `files`, `include`, or `exclude`, enable `--files` flags or set `TS_NODE_FILES=true`. diff --git a/website/docs/usage.md b/website/docs/usage.md index 1e6e563e8..9b62841f8 100644 --- a/website/docs/usage.md +++ b/website/docs/usage.md @@ -32,7 +32,7 @@ ts-node-esm script.ts ## Shebang -```typescript +```typescript twoslash #!/usr/bin/env ts-node console.log("Hello, world!") @@ -40,7 +40,7 @@ console.log("Hello, world!") Passing options via shebang requires the [`env -S` flag](https://manpages.debian.org/bullseye/coreutils/env.1.en.html#S), which is available on recent versions of `env`. ([compatibility](https://github.com/TypeStrong/ts-node/pull/1448#issuecomment-913895766)) -```typescript +```typescript twoslash #!/usr/bin/env -S ts-node --files // This shebang works on Mac and Linux with newer versions of env // Technically, Mac allows omitting `-S`, but Linux requires it @@ -48,7 +48,7 @@ Passing options via shebang requires the [`env -S` flag](https://manpages.debian To write scripts with maximum portability, [specify all options in your `tsconfig.json`](./configuration#via-tsconfigjson-recommended) and omit them from the shebang. -```typescript +```typescript twoslash #!/usr/bin/env ts-node // This shebang works everywhere ``` diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 109116d2c..8c33ffcbc 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -19,7 +19,7 @@ module.exports = { // //isCloseable: false, // Defaults to `true`. // }, colorMode: { - respectPrefersColorScheme: true + respectPrefersColorScheme: true, }, navbar: { title: 'ts-node', @@ -61,6 +61,20 @@ module.exports = { }, ], }, + metadata: [ + { + name: 'msapplication-TileColor', + content: '#2b5797', + }, + { + name: 'msapplication-config', + content: '/ts-node/img/favicon/browserconfig.xml', + }, + { + name: 'theme-color', + content: '#ffffff', + }, + ], // footer: { // style: 'dark', // links: [ @@ -99,23 +113,14 @@ module.exports = { // // copyright: `Copyright © ${new Date().getFullYear()} My Project, Inc. Built with Docusaurus.`, // }, prism: { - // for syntax highlighting - // additionalLanguages: ['powershell'], + // Note: these themes are ignored due to using shiki-twoslash + theme: require('prism-react-renderer/themes/vsLight'), + darkTheme: require('prism-react-renderer/themes/vsDark'), }, algolia: { - apiKey: 'c882a0a136ef4e15aa99db604280caa6', + appId: 'BYGNLKSCOV', + apiKey: '74ac2b781b0cf603c2f1b5e4f44e1c69', indexName: 'ts-node', - - // Optional: see doc section below - // contextualSearch: true, - - // Optional: see doc section below - // appId: 'YOUR_APP_ID', - - // Optional: Algolia search parameters - // searchParameters: {}, - - //... other Algolia params }, }, presets: [ @@ -124,19 +129,67 @@ module.exports = { { docs: { sidebarPath: require.resolve('./sidebars.js'), - editUrl: - 'https://github.com/TypeStrong/ts-node/edit/docs/website/', + editUrl: 'https://github.com/TypeStrong/ts-node/edit/docs/website/', }, - // blog: { - // showReadingTime: true, - // // Please change this to your repo. - // editUrl: - // 'https://github.com/facebook/docusaurus/edit/master/website/blog/', - // }, + blog: false, theme: { customCss: require.resolve('./src/css/custom.css'), }, }, ], + [ + 'docusaurus-preset-shiki-twoslash', + { + // https://github.com/shikijs/twoslash/blob/main/packages/shiki-twoslash/README.md#user-settings + + // langs: ["shell", "typescript", "javascript", "ts", "js", "tsx", "jsx", "json", "jsonc"], + includeJSDocInHover: true, + + themes: ['github-light', 'nord'], + + // VSCode default + // themes: ["light-plus", "dark-plus"], + + // Other options + // themes: ["min-light", "nord"], + // themes: ["min-light", "min-dark"], + // themes: ["github-light", "github-dark"], + // themes: ["solarized-light", "solarized-dark"], + }, + ], + ], + // Misleading API that probably will be refactored in Docusaurus, but this is + // simply a list of tags + stylesheets: [ + { + rel: 'apple-touch-icon', + sizes: '180x180', + href: '/ts-node/img/favicon/apple-touch-icon.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '32x32', + href: '/ts-node/img/favicon/favicon-32x32.png', + }, + { + rel: 'icon', + type: 'image/png', + sizes: '16x16', + href: '/ts-node/img/favicon/favicon-16x16.png', + }, + { + rel: 'manifest', + href: '/ts-node/img/favicon/site.webmanifest', + }, + { + rel: 'mask-icon', + href: '/ts-node/img/favicon/safari-pinned-tab.svg', + color: '#5bbad5', + }, + { + rel: 'shortcut icon', + href: '/ts-node/img/favicon/favicon.ico', + }, ], }; diff --git a/website/package.json b/website/package.json index 6c3851cd3..c3545ea89 100644 --- a/website/package.json +++ b/website/package.json @@ -13,15 +13,15 @@ "build-readme": "./scripts/build-readme.mjs" }, "dependencies": { - "@docusaurus/core": "2.0.0-alpha.75", - "@docusaurus/preset-classic": "2.0.0-alpha.75", - "@docusaurus/theme-search-algolia": "^2.0.0-alpha.75", - "@mdx-js/react": "^1.6.21", + "@docusaurus/core": "2.0.0-beta.17", + "@docusaurus/preset-classic": "2.0.0-beta.17", + "@mdx-js/react": "^1.6.22", "@types/js-yaml": "^4.0.0", "clsx": "^1.1.1", + "docusaurus-preset-shiki-twoslash": "^1.1.36", "js-yaml": "^4.0.0", - "react": "^16.8.4", - "react-dom": "^16.8.4", + "react": "^17.0.2", + "react-dom": "^17.0.2", "remark": "^13.0.0", "remark-behead": "^2.3.3", "remark-frontmatter": "^3.0.0", @@ -42,5 +42,10 @@ "last 1 firefox version", "last 1 safari version" ] + }, + "packageManager": "yarn@1.22.17", + "volta": { + "extends": "../package.json", + "yarn": "1.22.17" } } diff --git a/website/scripts/build-readme.mjs b/website/scripts/build-readme.mjs index b7cb22c48..50842776a 100755 --- a/website/scripts/build-readme.mjs +++ b/website/scripts/build-readme.mjs @@ -25,7 +25,7 @@ const readmePath = Path.resolve(__root, 'README.md'); const generateReadmeHeadersForCategories = { General: false, Advanced: true, - Recipes: true + Recipes: true, }; import sidebars from '../sidebars.js'; @@ -35,42 +35,50 @@ async function main() { await appendMarkdownFileToReadmeAst({ path: 'readme-sources/prefix.md', - headerLevel: 1 + headerLevel: 1, }); const sidebar = sidebars.primarySidebar; - for(const category of sidebar) { - const generateReadmeHeader = generateReadmeHeadersForCategories[category.label]; - if(generateReadmeHeader) { + for (const category of sidebar) { + const generateReadmeHeader = + generateReadmeHeadersForCategories[category.label]; + if (generateReadmeHeader) { readmeNodes.push(headerNode(1, category.label)); - } else if(generateReadmeHeader == null) { - throw new Error(`Update ${ import.meta.url } to include all sidebar categories`); + } else if (generateReadmeHeader == null) { + throw new Error( + `Update ${import.meta.url} to include all sidebar categories` + ); } - for(const page of category.items) { + for (const page of category.items) { await appendMarkdownFileToReadmeAst({ - path: `docs/${ page }.md`, - headerLevel: 1 + !!generateReadmeHeader + path: `docs/${page}.md`, + headerLevel: 1 + !!generateReadmeHeader, }); } } appendMarkdownFileToReadmeAst({ path: 'readme-sources/license.md', - headerLevel: 1 + headerLevel: 1, }); - async function appendMarkdownFileToReadmeAst({path, headerLevel}) { + async function appendMarkdownFileToReadmeAst({ path, headerLevel }) { const absPath = Path.resolve(__websiteRoot, path); - console.log(`Appending ${ path } at header level ${ headerLevel }`); + console.log(`Appending ${path} at header level ${headerLevel}`); const markdownSource = fs.readFileSync(absPath, 'utf8'); await remark() .use(remarkFrontmatter, ['yaml']) .use(parseFrontmatter) .use(remarkBehead, { after: '', depth: headerLevel - 1 }) .use(() => (ast) => { - const {frontmatter} = ast; - if(frontmatter && !frontmatter.omitHeaderOnMerge) { - readmeNodes.push(headerNode(headerLevel, frontmatter && frontmatter.title || Path.basename(absPath))); + const { frontmatter } = ast; + if (frontmatter && !frontmatter.omitHeaderOnMerge) { + readmeNodes.push( + headerNode( + headerLevel, + (frontmatter && frontmatter.title) || Path.basename(absPath) + ) + ); } readmeNodes.push(...ast.children); }) @@ -84,73 +92,93 @@ async function main() { .use(codeLanguageJsonToJsonc) .use(rewritePageLinksToAnchorLinks) .use(rewriteImgTargets) - .use(remarkToc, {tight: true}) - .process(vfile({ - path: readmePath, - contents: '' - })); + .use(trimCutFromTwoslashCode) + .use(remarkToc, { tight: true }) + .process( + vfile({ + path: readmePath, + contents: '', + }) + ); console.error(vfileReporter(renderedReadme)); - if(renderedReadme.messages.length) throw new Error('Aborting on diagnostics.'); + if (renderedReadme.messages.length) + throw new Error('Aborting on diagnostics.'); const lintResults = await remark() .use(remarkValidateLinks) .use(remarkRecommended) .process(renderedReadme); console.error(vfileReporter(lintResults)); - if(lintResults.messages.length) throw new Error('Aborting on diagnostics.'); + if (lintResults.messages.length) throw new Error('Aborting on diagnostics.'); fs.writeFileSync(readmePath, renderedReadme.contents); } function parseFrontmatter() { return (ast) => { - if(ast.children[0].type === 'yaml') { + if (ast.children[0].type === 'yaml') { ast.frontmatter = jsYaml.load(ast.children[0].value); ast.children.splice(0, 1); } - } + }; } function codeLanguageJsonToJsonc() { return (ast) => { - visit(ast, 'code', node => { - if(node.lang === 'json') node.lang = 'jsonc'; - }) - } + visit(ast, 'code', (node) => { + if (node.lang === 'json') node.lang = 'jsonc'; + }); + }; } function rewritePageLinksToAnchorLinks() { return (ast) => { - visit(ast, 'link', node => { - if(node.url?.match?.(/^https?\:\/\//)) return; + visit(ast, 'link', (node) => { + if (node.url?.match?.(/^https?\:\/\//)) return; // TODO take page title into account node.url = node.url.replace(/^[\.\/]*(?:([^#]+)|.*#(.*))$/, '#$1$2'); node.url = node.url.replace(/\.md$/, ''); }); - } + }; } function rewriteImgTargets() { return (ast) => { - visit(ast, 'image', node => { + visit(ast, 'image', (node) => { node.url = node.url.replace(/^\//, 'website/static/'); }); - } + }; +} + +function trimCutFromTwoslashCode() { + return (ast) => { + // Strip everything above // ---cut--- in twoslash code blocks + const lookingFor = '\n// ---cut---\n'; + visit(ast, 'code', (node) => { + if (node.meta?.includes('twoslash') && node.value.includes(lookingFor)) { + node.value = node.value.slice( + node.value.lastIndexOf(lookingFor) + lookingFor.length + ); + } + }); + }; } function headerNode(depth, value) { return { type: 'heading', depth, - children: [{ - type: 'text', - value, - children: [] - }] + children: [ + { + type: 'text', + value, + children: [], + }, + ], }; } try { await main(); -} catch(e) { +} catch (e) { console.error(e.message); process.exitCode = 1; } diff --git a/website/sidebars.js b/website/sidebars.js index 4f1d70e01..4e3dbd9f5 100644 --- a/website/sidebars.js +++ b/website/sidebars.js @@ -1,51 +1,55 @@ module.exports = { - primarySidebar: [{ - type: 'category', - label: 'General', - collapsed: false, - items: [ - 'overview', - 'installation', - 'usage', - 'configuration', - 'options', - 'imports', - 'troubleshooting', - 'performance', - ] - }, { - type: 'category', - label: 'Advanced', - collapsed: false, - items: [ - 'how-it-works', - 'paths', - 'types', - 'compilers', - 'transpilers', - 'module-type-overrides' - ], - }, { - type: 'category', - label: 'Recipes', - collapsed: false, - items: [ - 'recipes/watching-and-restarting', - 'recipes/ava', - 'recipes/gulp', - 'recipes/intellij', - 'recipes/mocha', - 'recipes/tape', - 'recipes/visual-studio-code', - 'recipes/other' - ] - }], - hiddenSidebar: [{ - type: 'category', - label: 'Hidden pages', - collapsed: false, - items: [ - 'options-table', - ] - }], + primarySidebar: [ + { + type: 'category', + label: 'General', + collapsed: false, + items: [ + 'overview', + 'installation', + 'usage', + 'configuration', + 'options', + 'imports', + 'troubleshooting', + 'performance', + ], + }, + { + type: 'category', + label: 'Advanced', + collapsed: false, + items: [ + 'how-it-works', + 'paths', + 'types', + 'compilers', + 'transpilers', + 'module-type-overrides', + ], + }, + { + type: 'category', + label: 'Recipes', + collapsed: false, + items: [ + 'recipes/watching-and-restarting', + 'recipes/ava', + 'recipes/gulp', + 'recipes/intellij', + 'recipes/mocha', + 'recipes/tape', + 'recipes/visual-studio-code', + 'recipes/other', + ], + }, + ], + hiddenSidebar: [ + { + type: 'category', + label: 'Hidden pages', + collapsed: false, + items: ['options-table'], + }, + ], }; diff --git a/website/src/css/custom.css b/website/src/css/custom.css index 9b7e4d80f..49c27214f 100644 --- a/website/src/css/custom.css +++ b/website/src/css/custom.css @@ -33,3 +33,24 @@ margin: 0 calc(-1 * var(--ifm-pre-padding)); padding: 0 var(--ifm-pre-padding); } + +/* Hide the external link icon */ +.navbar__item svg { + display: none; +} + +/* + * Shiki-twoslash + * See: https://github.com/shikijs/twoslash/tree/main/packages/docusaurus-preset-shiki-twoslash + */ +[data-theme='light'] .shiki.nord, +[data-theme='light'] .shiki.min-dark, +[data-theme='light'] .shiki.github-dark, +[data-theme='light'] .shiki.dark-plus, +[data-theme='light'] .shiki.solarized-dark, +[data-theme='dark'] .shiki.min-light, +[data-theme='dark'] .shiki.github-light, +[data-theme='dark'] .shiki.light-plus, +[data-theme='dark'] .shiki.solarized-light { + display: none; +} diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 9a50c6e39..dcb410ad0 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -2,12 +2,11 @@ import React from 'react'; import clsx from 'clsx'; import Layout from '@theme/Layout'; import Link from '@docusaurus/Link'; -import Head from '@docusaurus/Head'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import useBaseUrl from '@docusaurus/useBaseUrl'; import styles from './styles.module.css'; -function Feature({imageUrl, title, description}) { +function Feature({ imageUrl, title, description }) { const imgUrl = useBaseUrl(imageUrl); return (
@@ -22,38 +21,28 @@ function Feature({imageUrl, title, description}) { function Home() { const context = useDocusaurusContext(); - const {siteConfig = {}} = context; + const { siteConfig = {} } = context; return ( - - - - - - - - - - - - +

{siteConfig.title}

{siteConfig.tagline}

+ to={useBaseUrl('docs/')} + > Get Started