Skip to content

Commit ec316d3

Browse files
feat: support latest Node versions
1 parent cb09f00 commit ec316d3

File tree

15 files changed

+491
-55
lines changed

15 files changed

+491
-55
lines changed

src/cjs/api/module-resolve-filename/resolve-ts-extensions.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
} from '../../../utils/path-utils.js';
77
import { allowJs } from '../../../utils/tsconfig.js';
88
import type { SimpleResolve } from '../types.js';
9-
import { logCjs } from '../../../utils/debug.js';
9+
import { logCjs as log } from '../../../utils/debug.js';
1010

1111
/**
1212
* Typescript gives .ts, .cts, or .mts priority over actual .js, .cjs, or .mjs extensions
@@ -16,7 +16,12 @@ const resolveTsFilename = (
1616
request: string,
1717
isTsParent: boolean,
1818
) => {
19-
logCjs('resolveTsFilename', request);
19+
log('resolveTsFilename', {
20+
request,
21+
isDirectory: isDirectoryPattern.test(request),
22+
isTsParent,
23+
allowJs,
24+
});
2025
if (
2126
isDirectoryPattern.test(request)
2227
|| (!isTsParent && !allowJs)
@@ -50,7 +55,7 @@ export const createTsExtensionResolver = (
5055
): SimpleResolve => (
5156
request,
5257
) => {
53-
logCjs('resolveTsFilename', {
58+
log('resolveTsFilename', {
5459
request,
5560
isTsParent,
5661
isFilePath: isFilePath(request),

src/esm/hook/load.ts

Lines changed: 194 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,23 @@ import type { TransformOptions } from 'esbuild';
66
import { transform, transformSync } from '../../utils/transform/index.js';
77
import { transformDynamicImport } from '../../utils/transform/transform-dynamic-import.js';
88
import { inlineSourceMap } from '../../source-map.js';
9-
import { isFeatureSupported, importAttributes, esmLoadReadFile } from '../../utils/node-features.js';
9+
import {
10+
isFeatureSupported,
11+
importAttributes,
12+
esmLoadReadFile,
13+
requireEsm,
14+
loadReadFromSource,
15+
} from '../../utils/node-features.js';
1016
import { parent } from '../../utils/ipc/client.js';
1117
import type { Message } from '../types.js';
1218
import { fileMatcher } from '../../utils/tsconfig.js';
1319
import { isJsonPattern, tsExtensionsPattern, fileUrlPrefix } from '../../utils/path-utils.js';
1420
import { isESM } from '../../utils/es-module-lexer.js';
1521
import { logEsm as log, debugEnabled } from '../../utils/debug.js';
16-
import { getNamespace } from './utils.js';
22+
import { getNamespace, decodeCjsQuery } from './utils.js';
1723
import { data } from './initialize.js';
1824

19-
const contextAttributesProperty = (
25+
const importAttributesProperty = (
2026
isFeatureSupported(importAttributes)
2127
? 'importAttributes'
2228
: 'importAssertions' as 'importAttributes'
@@ -32,6 +38,9 @@ let load: LoadHook = async (
3238
return nextLoad(url, context);
3339
}
3440

41+
// TODO: Add version
42+
url = decodeCjsQuery(url);
43+
3544
const urlNamespace = getNamespace(url);
3645
if (data.namespace && data.namespace !== urlNamespace) {
3746
return nextLoad(url, context);
@@ -57,11 +66,121 @@ let load: LoadHook = async (
5766
});
5867
}
5968

69+
const filePath = url.startsWith(fileUrlPrefix) ? fileURLToPath(url) : url;
70+
71+
if (context.format === 'module-json') {
72+
const code = await readFile(new URL(url), 'utf8');
73+
const transformed = await transform(
74+
code,
75+
filePath,
76+
{
77+
tsconfigRaw: (
78+
path.isAbsolute(filePath)
79+
? fileMatcher?.(filePath) as TransformOptions['tsconfigRaw']
80+
: undefined
81+
),
82+
},
83+
);
84+
return {
85+
shortCircuit: true,
86+
format: 'module',
87+
source: inlineSourceMap(transformed),
88+
};
89+
}
90+
91+
if (context.format === 'commonjs-json') {
92+
const code = await readFile(new URL(url), 'utf8');
93+
const transformed = transformSync(
94+
code,
95+
filePath,
96+
{
97+
tsconfigRaw: (
98+
path.isAbsolute(filePath)
99+
? fileMatcher?.(filePath) as TransformOptions['tsconfigRaw']
100+
: undefined
101+
),
102+
},
103+
);
104+
105+
const jsonExportKeys = Object.keys(JSON.parse(code));
106+
transformed.code += `\n0 && (module.exports = {${jsonExportKeys.join(',')}});`;
107+
108+
return {
109+
shortCircuit: true,
110+
format: 'commonjs',
111+
source: inlineSourceMap(transformed),
112+
};
113+
}
114+
115+
/**
116+
* For compiling ambiguous ESM (ESM syntax in a package without type)
117+
* So Node.js can kind of do this now, but it tries CommonJS first, and if it fails,
118+
* it uses NATIVE ESM. This means ESM code that uses mixed syntax (e.g. __dirname)
119+
* wil not work.
120+
*/
121+
if (
122+
(
123+
context.format === undefined
124+
|| context.format === 'commonjs'
125+
|| context.format === 'commonjs-typescript'
126+
)
127+
&& isFeatureSupported(esmLoadReadFile)
128+
&& url.startsWith('file:') // Could be data:
129+
&& filePath.endsWith('.js')
130+
) {
131+
const code = await readFile(new URL(url), 'utf8');
132+
133+
// if the file extension is .js, only transform if using esm syntax
134+
if (isESM(code)) {
135+
/**
136+
* es or cjs module lexer unfortunately cannot be used because it doesn't support
137+
* typescript syntax
138+
*
139+
* While the full code is transformed, only the exports are used for parsing.
140+
* In fact, the code can't even run because imports cannot be resolved relative
141+
* from the data: URL.
142+
*
143+
* This should pre-compile for the CJS loader to have a cache hit
144+
*
145+
* I considered extracting the CJS exports from esbuild via (0&&(module.exports={})
146+
* to minimize the data URL size but this only works for ESM->CJS and not CTS files
147+
* which are already in CJS syntax.
148+
* In CTS, module.exports can be written in any pattern.
149+
*/
150+
const transformed = transformSync(
151+
code,
152+
url,
153+
{
154+
tsconfigRaw: fileMatcher?.(filePath) as TransformOptions['tsconfigRaw'],
155+
},
156+
);
157+
158+
if (isFeatureSupported(loadReadFromSource)) {
159+
return {
160+
shortCircuit: true,
161+
format: 'commonjs',
162+
163+
// This is necessary for CJS exports to be parsed correctly
164+
// Returning a `source` makes the ESM translator to handle
165+
// the CJS compilation and skips the CJS loader
166+
source: inlineSourceMap(transformed),
167+
};
168+
}
169+
170+
const filePathWithNamespace = urlNamespace ? `${filePath}?namespace=${encodeURIComponent(urlNamespace)}` : filePath;
171+
return {
172+
shortCircuit: true,
173+
format: 'commonjs',
174+
responseURL: `data:text/javascript,${encodeURIComponent(transformed.code)}?filePath=${encodeURIComponent(filePathWithNamespace)}`,
175+
};
176+
}
177+
}
178+
60179
if (isJsonPattern.test(url)) {
61-
let contextAttributes = context[contextAttributesProperty];
180+
let contextAttributes = context[importAttributesProperty];
62181
if (!contextAttributes) {
63182
contextAttributes = {};
64-
context[contextAttributesProperty] = contextAttributes;
183+
context[importAttributesProperty] = contextAttributes;
65184
}
66185

67186
if (!contextAttributes.type) {
@@ -75,7 +194,21 @@ let load: LoadHook = async (
75194
loaded,
76195
});
77196

78-
const filePath = url.startsWith(fileUrlPrefix) ? fileURLToPath(url) : url;
197+
if (
198+
isFeatureSupported(loadReadFromSource)
199+
&& loaded.format === 'commonjs'
200+
&& filePath.endsWith('.cjs')
201+
) {
202+
let code = await readFile(new URL(url), 'utf8');
203+
// Contains native ESM check
204+
const transformed = transformDynamicImport(filePath, code);
205+
if (transformed) {
206+
code = inlineSourceMap(transformed);
207+
}
208+
loaded.source = code;
209+
loaded.shortCircuit = true;
210+
return loaded;
211+
}
79212

80213
if (
81214
loaded.format === 'commonjs'
@@ -86,7 +219,13 @@ let load: LoadHook = async (
86219
const code = await readFile(new URL(url), 'utf8');
87220

88221
// if the file extension is .js, only transform if using esm syntax
89-
if (!filePath.endsWith('.js') || isESM(code)) {
222+
if (
223+
// TypeScript files
224+
!filePath.endsWith('.js')
225+
226+
// ESM syntax in CommonJS type package
227+
|| isESM(code)
228+
) {
90229
/**
91230
* es or cjs module lexer unfortunately cannot be used because it doesn't support
92231
* typescript syntax
@@ -104,15 +243,26 @@ let load: LoadHook = async (
104243
*/
105244
const transformed = transformSync(
106245
code,
107-
filePath,
246+
url,
108247
{
109248
tsconfigRaw: fileMatcher?.(filePath) as TransformOptions['tsconfigRaw'],
110249
},
111250
);
112251

113-
const filePathWithNamespace = urlNamespace ? `${filePath}?namespace=${encodeURIComponent(urlNamespace)}` : filePath;
114-
115-
loaded.responseURL = `data:text/javascript,${encodeURIComponent(transformed.code)}?filePath=${encodeURIComponent(filePathWithNamespace)}`;
252+
if (isFeatureSupported(loadReadFromSource)) {
253+
/**
254+
* Compile ESM to CJS
255+
* In v22.15, the CJS loader logic is now moved to the ESM loader
256+
*/
257+
loaded.source = inlineSourceMap(transformed);
258+
} else {
259+
/**
260+
* This tricks Node into thinking the file is a data URL so it doesn't try to read from disk
261+
* to parse the CJS exports
262+
*/
263+
const filePathWithNamespace = urlNamespace ? `${filePath}?namespace=${encodeURIComponent(urlNamespace)}` : filePath;
264+
loaded.responseURL = `data:text/javascript,${encodeURIComponent(transformed.code)}?filePath=${encodeURIComponent(filePathWithNamespace)}`;
265+
}
116266

117267
log('returning CJS export annotation', loaded);
118268
return loaded;
@@ -126,8 +276,40 @@ let load: LoadHook = async (
126276

127277
const code = loaded.source.toString();
128278

279+
// Since CJS can now require ESM, JSONs are now handled by the
280+
// ESM loader as ESM in module contexts
281+
// TODO: If we can detect whether this was "required",
282+
// we can let the CJS loader handler it by returning an empty source
283+
// Support named imports in JSON modules
284+
/**
285+
* In versions of Node that supports require'ing ESM,
286+
*/
287+
if (
288+
isFeatureSupported(requireEsm)
289+
&& loaded.format === 'json'
290+
) {
291+
const transformed = transformSync(
292+
code,
293+
filePath,
294+
{
295+
tsconfigRaw: (
296+
path.isAbsolute(filePath)
297+
? fileMatcher?.(filePath) as TransformOptions['tsconfigRaw']
298+
: undefined
299+
),
300+
},
301+
);
302+
303+
const jsonExportKeys = Object.keys(JSON.parse(code));
304+
transformed.code += `\n0 && (module.exports = {${jsonExportKeys.join(',')}});`;
305+
306+
return {
307+
format: 'commonjs',
308+
source: inlineSourceMap(transformed),
309+
};
310+
}
311+
129312
if (
130-
// Support named imports in JSON modules
131313
loaded.format === 'json'
132314
|| tsExtensionsPattern.test(url)
133315
) {

0 commit comments

Comments
 (0)