Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/server/utils/src/compilers/typescript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const compileByTs: CompileFunc = async (
ts,
absoluteBaseUrl,
paths,
compileOptions.moduleType,
);

const emitResult = program.emit(undefined, undefined, undefined, undefined, {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
import * as os from 'os';
import path, { dirname, posix } from 'path';
import { findMatchedSourcePath, findSourceEntry } from '@modern-js/utils';
import type { MatchPath } from '@modern-js/utils/tsconfig-paths';
import { createMatchPath } from '@modern-js/utils/tsconfig-paths';
import * as ts from 'typescript';

// Convert a resolved source path into the specifier that native ESM output
// should reference at runtime, which is always the emitted `.js` file.
const toEsmOutputPath = (resolvedPath: string) => {
const sourcePath = findSourceEntry(resolvedPath) || resolvedPath;
const ext = path.extname(sourcePath);

return ext ? `${sourcePath.slice(0, -ext.length)}.js` : `${sourcePath}.js`;
};

const resolveRelativeEsmSpecifier = (sf: ts.SourceFile, text: string) => {
if (!text.startsWith('./') && !text.startsWith('../')) {
return;
}

const importerDir = dirname(sf.fileName);
return path.resolve(importerDir, text);
};

const isRegExpKey = (str: string) => {
return str.startsWith('^') || str.endsWith('$');
};
Expand Down Expand Up @@ -67,6 +86,7 @@ export function tsconfigPathsBeforeHookFactory(
tsBinary: typeof ts,
baseUrl: string,
paths: Record<string, string[] | string>,
moduleType?: 'module' | 'commonjs',
) {
const tsPaths: Record<string, string[]> = {};
const alias: Record<string, string> = {};
Expand Down Expand Up @@ -114,7 +134,7 @@ export function tsconfigPathsBeforeHookFactory(
1,
importPathWithQuotes.length - 1,
);
const result = getNotAliasedPath(sf, matchPath, text);
const result = getNotAliasedPath(sf, matchPath, text, moduleType);
if (!result) {
return node;
}
Expand All @@ -141,7 +161,7 @@ export function tsconfigPathsBeforeHookFactory(
1,
importPathWithQuotes.length - 1,
);
const result = getNotAliasedPath(sf, matchPath, text);
const result = getNotAliasedPath(sf, matchPath, text, moduleType);
if (!result) {
return node;
}
Expand Down Expand Up @@ -183,19 +203,23 @@ export function tsconfigPathsBeforeHookFactory(
};
}

// fork from https://github.com/nestjs/nest-cli/blob/HEAD/lib/compiler/hooks/tsconfig-paths.hook.ts
// license at https://github.com/nestjs/nest/blob/master/LICENSE
function getNotAliasedPath(
sf: ts.SourceFile,
matcher: MatchPath,
text: string,
moduleType?: 'module' | 'commonjs',
) {
let result = matcher(text, undefined, undefined, [
'.ts',
'.tsx',
'.js',
'.jsx',
]);
// Resolve aliases and tsconfig paths using the same `.js` -> `.ts` fallback
// rules as the runtime loaders.
let result = findMatchedSourcePath(matcher, text);

// For native ESM, unresolved relative imports like `../service/user` must be
// resolved to a source path before we convert them to the emitted `.js` specifier.
if (!result && moduleType === 'module') {
// This branch is only for relative specifiers. Bare package imports should
// stay untouched when they are not matched by alias rules.
result = resolveRelativeEsmSpecifier(sf, text);
}

if (!result) {
return;
Expand All @@ -206,7 +230,8 @@ function getNotAliasedPath(
}

if (!path.isAbsolute(result)) {
// handle alias to alias
// If an alias resolves to another bare specifier, prefer leaving it as a
// package import when Node can resolve that package.
if (!result.startsWith('.') && !result.startsWith('..')) {
try {
// Installed packages (node modules) should take precedence over root files with the same name.
Expand All @@ -220,6 +245,8 @@ function getNotAliasedPath(
} catch {}
}
try {
// Likewise, if the original specifier already resolves as a package,
// keep the original text instead of forcing a relative filesystem path.
// Installed packages (node modules) should take precedence over root files with the same name.
// Ref: https://github.com/nestjs/nest-cli/issues/838
const packagePath = require.resolve(text, {
Expand All @@ -231,6 +258,13 @@ function getNotAliasedPath(
} catch {}
}

if (moduleType === 'module') {
// Native ESM output must reference the emitted file extension that Node
// will load at runtime, typically `.js`.
result = toEsmOutputPath(result);
}

// Emit a relative specifier from the current source file to the resolved target.
const resolvedPath = posix.relative(dirname(sf.fileName), result) || './';
return resolvedPath[0] === '.' ? resolvedPath : `./${resolvedPath}`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { shared } from '@shared/index.js';

const api = () => {
return `${shared}-js-alias`;
};

export default api;
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { shared } from '../shared/index';

const api = () => {
return `${shared}-relative`;
};

export default api;
16 changes: 16 additions & 0 deletions packages/server/utils/tests/fixtures/ts-example/tsconfig.esm.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"extends": "@modern-js/tsconfig/base",
"compilerOptions": {
"declaration": false,
"jsx": "preserve",
"baseUrl": "./",
"module": "ESNext",
"paths": {
"@/*": ["./src/*"],
"@shared/*": ["./shared/*"],
"@api/*": ["./api/*"],
"@server/*": ["./server/*"]
}
},
"include": ["src", "shared", "server", "config", "api", "modern-app-env.d.ts"]
}
45 changes: 45 additions & 0 deletions packages/server/utils/tests/ts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,4 +55,49 @@ describe('typescript', () => {

await fs.remove(distDir);
});

it('should keep .js suffix for aliased imports in esm output', async () => {
const example = path.join(__dirname, './fixtures', './ts-example');
const tsconfigPath = path.join(example, './tsconfig.esm.json');
const distDir = path.join(example, './dist-esm');
const sharedDir = path.join(example, './shared');
const apiDir = path.join(example, './api');
const serverDir = path.join(example, './server');

try {
await compile(
example,
{
alias: {
'@modern-js/runtime/server': path.join(
sharedDir,
'./runtime/server',
),
},
} as any,
{
sourceDirs: [sharedDir, apiDir, serverDir],
distDir,
tsconfigPath,
moduleType: 'module',
},
);

const apiContent = await fs.readFile(
path.join(distDir, './api/index.js'),
);
const jsAliasContent = await fs.readFile(
path.join(distDir, './api/js-alias.js'),
);
const relativeContent = await fs.readFile(
path.join(distDir, './api/relative.js'),
);

expect(apiContent.toString()).toContain(`from "../shared/index.js"`);
expect(jsAliasContent.toString()).toContain(`from "../shared/index.js"`);
expect(relativeContent.toString()).toContain(`from "../shared/index.js"`);
} finally {
await fs.remove(distDir);
}
});
});
1 change: 0 additions & 1 deletion packages/solutions/app-tools/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,6 @@ export const dev = async (
combinedAlias,
{
moduleType: appContext.moduleType,
preferTsNodeForServerRuntime: true,
},
);

Expand Down
19 changes: 8 additions & 11 deletions packages/solutions/app-tools/src/esm/register-esm.mjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,17 @@
import path from 'node:path';
import { fs } from '@modern-js/utils';

/**
* Register Node.js module hooks for TypeScript support.
* Uses node:module register API to enable ts-node loader.
*/
export const registerModuleHooks = async ({ appDir, distDir, alias }) => {
export const registerModuleHooks = async ({
appDir,
distDir,
baseUrl,
paths,
}) => {
const TS_CONFIG_FILENAME = `tsconfig.json`;
const tsconfigPath = path.resolve(appDir, TS_CONFIG_FILENAME);
const hasTsconfig = await fs.pathExists(tsconfigPath);

if (!hasTsconfig) {
return;
}

const { register } = await import('node:module');
// These can be overridden by ts-node options in tsconfig.json
Expand All @@ -26,10 +25,8 @@ export const registerModuleHooks = async ({ appDir, distDir, alias }) => {
)}/`;
register('./ts-node-loader.mjs', import.meta.url, {
data: {
appDir,
distDir,
alias,
tsconfigPath,
baseUrl,
paths,
},
});
};
Expand Down
25 changes: 16 additions & 9 deletions packages/solutions/app-tools/src/esm/ts-node-loader.mjs
Original file line number Diff line number Diff line change
@@ -1,21 +1,26 @@
import { pathToFileURL } from 'url';
import { findMatchedSourcePath, findSourceEntry } from '@modern-js/utils';
import { createMatchPath } from '@modern-js/utils/tsconfig-paths';
import { resolve as tsNodeResolve } from 'ts-node/esm';
import { load as tsNodeLoad } from 'ts-node/esm';
import { createMatchPath } from './utils.mjs';

let matchPath;
export async function initialize({ appDir, alias, tsconfigPath }) {
matchPath = createMatchPath({
alias,
appDir,
tsconfigPath,
});

export async function initialize({ baseUrl, paths }) {
matchPath = createMatchPath(baseUrl || './', paths || {});
}

export function resolve(specifier, context, defaultResolve) {
const match = matchPath(specifier);
// Without this rewrite, aliases such as `@service/user` and
// `@service/user.js` would never reach ts-node as real source files.
const match = findMatchedSourcePath(matchPath, specifier);

return match
? tsNodeResolve(pathToFileURL(match).href, context, defaultResolve)
? tsNodeResolve(
pathToFileURL(findSourceEntry(match) || match).href,
context,
defaultResolve,
)
: tsNodeResolve(specifier, context, defaultResolve);
}

Expand All @@ -30,5 +35,7 @@ export function load(url, context, defaultLoad) {
return defaultLoad(url, context);
}

// Without ts-node here, local `.ts` files would be handed to Node as-is and
// fail to execute in environments that do not natively run TypeScript.
return tsNodeLoad(url, context, defaultLoad);
}
Loading
Loading