Skip to content

Commit

Permalink
Feature: Expose esm hooks factory to public API (#1439)
Browse files Browse the repository at this point in the history
* 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 <cspotcode@gmail.com>
  • Loading branch information
Ron S and cspotcode committed Oct 10, 2021
1 parent b52ca45 commit 4a0db31
Show file tree
Hide file tree
Showing 9 changed files with 107 additions and 23 deletions.
1 change: 0 additions & 1 deletion src/bin.ts
Expand Up @@ -299,7 +299,6 @@ export function main(
['ts-node']: {
...service.options,
optionBasePaths: undefined,
experimentalEsmLoader: undefined,
compilerOptions: undefined,
project: service.configFilePath ?? service.options.project,
},
Expand Down
5 changes: 4 additions & 1 deletion src/configuration.ts
Expand Up @@ -251,7 +251,10 @@ export function readConfig(
*/
function filterRecognizedTsConfigTsNodeOptions(
jsonObject: any
): { recognized: TsConfigOptions; unrecognized: any } {
): {
recognized: TsConfigOptions;
unrecognized: any;
} {
if (jsonObject == null) return { recognized: {}, unrecognized: {} };
const {
compiler,
Expand Down
28 changes: 16 additions & 12 deletions src/esm.ts
@@ -1,4 +1,4 @@
import { register, getExtensions, RegisterOptions } from './index';
import { getExtensions, register, RegisterOptions, Service } from './index';
import {
parse as parseUrl,
format as formatUrl,
Expand All @@ -15,17 +15,21 @@ const {

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

/** @internal */
export function registerAndCreateEsmHooks(opts?: RegisterOptions) {
// Automatically performs registration just like `-r ts-node/register`
const tsNodeInstance = register({
...opts,
experimentalEsmLoader: true,
});
const tsNodeInstance = register(opts);

return createEsmHooks(tsNodeInstance);
}

export function createEsmHooks(tsNodeService: Service) {
tsNodeService.enableExperimentalEsmLoaderInterop();

// Custom implementation that considers additional file extensions and automatically adds file extensions
const nodeResolveImplementation = createResolve({
...getExtensions(tsNodeInstance.config),
preferTsExts: tsNodeInstance.options.preferTsExts,
...getExtensions(tsNodeService.config),
preferTsExts: tsNodeService.options.preferTsExts,
});

return { resolve, getFormat, transformSource };
Expand Down Expand Up @@ -98,17 +102,17 @@ export function registerAndCreateEsmHooks(opts?: RegisterOptions) {
// If file has .ts, .tsx, or .jsx extension, then ask node how it would treat this file if it were .js
const ext = extname(nativePath);
let nodeSays: { format: Format };
if (ext !== '.js' && !tsNodeInstance.ignored(nativePath)) {
if (ext !== '.js' && !tsNodeService.ignored(nativePath)) {
nodeSays = await defer(formatUrl(pathToFileURL(nativePath + '.js')));
} else {
nodeSays = await defer();
}
// For files compiled by ts-node that node believes are either CJS or ESM, check if we should override that classification
if (
!tsNodeInstance.ignored(nativePath) &&
!tsNodeService.ignored(nativePath) &&
(nodeSays.format === 'commonjs' || nodeSays.format === 'module')
) {
const { moduleType } = tsNodeInstance.moduleTypeClassifier.classifyModule(
const { moduleType } = tsNodeService.moduleTypeClassifier.classifyModule(
normalizeSlashes(nativePath)
);
if (moduleType === 'cjs') {
Expand Down Expand Up @@ -139,11 +143,11 @@ export function registerAndCreateEsmHooks(opts?: RegisterOptions) {
}
const nativePath = fileURLToPath(url);

if (tsNodeInstance.ignored(nativePath)) {
if (tsNodeService.ignored(nativePath)) {
return defer();
}

const emittedJs = tsNodeInstance.compile(sourceAsString, nativePath);
const emittedJs = tsNodeService.compile(sourceAsString, nativePath);

return { source: emittedJs };
}
Expand Down
27 changes: 18 additions & 9 deletions src/index.ts
Expand Up @@ -294,12 +294,6 @@ export interface CreateOptions {
transformers?:
| _ts.CustomTransformers
| ((p: _ts.Program) => _ts.CustomTransformers);
/**
* True if require() hooks should interop with experimental ESM loader.
* Enabled explicitly via a flag since it is a breaking change.
* @internal
*/
experimentalEsmLoader?: boolean;
/**
* Allows the usage of top level await in REPL.
*
Expand Down Expand Up @@ -369,7 +363,6 @@ export interface TsConfigOptions
| 'dir'
| 'cwd'
| 'projectSearchDir'
| 'experimentalEsmLoader'
| 'optionBasePaths'
> {}

Expand Down Expand Up @@ -405,7 +398,6 @@ export const DEFAULTS: RegisterOptions = {
typeCheck: yn(env.TS_NODE_TYPE_CHECK),
compilerHost: yn(env.TS_NODE_COMPILER_HOST),
logError: yn(env.TS_NODE_LOG_ERROR),
experimentalEsmLoader: false,
experimentalReplAwait: yn(env.TS_NODE_EXPERIMENTAL_REPL_AWAIT) ?? undefined,
};

Expand Down Expand Up @@ -452,6 +444,8 @@ export interface Service {
addDiagnosticFilter(filter: DiagnosticFilter): void;
/** @internal */
installSourceMapSupport(): void;
/** @internal */
enableExperimentalEsmLoaderInterop(): void;
}

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

/**
* True if require() hooks should interop with experimental ESM loader.
* Enabled explicitly via a flag since it is a breaking change.
*/
let experimentalEsmLoader = false;
function enableExperimentalEsmLoaderInterop() {
experimentalEsmLoader = true;
}

return {
[TS_NODE_SERVICE_BRAND]: true,
ts,
Expand All @@ -1274,6 +1277,7 @@ export function create(rawOptions: CreateOptions = {}): Service {
shouldReplAwait,
addDiagnosticFilter,
installSourceMapSupport,
enableExperimentalEsmLoaderInterop,
};
}

Expand Down Expand Up @@ -1468,3 +1472,8 @@ function getTokenAtPosition(
return current;
}
}

import type { createEsmHooks as createEsmHooksFn } from './esm';
export const createEsmHooks: typeof createEsmHooksFn = (
tsNodeService: Service
) => require('./esm').createEsmHooks(tsNodeService);
39 changes: 39 additions & 0 deletions src/test/esm-loader.spec.ts
@@ -0,0 +1,39 @@
// ESM loader hook tests
// TODO: at the time of writing, other ESM loader hook tests have not been moved into this file.
// Should consolidate them here.

import { context } from './testlib';
import semver = require('semver');
import {
contextTsNodeUnderTest,
EXPERIMENTAL_MODULES_FLAG,
TEST_DIR,
} from './helpers';
import { createExec } from './exec-helpers';
import { join } from 'path';
import * as expect from 'expect';

const test = context(contextTsNodeUnderTest);

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`,
{
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'/);
});
}
});
4 changes: 4 additions & 0 deletions tests/esm-custom-loader/index.ts
@@ -0,0 +1,4 @@
export function abc() {
let unusedVar: string;
return true;
}
16 changes: 16 additions & 0 deletions tests/esm-custom-loader/loader.mjs
@@ -0,0 +1,16 @@
import { fileURLToPath } from 'url';
import { createRequire } from 'module';
const require = createRequire(fileURLToPath(import.meta.url));

/** @type {import('../../dist')} **/
const { createEsmHooks, register } = require('ts-node');

const tsNodeInstance = register({
compilerOptions: {
noUnusedLocals: true,
},
});

export const { resolve, getFormat, transformSource } = createEsmHooks(
tsNodeInstance
);
3 changes: 3 additions & 0 deletions tests/esm-custom-loader/package.json
@@ -0,0 +1,3 @@
{
"type": "module"
}
7 changes: 7 additions & 0 deletions tests/esm-custom-loader/tsconfig.json
@@ -0,0 +1,7 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "node",
"noUnusedLocals": false
}
}

0 comments on commit 4a0db31

Please sign in to comment.