Skip to content

Commit d07cba2

Browse files
authored
add Node.js v20 support (#28)
* add Node.js v20 support Since Node.js v20 moves loaders to a separate thread, we can no longer depend on loading the modules in the loader to get the exports. This is needed to add our mutable proxy. For Node.js 20, exports are now retrieved via parsing. To reduce startup overhead on older versions of Node.js, the previous method of getting exports is used, avoiding loading and using parsers. * make stacked loaders work * restrict get-esm-exports test to node 20 * remove 'unsupported' * normalize file URLs to paths (important on windows) * remove trailing \r in esm exports test
1 parent b49545c commit d07cba2

File tree

7 files changed

+230
-30
lines changed

7 files changed

+230
-30
lines changed

.github/workflows/ci.yml

Lines changed: 2 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010

1111
strategy:
1212
matrix:
13-
node-version: [12.x, 14.x, 16.10.0, 16.16.0, 16.17.0, 16.x, 17.x, 18.5.0, 18.x]
13+
node-version: [12.x, 14.x, 16.10.0, 16.16.0, 16.17.0, 16.x, 17.x, 18.5.0, 18.x, 20.x]
1414

1515
steps:
1616
- uses: actions/checkout@v2
@@ -23,28 +23,12 @@ jobs:
2323
- run: npm run test:ts
2424
if: (matrix.node-version != '12.x' && matrix.node-version != '14.x' && matrix.node-version != '16.10.0')
2525

26-
build-unsupported:
27-
runs-on: ubuntu-latest
28-
29-
strategy:
30-
matrix:
31-
node-version: [20.x]
32-
33-
steps:
34-
- uses: actions/checkout@v2
35-
- name: Use Node.js ${{ matrix.node-version }}
36-
uses: actions/setup-node@v2
37-
with:
38-
node-version: ${{ matrix.node-version }}
39-
- run: npm install
40-
- run: npm test:unsupported
41-
4226
build-win:
4327
runs-on: windows-latest
4428

4529
strategy:
4630
matrix:
47-
node-version: [ 12.x, 14.x, 16.10.0, 16.16.0, 16.17.0, 16.x, 18.5.0, 18.x ]
31+
node-version: [12.x, 14.x, 16.10.0, 16.16.0, 16.17.0, 16.x, 18.5.0, 18.x, 20.x]
4832

4933
steps:
5034
- uses: actions/checkout@v2

hook.js

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ const NODE_MINOR = Number(NODE_VERSION[1])
1414

1515
let entrypoint
1616

17+
if (NODE_MAJOR >= 20) {
18+
getExports = require('./lib/get-exports.js')
19+
} else {
20+
getExports = (url) => import(url).then(Object.keys)
21+
}
22+
1723
function hasIitm (url) {
1824
try {
1925
return new URL(url).searchParams.has('iitm')
@@ -103,16 +109,16 @@ function createHook (meta) {
103109

104110
return {
105111
url: addIitm(url.url),
106-
shortCircuit: true
112+
shortCircuit: true,
113+
format: url.format
107114
}
108115
}
109116

110117
const iitmURL = new URL('lib/register.js', meta.url).toString()
111118
async function getSource (url, context, parentGetSource) {
112119
if (hasIitm(url)) {
113120
const realUrl = deleteIitm(url)
114-
const realModule = await import(realUrl)
115-
const exportNames = Object.keys(realModule)
121+
const exportNames = await getExports(realUrl, context, parentGetSource)
116122
return {
117123
source: `
118124
import { register } from '${iitmURL}'
@@ -137,7 +143,7 @@ register('${realUrl}', namespace, set, '${specifiers.get(realUrl)}')
137143
// For Node.js 16.12.0 and higher.
138144
async function load (url, context, parentLoad) {
139145
if (hasIitm(url)) {
140-
const { source } = await getSource(url, context)
146+
const { source } = await getSource(url, context, parentLoad)
141147
return {
142148
source,
143149
shortCircuit: true,
@@ -148,10 +154,7 @@ register('${realUrl}', namespace, set, '${specifiers.get(realUrl)}')
148154
return parentLoad(url, context, parentLoad)
149155
}
150156

151-
if (NODE_MAJOR >= 20) {
152-
process.emitWarning('import-in-the-middle is currently unsupported on Node.js v20 and has been disabled.')
153-
return {} // TODO: Add support for Node >=20
154-
} else if (NODE_MAJOR >= 17 || (NODE_MAJOR === 16 && NODE_MINOR >= 12)) {
157+
if (NODE_MAJOR >= 17 || (NODE_MAJOR === 16 && NODE_MINOR >= 12)) {
155158
return { load, resolve }
156159
} else {
157160
return {

lib/get-esm-exports.js

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
'use strict'
2+
3+
const { Parser } = require('acorn')
4+
const { importAssertions } = require('acorn-import-assertions');
5+
6+
const acornOpts = {
7+
ecmaVersion: 'latest',
8+
sourceType: 'module'
9+
}
10+
11+
const parser = Parser.extend(importAssertions)
12+
13+
function warn (txt) {
14+
process.emitWarning(txt, 'get-esm-exports')
15+
}
16+
17+
function getEsmExports (moduleStr) {
18+
const exportedNames = new Set()
19+
const tree = parser.parse(moduleStr, acornOpts)
20+
for (const node of tree.body) {
21+
if (!node.type.startsWith('Export')) continue
22+
switch (node.type) {
23+
case 'ExportNamedDeclaration':
24+
if (node.declaration) {
25+
parseDeclaration(node, exportedNames)
26+
} else {
27+
parseSpecifiers(node, exportedNames)
28+
}
29+
break
30+
case 'ExportDefaultDeclaration':
31+
exportedNames.add('default')
32+
break
33+
case 'ExportAllDeclaration':
34+
if (node.exported) {
35+
exportedNames.add(node.exported.name)
36+
} else {
37+
exportedNames.add('*')
38+
}
39+
break
40+
default:
41+
warn('unrecognized export type: ' + node.type)
42+
}
43+
}
44+
return Array.from(exportedNames)
45+
}
46+
47+
function parseDeclaration (node, exportedNames) {
48+
switch (node.declaration.type) {
49+
case 'FunctionDeclaration':
50+
exportedNames.add(node.declaration.id.name)
51+
break
52+
case 'VariableDeclaration':
53+
for (const varDecl of node.declaration.declarations) {
54+
parseVariableDeclaration(varDecl, exportedNames)
55+
}
56+
break
57+
case 'ClassDeclaration':
58+
exportedNames.add(node.declaration.id.name)
59+
break
60+
default:
61+
warn('unknown declaration type: ' + node.delcaration.type)
62+
}
63+
}
64+
65+
function parseVariableDeclaration (node, exportedNames) {
66+
switch (node.id.type) {
67+
case 'Identifier':
68+
exportedNames.add(node.id.name)
69+
break
70+
case 'ObjectPattern':
71+
for (const prop of node.id.properties) {
72+
exportedNames.add(prop.value.name)
73+
}
74+
break
75+
case 'ArrayPattern':
76+
for (const elem of node.id.elements) {
77+
exportedNames.add(elem.name)
78+
}
79+
break
80+
default:
81+
warn('unknown variable declaration type: ' + node.id.type)
82+
}
83+
}
84+
85+
function parseSpecifiers (node, exportedNames) {
86+
for (const specifier of node.specifiers) {
87+
if (specifier.exported.type === 'Identifier') {
88+
exportedNames.add(specifier.exported.name)
89+
} else if (specifier.exported.type === 'Literal') {
90+
exportedNames.add(specifier.exported.value)
91+
} else {
92+
warn('unrecognized specifier type: ' + specifier.exported.type)
93+
}
94+
}
95+
}
96+
97+
module.exports = getEsmExports

lib/get-exports.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use strict'
2+
3+
const getEsmExports = require('./get-esm-exports.js')
4+
const { parse: getCjsExports } = require('cjs-module-lexer')
5+
const fs = require('fs')
6+
const { fileURLToPath } = require('url')
7+
8+
function addDefault(arr) {
9+
return Array.from(new Set(['default', ...arr]))
10+
}
11+
12+
async function getExports (url, context, parentLoad) {
13+
// `parentLoad` gives us the possibility of getting the source
14+
// from an upstream loader. This doesn't always work though,
15+
// so later on we fall back to reading it from disk.
16+
const parentCtx = await parentLoad(url, context)
17+
let source = parentCtx.source
18+
const format = parentCtx.format
19+
20+
// TODO support non-node/file urls somehow?
21+
if (format === 'builtin') {
22+
// Builtins don't give us the source property, so we're stuck
23+
// just requiring it to get the exports.
24+
return addDefault(Object.keys(require(url)))
25+
}
26+
27+
if (!source) {
28+
// Sometimes source is retrieved by parentLoad, sometimes it isn't.
29+
source = fs.readFileSync(fileURLToPath(url), 'utf8')
30+
}
31+
32+
if (format === 'module') {
33+
return getEsmExports(source)
34+
}
35+
if (format === 'commonjs') {
36+
return addDefault(getCjsExports(source).exports)
37+
}
38+
39+
// At this point our `format` is either undefined or not known by us. Fall
40+
// back to parsing as ESM/CJS.
41+
const esmExports = getEsmExports(source)
42+
if (!esmExports.length) {
43+
// TODO(bengl) it's might be possible to get here if somehow the format
44+
// isn't set at first and yet we have an ESM module with no exports.
45+
// I couldn't construct an example that would do this, so maybe it's
46+
// impossible?
47+
return addDefault(getCjsExports(source).exports)
48+
}
49+
}
50+
51+
module.exports = getExports

package.json

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@
44
"description": "Intercept imports in Node.js",
55
"main": "index.js",
66
"scripts": {
7-
"test": "c8 --check-coverage --lines 85 imhotap --runner test/runtest --files test/{hook,low-level,other}/*",
8-
"test:unsupported": "imhotap --runner test/runtest --files test/hook/loader.mjs",
9-
"test-win": "c8 --check-coverage --lines 85 imhotap --runner test\\runtest.bat --files test/{hook,low-level,other}/*",
7+
"test": "c8 --check-coverage --lines 85 imhotap --runner test/runtest --files test/{hook,low-level,other,get-esm-exports}/*",
8+
"test-win": "c8 --check-coverage --lines 85 imhotap --runner test\\runtest.bat --files test/{hook,low-level,other,get-esm-exports}/*",
109
"test:ts": "c8 imhotap --runner test/runtest --files test/typescript/*.test.mts",
1110
"test-win:ts": "c8 imhotap --runner test\\runtest.bat --files test/typescript/*.test.mts",
12-
"coverage": "c8 --reporter html imhotap --runner test/runtest --files test/{hook,low-level,other}/* && echo '\nNow open coverage/index.html\n'"
11+
"coverage": "c8 --reporter html imhotap --runner test/runtest --files test/{hook,low-level,other,get-esm-exports}/* && echo '\nNow open coverage/index.html\n'"
1312
},
1413
"repository": {
1514
"type": "git",
@@ -37,6 +36,9 @@
3736
"typescript": "^4.7.4"
3837
},
3938
"dependencies": {
39+
"acorn": "^8.8.2",
40+
"acorn-import-assertions": "^1.9.0",
41+
"cjs-module-lexer": "^1.2.2",
4042
"module-details-from-path": "^1.0.3"
4143
}
4244
}

test/fixtures/esm-exports.txt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Exporting declarations
2+
export let name1, name2/*, … */; // also var //| name1,name2
3+
export const name1 = 1, name2 = 2/*, … */; // also var, let //| name1,name2
4+
export function functionName() { /* … */ } //| functionName
5+
export class ClassName { /* … */ } //| ClassName
6+
export function* generatorFunctionName() { /* … */ } //| generatorFunctionName
7+
export const { name1, name2: bar } = o; //| name1,bar
8+
export const [ name1, name2 ] = array; //| name1,name2
9+
10+
// Export list
11+
let name1, nameN; export { name1, /* …, */ nameN }; //| name1,nameN
12+
let variable1, variable2, nameN; export { variable1 as name1, variable2 as name2, /* …, */ nameN }; //| name1,name2,nameN
13+
let variable1; export { variable1 as "string name" }; //| string name
14+
let name1; export { name1 as default /*, … */ }; //| default
15+
16+
// Default exports
17+
export default expression; //| default
18+
export default function functionName() { /* … */ } //| default
19+
export default class ClassName { /* … */ } //| default
20+
export default function* generatorFunctionName() { /* … */ } //| default
21+
export default function () { /* … */ } //| default
22+
export default class { /* … */ } //| default
23+
export default function* () { /* … */ } //| default
24+
25+
// Aggregating modules
26+
export * from "module-name"; //| *
27+
export * as name1 from "module-name"; //| name1
28+
export { name1, /* …, */ nameN } from "module-name"; //| name1,nameN
29+
export { import1 as name1, import2 as name2, /* …, */ nameN } from "module-name"; //| name1,name2,nameN
30+
export { default, /* …, */ } from "module-name"; //| default
31+
export { default as name1 } from "module-name"; //| name1
32+
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
'use strict'
2+
3+
const getEsmExports = require('../../lib/get-esm-exports.js')
4+
const fs = require('fs')
5+
const assert = require('assert')
6+
const path = require('path')
7+
8+
const fixturePath = path.join(__dirname, '../fixtures/esm-exports.txt')
9+
const fixture = fs.readFileSync(fixturePath, 'utf8')
10+
11+
fixture.split('\n').forEach(line => {
12+
if (!line.includes(' //| ')) return
13+
const [mod, testStr] = line.split(' //| ')
14+
const expectedNames = testStr.split(',').map(x => x.trim())
15+
if (expectedNames[0] === '') {
16+
expectedNames.length = 0
17+
}
18+
const names = getEsmExports(mod)
19+
assert.deepEqual(expectedNames, names)
20+
console.log(`${mod}\n ✅ contains exports: ${testStr}`)
21+
})
22+
23+
// // Generate fixture data
24+
// fixture.split('\n').forEach(line => {
25+
// if (!line.includes('export ')) {
26+
// console.log(line)
27+
// return
28+
// }
29+
// const names = getEsmExports(line)
30+
// console.log(line, '//|', names.join(','))
31+
// })

0 commit comments

Comments
 (0)