Skip to content

Commit acf3d8f

Browse files
fix: support Node 20.11/21.2 import.meta paths
## Problem - Node officially exposes `import.meta.dirname` and `import.meta.filename` for local `file:` ESM starting in Node v20.11.0 and v21.2.0 ([nodejs/node#48740](nodejs/node#48740), [v20.11.0 docs](https://github.com/nodejs/node/blob/v20.11.0/doc/api/esm.md#L328-L362)). - tsx's CJS transform only preserved `import.meta.url`, so transformed modules on those Node versions saw `dirname` / `filename` as missing and `Object.hasOwn(import.meta, ...)` stayed false. - The failure shows up when tsx transforms an ESM file to CJS, including direct `.js` execution and CJS `require()` loading an ESM-shaped `.js` file. ```js // direct.js console.log({ url: import.meta.url, // before and after: file:///.../direct.js dirname: import.meta.dirname, // before: undefined; after on Node v20.11.0+ / v21.2.0+: /... filename: import.meta.filename, // before: undefined; after on Node v20.11.0+ / v21.2.0+: /.../direct.js }); // require.cjs require('./required.js'); // required.js console.log({ url: import.meta.url, // before and after: file:///.../required.js dirname: import.meta.dirname, // before: undefined; after on Node v20.11.0+ / v21.2.0+: /... filename: import.meta.filename, // before: undefined; after on Node v20.11.0+ / v21.2.0+: /.../required.js }); export const loaded = true; ``` ## Changes - Defines the whole `import.meta` object through esbuild for CJS transforms that actually reference `import.meta`, instead of patching transformed output after source maps are generated. - Preserves practical object access patterns: `import.meta.url`, `const meta = import.meta`, destructuring, computed property reads, and property descriptors. - Keeps `dirname` / `filename` gated to Node-supported versions in this major because exposing new properties on older Node could break user shims; a next major can backport them for all transformed CJS. - Adds coverage for helper-name collisions and source-map column preservation so the fix does not reintroduce shadowing bugs or post-transform map drift. - Keeps the PR scoped to path metadata; it does not try to emulate `import.meta.resolve`, `import.meta.main`, or native null-prototype `import.meta` shape.
1 parent 4bbef80 commit acf3d8f

5 files changed

Lines changed: 218 additions & 14 deletions

File tree

src/utils/node-features.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,13 @@ export const esmLoadReadFile: Version[] = [
9090
[21, 3, 0],
9191
];
9292

93+
// https://github.com/nodejs/node/pull/48740
94+
// https://github.com/nodejs/node/blob/v20.11.0/doc/api/esm.md#L328-L362
95+
export const importMetaPathProperties: Version[] = [
96+
[20, 11, 0],
97+
[21, 2, 0],
98+
];
99+
93100
// https://github.com/nodejs/node/pull/55085
94101
export const requireEsm: Version[] = [
95102
[20, 19, 0],

src/utils/transform/index.ts

Lines changed: 35 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import path from 'node:path';
12
import { pathToFileURL, fileURLToPath } from 'node:url';
23
import {
34
transform as esbuildTransform,
@@ -7,6 +8,7 @@ import {
78
type TransformFailure,
89
} from 'esbuild';
910
import { sha1 } from '../sha1.js';
11+
import { importMetaPathProperties, isFeatureSupported } from '../node-features.js';
1012
import {
1113
version as transformDynamicImportVersion,
1214
transformDynamicImport,
@@ -33,14 +35,30 @@ const formatEsbuildError = (
3335
throw error;
3436
};
3537

38+
const getImportMeta = (
39+
filePath: string,
40+
url: string,
41+
) => ({
42+
...(
43+
// Keep dirname/filename aligned to Node in this major because exposing
44+
// new properties can break user shims. In the next major, tsx can
45+
// backport them because transformed CJS already owns import.meta.
46+
isFeatureSupported(importMetaPathProperties)
47+
? {
48+
dirname: path.dirname(filePath),
49+
filename: filePath,
50+
}
51+
: {}
52+
),
53+
url,
54+
});
55+
3656
// Used by cjs-loader
3757
export const transformSync = (
3858
code: string,
3959
filePathOrUrl: string,
4060
extendOptions?: TransformOptions,
4161
): Transformed => {
42-
const define: { [key: string]: string } = {};
43-
4462
let url: string;
4563
let filePath: string;
4664
let query: string | undefined;
@@ -54,31 +72,34 @@ export const transformSync = (
5472
url = pathToFileURL(filePath) + (query ? `?${query}` : '');
5573
}
5674

57-
if (
58-
!(
59-
filePath.endsWith('.cjs')
60-
|| filePath.endsWith('.cts')
61-
)
62-
) {
63-
define['import.meta.url'] = JSON.stringify(url);
64-
}
65-
66-
const esbuildOptions = {
75+
const esbuildOptions: TransformOptions = {
6776
...cacheConfig,
6877
format: 'cjs',
6978
sourcefile: filePath,
70-
define,
7179
banner: `__filename=${JSON.stringify(filePath)};(()=>{`,
7280
footer: '})()',
7381

7482
// CJS Annotations for Node. Used by ESM loader for CJS interop
7583
platform: 'node',
7684

7785
...extendOptions,
78-
} as const;
86+
};
87+
88+
if (
89+
code.includes('import.meta')
90+
&& esbuildOptions.format === 'cjs'
91+
&& !filePath.endsWith('.cjs')
92+
&& !filePath.endsWith('.cts')
93+
) {
94+
esbuildOptions.define = {
95+
...esbuildOptions.define,
96+
'import.meta': JSON.stringify(getImportMeta(filePath, url)),
97+
};
98+
}
7999

80100
const hash = sha1([
81101
code,
102+
url,
82103
JSON.stringify(esbuildOptions),
83104
esbuildVersion,
84105
transformDynamicImportVersion,

tests/specs/transform.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { describe, test, expect } from 'manten';
22
import { createFsRequire } from 'fs-require';
3+
import { createFixture } from 'fs-fixture';
4+
import { execaNode } from 'execa';
35
import { Volume } from 'memfs';
46
import outdent from 'outdent';
57
import { transform, transformSync } from '../../src/utils/transform/index.js';
8+
import { inlineSourceMap } from '../../src/source-map.js';
69

710
const base64Module = (code: string) => `data:text/javascript;base64,${Buffer.from(code).toString('base64')}`;
811

@@ -62,6 +65,103 @@ export const transformSpec = () => describe('transform', () => {
6265
});
6366
});
6467

68+
test('import.meta helper cannot be shadowed by user bindings', () => {
69+
const transformed = transformSync(
70+
`
71+
const define_import_meta_default = { url: 'shadowed' };
72+
export const url = import.meta.url;
73+
export const local = define_import_meta_default.url;
74+
`,
75+
'file.js',
76+
{ format: 'cjs' },
77+
);
78+
79+
const fsRequire = createFsRequire(Volume.fromJSON({
80+
'/file.js': transformed.code,
81+
}));
82+
83+
expect(fsRequire('/file.js')).toEqual({
84+
local: 'shadowed',
85+
url: expect.stringMatching(/^file:\/\/\/.*\/file.js$/),
86+
});
87+
});
88+
89+
test('doesnt emit import.meta helper without import.meta syntax', () => {
90+
const transformed = transformSync(
91+
`
92+
// import.meta.url
93+
const text = 'import.meta.url';
94+
export const value = text;
95+
`,
96+
'file.js',
97+
{ format: 'cjs' },
98+
);
99+
100+
expect(transformed.code).not.toContain('import_meta');
101+
});
102+
103+
test('supports import.meta object access', () => {
104+
const transformed = transformSync(
105+
`
106+
export const meta = import.meta;
107+
export const computed = import.meta['url'];
108+
export const destructured = (() => {
109+
const { url } = import.meta;
110+
return url;
111+
})();
112+
export const prototype = Object.getPrototypeOf(import.meta);
113+
export const hasProto = '__proto__' in import.meta;
114+
export const urlDescriptor = Object.getOwnPropertyDescriptor(import.meta, 'url');
115+
`,
116+
'file.js',
117+
{ format: 'cjs' },
118+
);
119+
const loaded = createFsRequire(Volume.fromJSON({
120+
'/file.js': transformed.code,
121+
}))('/file.js');
122+
123+
expect(loaded.meta.url).toMatch(/^file:\/\/\/.*\/file.js$/);
124+
expect(loaded.computed).toMatch(/^file:\/\/\/.*\/file.js$/);
125+
expect(loaded.destructured).toMatch(/^file:\/\/\/.*\/file.js$/);
126+
expect(loaded.urlDescriptor).toStrictEqual({
127+
configurable: true,
128+
enumerable: true,
129+
value: expect.stringMatching(/^file:\/\/\/.*\/file.js$/),
130+
writable: true,
131+
});
132+
133+
// TODO: Match native Node import.meta shape without adding another
134+
// source-map transform pass.
135+
// expect(Object.getPrototypeOf(loaded.meta)).toBe(null);
136+
// expect(loaded.prototype).toBe(null);
137+
// expect(loaded.hasProto).toBe(false);
138+
});
139+
140+
test('import.meta helper preserves source map columns', async () => {
141+
await using fixture = await createFixture({});
142+
const fileName = fixture.getPath('source-map-check.ts');
143+
const transformed = transformSync(
144+
outdent`
145+
export const meta = import.meta;
146+
const value = 'x';
147+
throw new Error('source-map-check');
148+
`,
149+
fileName,
150+
{ format: 'cjs' },
151+
);
152+
await fixture.writeFile('source-map-check.cjs', inlineSourceMap(transformed));
153+
154+
const { exitCode, stderr } = await execaNode(
155+
fixture.getPath('source-map-check.cjs'),
156+
{
157+
nodeOptions: ['--enable-source-maps'],
158+
reject: false,
159+
},
160+
);
161+
expect(exitCode).toBe(1);
162+
expect(stderr).toContain(`${fileName}:3:7`);
163+
});
164+
65165
test('dynamic import', () => {
66166
const dynamicImport = transformSync(
67167
'import((0, _url.pathToFileURL)(path).href)',

tests/specs/version-sensitive.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
import path from 'node:path';
12
import { setTimeout } from 'node:timers/promises';
3+
import { pathToFileURL } from 'node:url';
24
import {
35
describe, test, expect,
46
} from 'manten';
@@ -181,6 +183,77 @@ export const versionSensitiveTests = (node: NodeApis) => describe('Version-sensi
181183
expect(tsxProcess.stderr).toBe('');
182184
});
183185

186+
test('import.meta path properties follow Node file module support', async () => {
187+
await using fixture = await createFixture({
188+
'direct.js': `
189+
console.log(JSON.stringify({
190+
dirname: import.meta.dirname,
191+
filename: import.meta.filename,
192+
ownsDirname: Object.hasOwn(import.meta, 'dirname'),
193+
ownsFilename: Object.hasOwn(import.meta, 'filename'),
194+
ownsUrl: Object.hasOwn(import.meta, 'url'),
195+
url: import.meta.url,
196+
}));
197+
`,
198+
'require.cjs': 'require("./required.js");',
199+
'required.js': `
200+
console.log(JSON.stringify({
201+
dirname: import.meta.dirname,
202+
filename: import.meta.filename,
203+
ownsDirname: Object.hasOwn(import.meta, 'dirname'),
204+
ownsFilename: Object.hasOwn(import.meta, 'filename'),
205+
ownsUrl: Object.hasOwn(import.meta, 'url'),
206+
url: import.meta.url,
207+
}));
208+
export const loaded = true;
209+
`,
210+
});
211+
212+
const directProcess = await node.tsx(['direct.js'], fixture.path);
213+
expect(directProcess.failed).toBe(false);
214+
expect(directProcess.stderr).toBe('');
215+
const directFilePath = fixture.getPath('direct.js');
216+
if (node.supports.importMetaPathProperties) {
217+
expect(JSON.parse(directProcess.stdout)).toEqual({
218+
dirname: path.dirname(directFilePath),
219+
filename: directFilePath,
220+
ownsDirname: true,
221+
ownsFilename: true,
222+
ownsUrl: true,
223+
url: pathToFileURL(directFilePath).toString(),
224+
});
225+
} else {
226+
expect(JSON.parse(directProcess.stdout)).toEqual({
227+
ownsDirname: false,
228+
ownsFilename: false,
229+
ownsUrl: true,
230+
url: pathToFileURL(directFilePath).toString(),
231+
});
232+
}
233+
234+
const requireProcess = await node.tsx(['require.cjs'], fixture.path);
235+
expect(requireProcess.failed).toBe(false);
236+
expect(requireProcess.stderr).toBe('');
237+
const requiredFilePath = fixture.getPath('required.js');
238+
if (node.supports.importMetaPathProperties) {
239+
expect(JSON.parse(requireProcess.stdout)).toEqual({
240+
dirname: path.dirname(requiredFilePath),
241+
filename: requiredFilePath,
242+
ownsDirname: true,
243+
ownsFilename: true,
244+
ownsUrl: true,
245+
url: pathToFileURL(requiredFilePath).toString(),
246+
});
247+
} else {
248+
expect(JSON.parse(requireProcess.stdout)).toEqual({
249+
ownsDirname: false,
250+
ownsFilename: false,
251+
ownsUrl: true,
252+
url: pathToFileURL(requiredFilePath).toString(),
253+
});
254+
}
255+
});
256+
184257
test('require(esm) support controls extensionless .mjs resolution', async () => {
185258
await using fixture = await createFixture({
186259
'package.json': createPackageJson({ type: 'commonjs' }),

tests/utils/tsx.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
modulePackageMainResolution,
99
moduleRegister,
1010
moduleRegisterHooksCjsReload,
11+
importMetaPathProperties,
1112
testRunnerGlob,
1213
requireEsmExtensionlessMjs,
1314
requireEsm,
@@ -89,6 +90,8 @@ export const createNode = async (
8990

9091
moduleRegisterHooksCjsReload: isFeatureSupported(moduleRegisterHooksCjsReload, versionParsed),
9192

93+
importMetaPathProperties: isFeatureSupported(importMetaPathProperties, versionParsed),
94+
9295
testRunnerGlob: isFeatureSupported(testRunnerGlob, versionParsed),
9396

9497
// https://nodejs.org/docs/latest-v18.x/api/cli.html#--test

0 commit comments

Comments
 (0)