ESM loader that handles TypeScript path mapping #1450
Replies: 10 comments 50 replies
-
Under Windows, it the specifier also require
on absolute paths generated by |
Beta Was this translation helpful? Give feedback.
-
awesome workaround! to get this working for me, i needed to account for directories as well: /**
* Custom resolver that handles TypeScript path mappings.
*
* @see https://github.com/TypeStrong/ts-node/discussions/1450
* @see https://github.com/dividab/tsconfig-paths
*
* @async
* @param {string} specifier - Name of file to resolve
* @param {{ parentURL: string }} ctx - Resolver context
* @param {typeof resolveTs} defaultResolve - Default resolver function
* @return {Promise<{ url: string }>} Promise containing object with file path
*/
export const resolve = async (specifier, ctx, defaultResolve) => {
// Get base URL and path aliases
const { absoluteBaseUrl, paths } = loadConfig(process.cwd())
// Attempt to resolve path based on path aliases
const match = createMatchPath(absoluteBaseUrl, paths)(specifier)
// Update specifier if match was found
if (match) {
try {
const directory = lstatSync(match).isDirectory()
specifier = `${match}${directory ? '/index.js' : '.js'}`
} catch {
specifier = `${match}.js`
}
}
return resolveTs(specifier, ctx, defaultResolve)
} for those interested, my custom loader: import { lstatSync } from 'fs'
import path from 'path'
import { getFormat as getFormatTs, resolve as resolveTs } from 'ts-node/esm'
import { createMatchPath, loadConfig } from 'tsconfig-paths'
import useDualExports from '../helpers/use-dual-exports'
/**
* @file Helpers - Custom ESM Loader
* @module tools/loaders/esm
* @see https://github.com/TypeStrong/ts-node/issues/1007
*/
/** @typedef {'builtin'|'commonjs'|'dynamic'|'json'|'module'|'wasm'} Format */
// ! Add ESM-compatible export statement to `exports.default` statements
// ! Fixes: `TypeError: logger is not a function`
useDualExports([`${process.env.NODE_MODULES}/@flex-development/grease/cjs/**`])
/**
* ESM requires all imported files to have extensions. Unfortunately, most `bin`
* scripts do **not** include extensions.
*
* This custom hook provides support for extensionless files by assuming they're
* all `commonjs` modules.
*
* @see https://github.com/nodejs/node/pull/31415
* @see https://github.com/nodejs/modules/issues/488#issuecomment-589274887
* @see https://github.com/nodejs/modules/issues/488#issuecomment-804895142
*
* @async
* @param {string} url - File URL
* @param {{}} ctx - Resolver context
* @param {typeof getFormatTs} defaultGetFormat - Default format function
* @return {Promise<{ format: Format }>} Promise containing module format
*/
export const getFormat = async (url, ctx, defaultGetFormat) => {
// Get file extension
const ext = path.extname(url)
// Support extensionless files in `bin` scripts
if (/^file:\/\/\/.*\/bin\//.test(url) && !ext) return { format: 'commonjs' }
// Load TypeScript files as ESM
// See `tsconfig.json#ts-node.moduleTypes` for file-specific overrides
if (ext === '.ts') return { format: 'module' }
// Use default format module for all other files
return defaultGetFormat(url, ctx, defaultGetFormat)
}
/**
* Custom resolver that handles TypeScript path mappings.
*
* @see https://github.com/TypeStrong/ts-node/discussions/1450
* @see https://github.com/dividab/tsconfig-paths
*
* @async
* @param {string} specifier - Name of file to resolve
* @param {{ parentURL: string }} ctx - Resolver context
* @param {typeof resolveTs} defaultResolve - Default resolver function
* @return {Promise<{ url: string }>} Promise containing object with file path
*/
export const resolve = async (specifier, ctx, defaultResolve) => {
// Get base URL and path aliases
const { absoluteBaseUrl, paths } = loadConfig(process.cwd())
// Attempt to resolve path based on path aliases
const match = createMatchPath(absoluteBaseUrl, paths)(specifier)
// Update specifier if match was found
if (match) {
try {
const directory = lstatSync(match).isDirectory()
specifier = `${match}${directory ? '/index.js' : '.js'}`
} catch {
specifier = `${match}.js`
}
}
return resolveTs(specifier, ctx, defaultResolve)
}
export { transformSource } from 'ts-node/esm' example using custom loader: https://github.com/flex-development/log/tree/8bb4e104918bb0588d3aaa8aeb8733811bc1ac46 |
Beta Was this translation helpful? Give feedback.
-
These don't seem to work with new versions of Node 16 and 17 which have a new hooks API. Could somebody update these for the new API? Trying to use the above as is with Node 16.13.0 or 17.1.0 results in |
Beta Was this translation helpful? Give feedback.
-
The best I found was to start Node with "serve": "cross-env TS_NODE_PROJECT=\"tsconfig.build.json\" node --experimental-specifier-resolution=node --loader ./loader.js src/index.ts", Then I'm using this implementation of import { resolve as resolveTs } from 'ts-node/esm'
import * as tsConfigPaths from 'tsconfig-paths'
import { pathToFileURL } from 'url'
const { absoluteBaseUrl, paths } = tsConfigPaths.loadConfig()
const matchPath = tsConfigPaths.createMatchPath(absoluteBaseUrl, paths)
export function resolve (specifier, ctx, defaultResolve) {
const match = matchPath(specifier)
return match
? resolveTs(pathToFileURL(`${match}`).href, ctx, defaultResolve)
: resolveTs(specifier, ctx, defaultResolve)
}
export { load, transformSource } from 'ts-node/esm' If you want to continue suffixing const lastIndexOfIndex = specifier.lastIndexOf('/index.js')
if (lastIndexOfIndex !== -1) {
// Handle index.js
const trimmed = specifier.substring(0, lastIndexOfIndex)
const match = matchPath(trimmed)
if (match) return resolveTs(pathToFileURL(`${match}/index.js`).href, ctx, defaultResolve)
} else if (specifier.endsWith('.js')) {
// Handle *.js
const trimmed = specifier.substring(0, specifier.length - 3)
const match = matchPath(trimmed)
if (match) return resolveTs(pathToFileURL(`${match}.js`).href, ctx, defaultResolve)
}
return resolveTs(specifier, ctx, defaultResolve) I've patched this together from several sources, so credit goes to the original authors: |
Beta Was this translation helpful? Give feedback.
-
Hello, cannot get it to work. Would really appreciate your help. So I have a test.ts which looks like this: and in this test.ts I'm importing e2e-api from my dist folder. **I tried the following:
Both cases I get the same error mentioned above. loader.js looks like this: My tsconfig.json looks like this: |
Beta Was this translation helpful? Give feedback.
-
If you get Currently working code used by me: import { pathToFileURL } from 'url';
import { resolve as resolveTs, getFormat, transformSource, load } from 'ts-node/esm';
import * as tsConfigPaths from 'tsconfig-paths'
export { getFormat, transformSource, load };
const { absoluteBaseUrl, paths } = tsConfigPaths.loadConfig()
const matchPath = tsConfigPaths.createMatchPath(absoluteBaseUrl, paths)
export function resolve(specifier, context, defaultResolver) {
const mappedSpecifier = matchPath(specifier);
console.log(mappedSpecifier);
if (mappedSpecifier) {
specifier = pathToFileURL(mappedSpecifier) + '.ts';
}
return resolveTs(specifier, context, defaultResolver);
} |
Beta Was this translation helpful? Give feedback.
-
It appears there is already a PR to add support for this but unfortunately it appears progress has faulted. #1585 |
Beta Was this translation helpful? Give feedback.
-
The previous solution for matching // loader.js
import { isBuiltin } from 'node:module';
import { dirname } from 'node:path';
import { promisify } from 'node:util';
import { fileURLToPath, pathToFileURL } from 'node:url';
import resolveCallback from 'resolve';
import { resolve as resolveTs, load } from 'ts-node/esm';
import { loadConfig, createMatchPath } from 'tsconfig-paths';
const resolveAsync = promisify(resolveCallback);
const tsExtensions = new Set(['.tsx', '.ts', '.mts', '.cts']);
const { absoluteBaseUrl, paths } = loadConfig();
const matchPath = createMatchPath(absoluteBaseUrl, paths);
async function resolve(specifier, ctx, defaultResolve) {
const { parentURL = pathToFileURL(absoluteBaseUrl) } = ctx;
if (isBuiltin(specifier)) { return defaultResolve(specifier, ctx); }
if (specifier.startsWith('file://')) { specifier = fileURLToPath(specifier); }
let url;
try {
const resolution = await resolveAsync(matchPath(specifier) || specifier, {
basedir: dirname(fileURLToPath(parentURL)),
// For whatever reason, --experimental-specifier-resolution=node doesn't search for .mjs extensions
// but it does search for index.mjs files within directories
extensions: ['.js', '.json', '.node', '.mjs', ...tsExtensions],
});
url = pathToFileURL(resolution).href;
} catch (error) {
if (error.code === 'MODULE_NOT_FOUND') {
// Match Node's error code
error.code = 'ERR_MODULE_NOT_FOUND';
}
throw error;
}
return resolveTs(url, ctx, defaultResolve)
}
export { resolve, load };
import { resolve as resolvePath } from 'node:path';
let tryPaths = [specifier];
for (const [path, pathMaps] of Object.entries(paths)) {
const match = specifier.match(`^${path.replace('*', '(.*)')}`);
if (!match) { continue; }
tryPaths = tryPaths.concat(pathMaps.map((pathMap) => resolvePath(
absoluteBaseUrl,
pathMap.replace('*', match[1] || '')
)));
} Replace |
Beta Was this translation helpful? Give feedback.
-
I've got path mapping working for me on Node 19.6.0 |
Beta Was this translation helpful? Give feedback.
-
Did someone let it build js on production? This just seems to allow node to run directly |
Beta Was this translation helpful? Give feedback.
-
At the moment, the ESM loader does not handle TypeScript path mappings. To make it work you can use the following custom loader:
Then use the loader with
node --loader loader.js my-script.ts
.Caveat: This only works for module specifiers without an extension. For example,
import "/foo/bar"
works, butimport "/foo/bar.js"
andimport "/foo/bar.ts"
do not.Beta Was this translation helpful? Give feedback.
All reactions