Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature: Expose esm hooks factory to public API #1439

Merged
merged 11 commits into from Oct 10, 2021
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) {
nonara marked this conversation as resolved.
Show resolved Hide resolved
// 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
}
}