Skip to content

Commit 4a0db31

Browse files
Ron Scspotcode
Ron S
andauthored
Feature: Expose esm hooks factory to public API (#1439)
* Add esm to project exports * Convert internal `registerAndCreateEsmHooks` to `createEsmHooks` API * Revert "Add esm to project exports" This reverts commit f53ac63. * Revert esm loaders + add `registerAndCreateEsmHooks` * Add tests for `createEsmHooks` * refactor experimentalEsmLoader into an @internal method on the Service: enableExperimentalEsmLoaderInterop() This avoids consumers needing to pass an @internal flag at service creation time, since we can call the method automatically within createEsmHooks. * lint-fix * Make test case more robust * fix * Fix version check; we do not support ESM loader on less than node 12.16 Co-authored-by: Andrew Bradley <[email protected]>
1 parent b52ca45 commit 4a0db31

File tree

9 files changed

+107
-23
lines changed

9 files changed

+107
-23
lines changed

src/bin.ts

-1
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,6 @@ export function main(
299299
['ts-node']: {
300300
...service.options,
301301
optionBasePaths: undefined,
302-
experimentalEsmLoader: undefined,
303302
compilerOptions: undefined,
304303
project: service.configFilePath ?? service.options.project,
305304
},

src/configuration.ts

+4-1
Original file line numberDiff line numberDiff line change
@@ -251,7 +251,10 @@ export function readConfig(
251251
*/
252252
function filterRecognizedTsConfigTsNodeOptions(
253253
jsonObject: any
254-
): { recognized: TsConfigOptions; unrecognized: any } {
254+
): {
255+
recognized: TsConfigOptions;
256+
unrecognized: any;
257+
} {
255258
if (jsonObject == null) return { recognized: {}, unrecognized: {} };
256259
const {
257260
compiler,

src/esm.ts

+16-12
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { register, getExtensions, RegisterOptions } from './index';
1+
import { getExtensions, register, RegisterOptions, Service } from './index';
22
import {
33
parse as parseUrl,
44
format as formatUrl,
@@ -15,17 +15,21 @@ const {
1515

1616
// Note: On Windows, URLs look like this: file:///D:/dev/@TypeStrong/ts-node-examples/foo.ts
1717

18+
/** @internal */
1819
export function registerAndCreateEsmHooks(opts?: RegisterOptions) {
1920
// Automatically performs registration just like `-r ts-node/register`
20-
const tsNodeInstance = register({
21-
...opts,
22-
experimentalEsmLoader: true,
23-
});
21+
const tsNodeInstance = register(opts);
22+
23+
return createEsmHooks(tsNodeInstance);
24+
}
25+
26+
export function createEsmHooks(tsNodeService: Service) {
27+
tsNodeService.enableExperimentalEsmLoaderInterop();
2428

2529
// Custom implementation that considers additional file extensions and automatically adds file extensions
2630
const nodeResolveImplementation = createResolve({
27-
...getExtensions(tsNodeInstance.config),
28-
preferTsExts: tsNodeInstance.options.preferTsExts,
31+
...getExtensions(tsNodeService.config),
32+
preferTsExts: tsNodeService.options.preferTsExts,
2933
});
3034

3135
return { resolve, getFormat, transformSource };
@@ -98,17 +102,17 @@ export function registerAndCreateEsmHooks(opts?: RegisterOptions) {
98102
// If file has .ts, .tsx, or .jsx extension, then ask node how it would treat this file if it were .js
99103
const ext = extname(nativePath);
100104
let nodeSays: { format: Format };
101-
if (ext !== '.js' && !tsNodeInstance.ignored(nativePath)) {
105+
if (ext !== '.js' && !tsNodeService.ignored(nativePath)) {
102106
nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js')));
103107
} else {
104108
nodeSays = await defer();
105109
}
106110
// For files compiled by ts-node that node believes are either CJS or ESM, check if we should override that classification
107111
if (
108-
!tsNodeInstance.ignored(nativePath) &&
112+
!tsNodeService.ignored(nativePath) &&
109113
(nodeSays.format === 'commonjs' || nodeSays.format === 'module')
110114
) {
111-
const { moduleType } = tsNodeInstance.moduleTypeClassifier.classifyModule(
115+
const { moduleType } = tsNodeService.moduleTypeClassifier.classifyModule(
112116
normalizeSlashes(nativePath)
113117
);
114118
if (moduleType === 'cjs') {
@@ -139,11 +143,11 @@ export function registerAndCreateEsmHooks(opts?: RegisterOptions) {
139143
}
140144
const nativePath = fileURLToPath(url);
141145

142-
if (tsNodeInstance.ignored(nativePath)) {
146+
if (tsNodeService.ignored(nativePath)) {
143147
return defer();
144148
}
145149

146-
const emittedJs = tsNodeInstance.compile(sourceAsString, nativePath);
150+
const emittedJs = tsNodeService.compile(sourceAsString, nativePath);
147151

148152
return { source: emittedJs };
149153
}

src/index.ts

+18-9
Original file line numberDiff line numberDiff line change
@@ -294,12 +294,6 @@ export interface CreateOptions {
294294
transformers?:
295295
| _ts.CustomTransformers
296296
| ((p: _ts.Program) => _ts.CustomTransformers);
297-
/**
298-
* True if require() hooks should interop with experimental ESM loader.
299-
* Enabled explicitly via a flag since it is a breaking change.
300-
* @internal
301-
*/
302-
experimentalEsmLoader?: boolean;
303297
/**
304298
* Allows the usage of top level await in REPL.
305299
*
@@ -369,7 +363,6 @@ export interface TsConfigOptions
369363
| 'dir'
370364
| 'cwd'
371365
| 'projectSearchDir'
372-
| 'experimentalEsmLoader'
373366
| 'optionBasePaths'
374367
> {}
375368

@@ -405,7 +398,6 @@ export const DEFAULTS: RegisterOptions = {
405398
typeCheck: yn(env.TS_NODE_TYPE_CHECK),
406399
compilerHost: yn(env.TS_NODE_COMPILER_HOST),
407400
logError: yn(env.TS_NODE_LOG_ERROR),
408-
experimentalEsmLoader: false,
409401
experimentalReplAwait: yn(env.TS_NODE_EXPERIMENTAL_REPL_AWAIT) ?? undefined,
410402
};
411403

@@ -452,6 +444,8 @@ export interface Service {
452444
addDiagnosticFilter(filter: DiagnosticFilter): void;
453445
/** @internal */
454446
installSourceMapSupport(): void;
447+
/** @internal */
448+
enableExperimentalEsmLoaderInterop(): void;
455449
}
456450

457451
/**
@@ -688,7 +682,7 @@ export function create(rawOptions: CreateOptions = {}): Service {
688682
// If it's a file URL, convert to local path
689683
// Note: fileURLToPath does not exist on early node v10
690684
// I could not find a way to handle non-URLs except to swallow an error
691-
if (options.experimentalEsmLoader && path.startsWith('file://')) {
685+
if (experimentalEsmLoader && path.startsWith('file://')) {
692686
try {
693687
path = fileURLToPath(path);
694688
} catch (e) {
@@ -1260,6 +1254,15 @@ export function create(rawOptions: CreateOptions = {}): Service {
12601254
});
12611255
}
12621256

1257+
/**
1258+
* True if require() hooks should interop with experimental ESM loader.
1259+
* Enabled explicitly via a flag since it is a breaking change.
1260+
*/
1261+
let experimentalEsmLoader = false;
1262+
function enableExperimentalEsmLoaderInterop() {
1263+
experimentalEsmLoader = true;
1264+
}
1265+
12631266
return {
12641267
[TS_NODE_SERVICE_BRAND]: true,
12651268
ts,
@@ -1274,6 +1277,7 @@ export function create(rawOptions: CreateOptions = {}): Service {
12741277
shouldReplAwait,
12751278
addDiagnosticFilter,
12761279
installSourceMapSupport,
1280+
enableExperimentalEsmLoaderInterop,
12771281
};
12781282
}
12791283

@@ -1468,3 +1472,8 @@ function getTokenAtPosition(
14681472
return current;
14691473
}
14701474
}
1475+
1476+
import type { createEsmHooks as createEsmHooksFn } from './esm';
1477+
export const createEsmHooks: typeof createEsmHooksFn = (
1478+
tsNodeService: Service
1479+
) => require('./esm').createEsmHooks(tsNodeService);

src/test/esm-loader.spec.ts

+39
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// ESM loader hook tests
2+
// TODO: at the time of writing, other ESM loader hook tests have not been moved into this file.
3+
// Should consolidate them here.
4+
5+
import { context } from './testlib';
6+
import semver = require('semver');
7+
import {
8+
contextTsNodeUnderTest,
9+
EXPERIMENTAL_MODULES_FLAG,
10+
TEST_DIR,
11+
} from './helpers';
12+
import { createExec } from './exec-helpers';
13+
import { join } from 'path';
14+
import * as expect from 'expect';
15+
16+
const test = context(contextTsNodeUnderTest);
17+
18+
const exec = createExec({
19+
cwd: TEST_DIR,
20+
});
21+
22+
test.suite('createEsmHooks', (test) => {
23+
if (semver.gte(process.version, '12.16.0')) {
24+
test('should create proper hooks with provided instance', async () => {
25+
const { err } = await exec(
26+
`node ${EXPERIMENTAL_MODULES_FLAG} --loader ./loader.mjs index.ts`,
27+
{
28+
cwd: join(TEST_DIR, './esm-custom-loader'),
29+
}
30+
);
31+
32+
if (err === null) {
33+
throw new Error('Command was expected to fail, but it succeeded.');
34+
}
35+
36+
expect(err.message).toMatch(/TS6133:\s+'unusedVar'/);
37+
});
38+
}
39+
});

tests/esm-custom-loader/index.ts

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export function abc() {
2+
let unusedVar: string;
3+
return true;
4+
}

tests/esm-custom-loader/loader.mjs

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { fileURLToPath } from 'url';
2+
import { createRequire } from 'module';
3+
const require = createRequire(fileURLToPath(import.meta.url));
4+
5+
/** @type {import('../../dist')} **/
6+
const { createEsmHooks, register } = require('ts-node');
7+
8+
const tsNodeInstance = register({
9+
compilerOptions: {
10+
noUnusedLocals: true,
11+
},
12+
});
13+
14+
export const { resolve, getFormat, transformSource } = createEsmHooks(
15+
tsNodeInstance
16+
);

tests/esm-custom-loader/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"type": "module"
3+
}

tests/esm-custom-loader/tsconfig.json

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"compilerOptions": {
3+
"module": "ESNext",
4+
"moduleResolution": "node",
5+
"noUnusedLocals": false
6+
}
7+
}

0 commit comments

Comments
 (0)