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.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
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