Skip to content

Commit f77a35c

Browse files
authored
fix: Node 20 ESM fallback for TypeScript module graphs (#4557)
* Fix Node 20 ESM fallback for TypeScript module graphs * Fix Node 20 ESM fallback package resolution * Ignore esm fallback temp files in mock dev watchers
1 parent 67f316c commit f77a35c

9 files changed

Lines changed: 236 additions & 28 deletions

File tree

packages/core/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@midwayjs/core",
3-
"version": "4.0.2",
3+
"version": "4.0.3-beta.2",
44
"description": "midway core",
55
"main": "dist/index.js",
66
"typings": "dist/index.d.ts",

packages/core/src/util/index.ts

Lines changed: 178 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1-
import { dirname, resolve, sep, posix, join } from 'path';
2-
import { readFileSync, writeFileSync, unlinkSync, existsSync } from 'fs';
1+
import { dirname, resolve, sep, posix, join, relative } from 'path';
2+
import {
3+
readFileSync,
4+
writeFileSync,
5+
existsSync,
6+
mkdtempSync,
7+
rmSync,
8+
} from 'fs';
39
import { debuglog } from 'util';
410
import { PathToRegexpUtil } from './pathToRegexp';
511
import { MidwayCommonError } from '../error';
@@ -16,7 +22,9 @@ import { CONFIGURATION_KEY, CONFIGURATION_OBJECT_KEY } from '../decorator';
1622

1723
const debug = debuglog('midway:debug');
1824

19-
function resolveRelativeEsmSpecifierFallback(
25+
let cachedTypeScriptCompiler: any;
26+
27+
function resolveRelativeEsmSpecifierPath(
2028
importerFile: string,
2129
specifier: string
2230
): string | undefined {
@@ -28,7 +36,7 @@ function resolveRelativeEsmSpecifierFallback(
2836
}
2937

3038
const absolute = resolve(dirname(importerFile), specifier);
31-
const candidates: string[] = [];
39+
const candidates: string[] = [absolute];
3240

3341
if (/\.(mjs|cjs|js)$/i.test(specifier)) {
3442
candidates.push(
@@ -43,23 +51,44 @@ function resolveRelativeEsmSpecifierFallback(
4351
`${absolute}.cts`,
4452
`${absolute}.ts`,
4553
`${absolute}.tsx`,
54+
`${absolute}.mjs`,
55+
`${absolute}.cjs`,
56+
`${absolute}.js`,
57+
`${absolute}.json`,
4658
join(absolute, 'index.mts'),
4759
join(absolute, 'index.cts'),
4860
join(absolute, 'index.ts'),
49-
join(absolute, 'index.tsx')
61+
join(absolute, 'index.tsx'),
62+
join(absolute, 'index.mjs'),
63+
join(absolute, 'index.cjs'),
64+
join(absolute, 'index.js'),
65+
join(absolute, 'index.json')
5066
);
5167
}
5268

5369
for (const item of candidates) {
5470
if (existsSync(item)) {
55-
const normalized = item.split(sep).join('/');
56-
const baseDir = dirname(importerFile).split(sep).join('/');
57-
if (normalized.startsWith(baseDir + '/')) {
58-
return './' + normalized.slice(baseDir.length + 1);
59-
}
60-
return specifier;
71+
return item;
72+
}
73+
}
74+
75+
return undefined;
76+
}
77+
78+
function resolveRelativeEsmSpecifierFallback(
79+
importerFile: string,
80+
specifier: string
81+
): string | undefined {
82+
const resolved = resolveRelativeEsmSpecifierPath(importerFile, specifier);
83+
if (resolved) {
84+
const normalized = resolved.split(sep).join('/');
85+
const baseDir = dirname(importerFile).split(sep).join('/');
86+
if (normalized.startsWith(baseDir + '/')) {
87+
return './' + normalized.slice(baseDir.length + 1);
6188
}
89+
return specifier;
6290
}
91+
6392
return undefined;
6493
}
6594

@@ -86,6 +115,137 @@ function rewriteEsmSourceWithSpecifierFallback(
86115
return changed ? output : source;
87116
}
88117

118+
function shouldUseEsmFallback(
119+
originErr: any,
120+
filePath: string,
121+
rewritten: string,
122+
source: string
123+
) {
124+
if (rewritten !== source) {
125+
return true;
126+
}
127+
128+
return (
129+
originErr?.code === 'ERR_UNKNOWN_FILE_EXTENSION' &&
130+
/\.(mts|cts|ts|tsx)$/i.test(filePath)
131+
);
132+
}
133+
134+
function formatFallbackImportSpecifier(fromFile: string, toFile: string) {
135+
let specifier = relative(dirname(fromFile), toFile).split(sep).join('/');
136+
if (!specifier.startsWith('.')) {
137+
specifier = `./${specifier}`;
138+
}
139+
return specifier;
140+
}
141+
142+
function loadTypeScriptCompiler(sourceFile: string) {
143+
if (cachedTypeScriptCompiler) {
144+
return cachedTypeScriptCompiler;
145+
}
146+
147+
const searchPaths = [dirname(sourceFile), process.cwd(), __dirname];
148+
for (const item of searchPaths) {
149+
try {
150+
cachedTypeScriptCompiler = require(
151+
require.resolve('typescript', {
152+
paths: [item],
153+
})
154+
);
155+
return cachedTypeScriptCompiler;
156+
} catch {
157+
// try next path
158+
}
159+
}
160+
}
161+
162+
function createCompiledEsmFallbackGraph(entryFile: string) {
163+
const tempDir = mkdtempSync(
164+
join(dirname(entryFile), '.midway-esm-fallback-')
165+
);
166+
const compiledFileMap = new Map<string, string>();
167+
const tsCompiler = loadTypeScriptCompiler(entryFile);
168+
169+
const compileFile = (sourceFile: string): string => {
170+
const existed = compiledFileMap.get(sourceFile);
171+
if (existed) {
172+
return existed;
173+
}
174+
175+
const compiledFile = join(
176+
tempDir,
177+
`${crypto.createHash('sha1').update(sourceFile).digest('hex')}.mjs`
178+
);
179+
compiledFileMap.set(sourceFile, compiledFile);
180+
181+
if (sourceFile.endsWith('.json')) {
182+
const jsonSource = readFileSync(sourceFile, { encoding: 'utf-8' });
183+
writeFileSync(compiledFile, `export default ${jsonSource};`, {
184+
encoding: 'utf-8',
185+
});
186+
return compiledFile;
187+
}
188+
189+
const source = readFileSync(sourceFile, { encoding: 'utf-8' });
190+
const rewriteByPattern = (pattern: RegExp, input: string) => {
191+
return input.replace(pattern, (full, head, spec, tail) => {
192+
const resolved = resolveRelativeEsmSpecifierPath(sourceFile, spec);
193+
if (!resolved) {
194+
return full;
195+
}
196+
const compiledDependency = compileFile(resolved);
197+
const fallbackSpecifier = formatFallbackImportSpecifier(
198+
compiledFile,
199+
compiledDependency
200+
);
201+
return `${head}${fallbackSpecifier}${tail}`;
202+
});
203+
};
204+
205+
let rewritten = source;
206+
rewritten = rewriteByPattern(/(from\s+['"])([^'"]+)(['"])/g, rewritten);
207+
rewritten = rewriteByPattern(
208+
/(import\s*\(\s*['"])([^'"]+)(['"]\s*\))/g,
209+
rewritten
210+
);
211+
212+
let output = rewritten;
213+
if (/\.(mts|cts|ts|tsx)$/i.test(sourceFile)) {
214+
if (!tsCompiler) {
215+
throw new Error(
216+
`[core]: can not transpile esm typescript file "${sourceFile}", please install "typescript" in current project`
217+
);
218+
}
219+
220+
output = tsCompiler.transpileModule(rewritten, {
221+
fileName: sourceFile,
222+
compilerOptions: {
223+
module: tsCompiler.ModuleKind.ESNext,
224+
target: tsCompiler.ScriptTarget.ES2020,
225+
moduleResolution: tsCompiler.ModuleResolutionKind.NodeNext,
226+
esModuleInterop: true,
227+
allowSyntheticDefaultImports: true,
228+
resolveJsonModule: true,
229+
jsx: tsCompiler.JsxEmit.ReactJSX,
230+
},
231+
}).outputText;
232+
}
233+
234+
writeFileSync(compiledFile, output, { encoding: 'utf-8' });
235+
return compiledFile;
236+
};
237+
238+
return {
239+
entryFile: compileFile(entryFile),
240+
cleanup() {
241+
rmSync(tempDir, {
242+
recursive: true,
243+
force: true,
244+
});
245+
},
246+
};
247+
}
248+
89249
async function importWithSpecifierFallback(
90250
p: string,
91251
fileUrl: URL,
@@ -96,26 +256,19 @@ async function importWithSpecifierFallback(
96256
} catch (originErr) {
97257
const source = readFileSync(p, { encoding: 'utf-8' });
98258
const rewritten = rewriteEsmSourceWithSpecifierFallback(p, source);
99-
if (rewritten === source) {
259+
if (!shouldUseEsmFallback(originErr, p, rewritten, source)) {
100260
throw originErr;
101261
}
102262

103-
const tmpFile = `${p}.mw-esm-fallback-${Date.now()}-${Math.random()
104-
.toString(36)
105-
.slice(2)}.ts`;
106-
writeFileSync(tmpFile, rewritten, { encoding: 'utf-8' });
263+
const fallbackGraph = createCompiledEsmFallbackGraph(p);
107264
try {
108-
const tmpUrl = pathToFileURL(tmpFile);
265+
const tmpUrl = pathToFileURL(fallbackGraph.entryFile);
109266
if (importQuery) {
110267
tmpUrl.searchParams.set('mwImportQuery', importQuery);
111268
}
112269
return await import(tmpUrl.href);
113270
} finally {
114-
try {
115-
unlinkSync(tmpFile);
116-
} catch {
117-
// ignore cleanup failure
118-
}
271+
fallbackGraph.cleanup();
119272
}
120273
}
121274
}
@@ -193,14 +346,14 @@ export const loadModule = async (
193346
if (options.loadMode === 'commonjs') {
194347
try {
195348
return require(p);
196-
} catch (_) {
349+
} catch {
197350
for (const extraPath of [
198351
process.cwd(),
199352
...(options.extraModuleRoot || []),
200353
]) {
201354
try {
202355
return require(require.resolve(p, { paths: [extraPath] }));
203-
} catch (_) {
356+
} catch {
204357
// do nothing
205358
}
206359
}
@@ -485,7 +638,7 @@ export const transformRequestObjectByType = (originValue: any, targetType?) => {
485638

486639
export function toPathMatch(pattern) {
487640
if (typeof pattern === 'boolean') {
488-
return ctx => pattern;
641+
return () => pattern;
489642
}
490643
if (typeof pattern === 'string') {
491644
const reg = PathToRegexpUtil.toRegexp(pattern.replace('*', '(.*)'));
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { createRequire } from 'module';
2+
import assert from 'assert';
3+
4+
const require = createRequire(import.meta.url);
5+
const { loadModule } = require('../../../dist/');
6+
7+
const mod = await loadModule(
8+
new URL('./reexport-ts-entry.ts', import.meta.url).pathname,
9+
{
10+
loadMode: 'esm',
11+
}
12+
);
13+
14+
assert(mod.User.name === 'User');
15+
16+
const packageMod = await loadModule(
17+
new URL('./package-import-entry.ts', import.meta.url).pathname,
18+
{
19+
loadMode: 'esm',
20+
}
21+
);
22+
23+
assert(typeof packageMod.version === 'string');
24+
process.send('ready');
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import ts from 'typescript';
2+
3+
export const version = ts.version;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export class User {}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { User } from './reexport-ts-dep.js';

packages/core/test/util/util.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,26 @@ describe('/test/util/util.test.ts', () => {
6060
await sleep(1000);
6161
});
6262

63+
it('should fallback relative js specifier to ts file in esm mode', async () => {
64+
let child = fork('esm-fallback.mjs', [], {
65+
cwd: join(__dirname, './esm-fixtures'),
66+
});
67+
68+
child.on('close', code => {
69+
if (code !== 0) {
70+
console.log(`process exited with code ${code}`);
71+
}
72+
});
73+
74+
await new Promise<void>(resolve => {
75+
child.on('message', ready => {
76+
if (ready === 'ready') {
77+
resolve();
78+
}
79+
});
80+
});
81+
});
82+
6383
it('should safeGet be ok', () => {
6484
const fn = safelyGet(['a', 'b']);
6585
assert.deepEqual(2, fn({a: {b: 2}}), 'safelyGet one argument not ok');

packages/mock/src/rspack.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,10 @@ export function devPlugin(options: DevPluginOptions) {
6262
const resolvedBaseDir = baseDir;
6363
const basePath = options.basePath || '/api';
6464
const watchInclude = options.watch?.include || [/\.(ts|tsx|js|mjs|cjs)$/];
65-
const watchExclude = options.watch?.exclude || [/\.d\.ts$/];
65+
const watchExclude = options.watch?.exclude || [
66+
/\.d\.ts$/,
67+
/\/\.midway-esm-fallback-[^/]+\/.+$/,
68+
];
6669
const getRequestHandler =
6770
options.getRequestHandler || getDefaultRequestHandler;
6871
const hmrImportQueryEnvKey = 'MIDWAY_HMR_IMPORT_QUERY';

packages/mock/src/vite.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,10 @@ export function devPlugin(options: DevPluginOptions) {
8282
const resolvedBaseDir = baseDir;
8383
const basePath = options.basePath || '/api';
8484
const watchInclude = options.watch?.include || [/\.(ts|tsx|js|mjs|cjs)$/];
85-
const watchExclude = options.watch?.exclude || [/\.d\.ts$/];
85+
const watchExclude = options.watch?.exclude || [
86+
/\.d\.ts$/,
87+
/\/\.midway-esm-fallback-[^/]+\/.+$/,
88+
];
8689
const getRequestHandler =
8790
options.getRequestHandler || getDefaultRequestHandler;
8891
const routeManifestOptions =

0 commit comments

Comments
 (0)