Skip to content

Commit 74124e9

Browse files
mohd-akramtimfish
andauthored
fix: handling of default and star exports (#85)
Closes #68, Closes #77, Closes #62, Closes #60 Co-authored-by: Tim Fish <[email protected]>
1 parent 7fa4e9c commit 74124e9

20 files changed

+153
-217
lines changed

hook.js

Lines changed: 39 additions & 151 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
//
33
// This product includes software developed at Datadog (https://www.datadoghq.com/). Copyright 2021 Datadog, Inc.
44

5-
const { randomBytes } = require('crypto')
65
const { URL } = require('url')
76
const specifiers = new Map()
87
const isWin = process.platform === 'win32'
@@ -20,7 +19,7 @@ let getExports
2019
if (NODE_MAJOR >= 20 || (NODE_MAJOR === 18 && NODE_MINOR >= 19)) {
2120
getExports = require('./lib/get-exports.js')
2221
} else {
23-
getExports = ({ url }) => import(url).then(Object.keys)
22+
getExports = (url) => import(url).then(Object.keys)
2423
}
2524

2625
function hasIitm (url) {
@@ -117,70 +116,37 @@ function isBareSpecifier (specifier) {
117116
}
118117

119118
/**
120-
* @typedef {object} ProcessedModule
121-
* @property {string[]} imports A set of ESM import lines to be added to the
122-
* shimmed module source.
123-
* @property {string[]} namespaces A set of identifiers representing the
124-
* modules in `imports`, e.g. for `import * as foo from 'bar'`, "foo" will be
125-
* present in this array.
126-
* @property {Map<string, string>} setters The shimmed setters for all the
127-
* exports from the module and any transitive export all modules. The key is
128-
* used to deduplicate conflicting exports, assigning a priority to `default`
129-
* exports.
130-
*/
131-
132-
/**
133-
* Processes a module's exports and builds a set of new import statements,
134-
* namespace names, and setter blocks. If an export all export if encountered,
135-
* the target exports will be hoisted to the current module via a generated
136-
* namespace.
119+
* Processes a module's exports and builds a set of setter blocks.
137120
*
138121
* @param {object} params
139122
* @param {string} params.srcUrl The full URL to the module to process.
140123
* @param {object} params.context Provided by the loaders API.
141-
* @param {Function} params.parentGetSource Provides the source code for the
142-
* parent module.
143-
* @param {string} [params.ns='namespace'] A string identifier that will be
144-
* used as the namespace for the identifiers exported by the module.
145-
* @param {string} [params.defaultAs='default'] The name to give the default
146-
* identifier exported by the module (if one exists). This is really only
147-
* useful in a recursive situation where a transitive module's default export
148-
* needs to be renamed to the name of the module.
124+
* @param {Function} params.parentGetSource Provides the source code for the parent module.
125+
* @param {bool} params.excludeDefault Exclude the default export.
149126
*
150-
* @returns {Promise<ProcessedModule>}
127+
* @returns {Promise<Map<string, string>>} The shimmed setters for all the exports
128+
* from the module and any transitive export all modules.
151129
*/
152-
async function processModule ({
153-
srcUrl,
154-
context,
155-
parentGetSource,
156-
parentResolve,
157-
ns = 'namespace',
158-
defaultAs = 'default'
159-
}) {
160-
const exportNames = await getExports({
161-
url: srcUrl,
162-
context,
163-
parentLoad: parentGetSource,
164-
defaultAs
165-
})
166-
const imports = [`import * as ${ns} from ${JSON.stringify(srcUrl)}`]
167-
const namespaces = [ns]
168-
169-
// As we iterate found module exports we will add setter code blocks
170-
// to this map that will eventually be inserted into the shim module's
171-
// source code. We utilize a map in order to prevent duplicate exports.
172-
// As a consequence of default renaming, it is possible that a file named
173-
// `foo.mjs` which has `export function foo() {}` and `export default foo`
174-
// exports will result in the "foo" export being defined twice in our shim.
175-
// The map allows us to avoid this situation at the cost of losing the
176-
// named export in favor of the default export.
130+
async function processModule ({ srcUrl, context, parentGetSource, parentResolve, excludeDefault }) {
131+
const exportNames = await getExports(srcUrl, context, parentGetSource)
132+
const duplicates = new Set()
177133
const setters = new Map()
178134

135+
const addSetter = (name, setter) => {
136+
// When doing an `import *` duplicates become undefined, so do the same
137+
if (setters.has(name)) {
138+
duplicates.add(name)
139+
setters.delete(name)
140+
} else if (!duplicates.has(name)) {
141+
setters.set(name, setter)
142+
}
143+
}
144+
179145
for (const n of exportNames) {
146+
if (n === 'default' && excludeDefault) continue
147+
180148
if (isStarExportLine(n) === true) {
181149
const [, modFile] = n.split('* from ')
182-
const normalizedModName = normalizeModName(modFile)
183-
const modName = Buffer.from(modFile, 'hex') + Date.now() + randomBytes(4).toString('hex')
184150

185151
let modUrl
186152
if (isBareSpecifier(modFile)) {
@@ -191,70 +157,28 @@ async function processModule ({
191157
modUrl = new URL(modFile, srcUrl).href
192158
}
193159

194-
const data = await processModule({
160+
const setters = await processModule({
195161
srcUrl: modUrl,
196162
context,
197163
parentGetSource,
198-
parentResolve,
199-
ns: `$${modName}`,
200-
defaultAs: normalizedModName
164+
excludeDefault: true
201165
})
202-
Array.prototype.push.apply(imports, data.imports)
203-
Array.prototype.push.apply(namespaces, data.namespaces)
204-
for (const [k, v] of data.setters.entries()) {
205-
setters.set(k, v)
166+
for (const [name, setter] of setters.entries()) {
167+
addSetter(name, setter)
206168
}
207-
208-
continue
209-
}
210-
211-
const matches = /^rename (.+) as (.+)$/.exec(n)
212-
if (matches !== null) {
213-
// Transitive modules that export a default identifier need to have
214-
// that identifier renamed to the name of module. And our shim setter
215-
// needs to utilize that new name while being initialized from the
216-
// corresponding origin namespace.
217-
const renamedExport = matches[2]
218-
setters.set(`$${renamedExport}${ns}`, `
219-
let $${renamedExport} = ${ns}.default
220-
export { $${renamedExport} as ${renamedExport} }
221-
set.${renamedExport} = (v) => {
222-
$${renamedExport} = v
169+
} else {
170+
addSetter(n, `
171+
let $${n} = _.${n}
172+
export { $${n} as ${n} }
173+
set.${n} = (v) => {
174+
$${n} = v
223175
return true
224176
}
225177
`)
226-
continue
227178
}
228-
229-
setters.set(`$${n}` + ns, `
230-
let $${n} = ${ns}.${n}
231-
export { $${n} as ${n} }
232-
set.${n} = (v) => {
233-
$${n} = v
234-
return true
235-
}
236-
`)
237179
}
238180

239-
return { imports, namespaces, setters }
240-
}
241-
242-
/**
243-
* Given a module name, e.g. 'foo-bar' or './foo-bar.js', normalize it to a
244-
* string that is a valid JavaScript identifier, e.g. `fooBar`. Normalization
245-
* means converting kebab-case to camelCase while removing any path tokens and
246-
* file extensions.
247-
*
248-
* @param {string} name The module name to normalize.
249-
*
250-
* @returns {string} The normalized identifier.
251-
*/
252-
function normalizeModName (name) {
253-
return name
254-
.split('/')
255-
.pop()
256-
.replace(/(.+)\.(?:js|mjs)$/, '$1')
257-
.replaceAll(/(-.)/g, x => x[1].toUpperCase())
181+
return setters
258182
}
259183

260184
function addIitm (url) {
@@ -322,61 +246,25 @@ function createHook (meta) {
322246
async function getSource (url, context, parentGetSource) {
323247
if (hasIitm(url)) {
324248
const realUrl = deleteIitm(url)
325-
const { imports, namespaces, setters: mapSetters } = await processModule({
249+
const setters = await processModule({
326250
srcUrl: realUrl,
327251
context,
328252
parentGetSource,
329253
parentResolve: cachedResolve
330254
})
331-
const setters = Array.from(mapSetters.values())
332-
333-
// When we encounter modules that re-export all identifiers from other
334-
// modules, it is possible that the transitive modules export a default
335-
// identifier. Due to us having to merge all transitive modules into a
336-
// single common namespace, we need to recognize these default exports
337-
// and remap them to a name based on the module name. This prevents us
338-
// from overriding the top-level module's (the one actually being imported
339-
// by some source code) default export when we merge the namespaces.
340-
const renamedDefaults = setters
341-
.map(s => {
342-
const matches = /let \$(.+) = (\$.+)\.default/.exec(s)
343-
if (matches === null) return undefined
344-
return `_['${matches[1]}'] = ${matches[2]}.default`
345-
})
346-
.filter(s => s)
347-
348-
// The for loops are how we merge namespaces into a common namespace that
349-
// can be proxied. We can't use a simple `Object.assign` style merging
350-
// because transitive modules can export a default identifier that would
351-
// override the desired default identifier. So we need to do manual
352-
// merging with some logic around default identifiers.
353-
//
354-
// Additionally, we need to make sure any renamed default exports in
355-
// transitive dependencies are added to the common namespace. This is
356-
// accomplished through the `renamedDefaults` array.
357255
return {
358256
source: `
359257
import { register } from '${iitmURL}'
360-
${imports.join('\n')}
258+
import * as namespace from ${JSON.stringify(realUrl)}
361259
362-
const namespaces = [${namespaces.join(', ')}]
363260
// Mimic a Module object (https://tc39.es/ecma262/#sec-module-namespace-objects).
364-
const _ = Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } })
261+
const _ = Object.assign(
262+
Object.create(null, { [Symbol.toStringTag]: { value: 'Module' } }),
263+
namespace
264+
)
365265
const set = {}
366266
367-
const primary = namespaces.shift()
368-
for (const [k, v] of Object.entries(primary)) {
369-
_[k] = v
370-
}
371-
for (const ns of namespaces) {
372-
for (const [k, v] of Object.entries(ns)) {
373-
if (k === 'default') continue
374-
_[k] = v
375-
}
376-
}
377-
378-
${setters.join('\n')}
379-
${renamedDefaults.join('\n')}
267+
${Array.from(setters.values()).join('\n')}
380268
381269
register(${JSON.stringify(realUrl)}, _, set, ${JSON.stringify(specifiers.get(realUrl))})
382270
`

lib/get-esm-exports.js

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,21 @@ function warn (txt) {
1818
* Utilizes an AST parser to interpret ESM source code and build a list of
1919
* exported identifiers. In the baseline case, the list of identifiers will be
2020
* the simple identifier names as written in the source code of the module.
21-
* However, there are some special cases:
21+
* However, there is a special case:
2222
*
23-
* 1. When an `export * from './foo.js'` line is encountered it is rewritten
23+
* When an `export * from './foo.js'` line is encountered it is rewritten
2424
* as `* from ./foo.js`. This allows the interpreting code to recognize a
2525
* transitive export and recursively parse the indicated module. The returned
2626
* identifier list will have "* from ./foo.js" as an item.
2727
*
28-
* 2. When `defaultAs` has a value other than 'default', the export line will
29-
* be rewritten as `rename <identifier> as <defaultAsValue>`. This rename string
30-
* will be an item in the returned identifier list.
31-
*
3228
* @param {object} params
3329
* @param {string} params.moduleSource The source code of the module to parse
3430
* and interpret.
35-
* @param {string} [defaultAs='default'] When anything other than 'default' any
36-
* `export default` lines will be rewritten utilizing the value provided. For
37-
* example, if a module 'foo-bar.js' has the line `export default foo` and the
38-
* value of this parameter is 'baz', then the export will be rewritten to
39-
* `rename foo as baz`.
4031
*
4132
* @returns {string[]} The identifiers exported by the module along with any
4233
* custom directives.
4334
*/
44-
function getEsmExports ({ moduleSource, defaultAs = 'default' }) {
35+
function getEsmExports (moduleSource) {
4536
const exportedNames = new Set()
4637
const tree = parser.parse(moduleSource, acornOpts)
4738
for (const node of tree.body) {
@@ -56,19 +47,7 @@ function getEsmExports ({ moduleSource, defaultAs = 'default' }) {
5647
break
5748

5849
case 'ExportDefaultDeclaration': {
59-
if (defaultAs === 'default') {
60-
exportedNames.add('default')
61-
break
62-
}
63-
64-
if (node.declaration.type.toLowerCase() === 'identifier') {
65-
// e.g. `export default foo`
66-
exportedNames.add(`rename ${node.declaration.name} as ${defaultAs}`)
67-
} else {
68-
// e.g. `export function foo () {}
69-
exportedNames.add(`rename ${node.declaration.id.name} as ${defaultAs}`)
70-
}
71-
50+
exportedNames.add('default')
7251
break
7352
}
7453

lib/get-exports.js

Lines changed: 9 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,13 @@ async function getFullCjsExports (url, context, parentLoad, source) {
2020
const ex = getCjsExports(source)
2121
const full = Array.from(new Set([
2222
...addDefault(ex.exports),
23-
...(await Promise.all(ex.reexports.map(re => getExports(({
24-
url: (/^(..?($|\/|\\))/).test(re)
23+
...(await Promise.all(ex.reexports.map(re => getExports(
24+
(/^(..?($|\/|\\))/).test(re)
2525
? pathToFileURL(require.resolve(fileURLToPath(new URL(re, url)))).toString()
2626
: pathToFileURL(require.resolve(re)).toString(),
2727
context,
2828
parentLoad
29-
}))))).flat()
29+
)))).flat()
3030
]))
3131

3232
urlsBeingProcessed.delete(url)
@@ -38,25 +38,18 @@ async function getFullCjsExports (url, context, parentLoad, source) {
3838
* source code for said module from the loader API, and parses the result
3939
* for the entities exported from that module.
4040
*
41-
* @param {object} params
42-
* @param {string} params.url A file URL string pointing to the module that
41+
* @param {string} url A file URL string pointing to the module that
4342
* we should get the exports of.
44-
* @param {object} params.context Context object as provided by the `load`
43+
* @param {object} context Context object as provided by the `load`
4544
* hook from the loaders API.
46-
* @param {Function} params.parentLoad Next hook function in the loaders API
45+
* @param {Function} parentLoad Next hook function in the loaders API
4746
* hook chain.
48-
* @param {string} [defaultAs='default'] When anything other than 'default',
49-
* will trigger remapping of default exports in ESM source files to the
50-
* provided name. For example, if a submodule has `export default foo` and
51-
* 'myFoo' is provided for this parameter, the export line will be rewritten
52-
* to `rename foo as myFoo`. This is key to being able to support
53-
* `export * from 'something'` exports.
5447
*
5548
* @returns {Promise<string[]>} An array of identifiers exported by the module.
5649
* Please see {@link getEsmExports} for caveats on special identifiers that may
5750
* be included in the result set.
5851
*/
59-
async function getExports ({ url, context, parentLoad, defaultAs = 'default' }) {
52+
async function getExports (url, context, parentLoad) {
6053
// `parentLoad` gives us the possibility of getting the source
6154
// from an upstream loader. This doesn't always work though,
6255
// so later on we fall back to reading it from disk.
@@ -77,15 +70,15 @@ async function getExports ({ url, context, parentLoad, defaultAs = 'default' })
7770
}
7871

7972
if (format === 'module') {
80-
return getEsmExports({ moduleSource: source, defaultAs })
73+
return getEsmExports(source)
8174
}
8275
if (format === 'commonjs') {
8376
return getFullCjsExports(url, context, parentLoad, source)
8477
}
8578

8679
// At this point our `format` is either undefined or not known by us. Fall
8780
// back to parsing as ESM/CJS.
88-
const esmExports = getEsmExports({ moduleSource: source, defaultAs })
81+
const esmExports = getEsmExports(source)
8982
if (!esmExports.length) {
9083
// TODO(bengl) it's might be possible to get here if somehow the format
9184
// isn't set at first and yet we have an ESM module with no exports.

package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,15 +36,19 @@
3636
"@babel/core": "^7.23.7",
3737
"@babel/eslint-parser": "^7.23.3",
3838
"@babel/plugin-syntax-import-assertions": "^7.23.3",
39+
"@react-email/components": "^0.0.19",
3940
"@types/node": "^18.0.6",
4041
"c8": "^7.8.0",
42+
"date-fns": "^3.6.0",
4143
"eslint": "^8.55.0",
4244
"eslint-config-standard": "^17.1.0",
4345
"eslint-plugin-import": "^2.29.0",
4446
"eslint-plugin-n": "^16.4.0",
4547
"eslint-plugin-node": "^11.1.0",
4648
"eslint-plugin-promise": "^6.1.1",
49+
"got": "^14.3.0",
4750
"imhotap": "^2.1.0",
51+
"openai": "^4.47.2",
4852
"ts-node": "^10.9.1",
4953
"typescript": "^4.7.4"
5054
},

0 commit comments

Comments
 (0)