Skip to content

Commit c541414

Browse files
committed
fix: support exports maps
1 parent 9764e3e commit c541414

File tree

14 files changed

+322
-56
lines changed

14 files changed

+322
-56
lines changed

edge-runtime/lib/cjs.test.ts

Lines changed: 68 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,47 +6,65 @@ import { assertEquals } from 'https://deno.land/[email protected]/testing/asserts.ts'
66

77
import { registerCJSModules } from './cjs.ts'
88

9-
Deno.test('Virtual CJS Module loader', async (t) => {
10-
const localRequire = createRequire(import.meta.url)
11-
const realRequireResult = localRequire('./fixture/cjs/entry.js') as Record<string, string>
12-
13-
const fixtureRoot = new URL('./fixture/cjs/', import.meta.url)
14-
const virtualRoot = new URL('file:///virtual-root/index.mjs')
15-
16-
const fixtureRootPath = fileURLToPath(fixtureRoot)
17-
const virtualRootPath = dirname(fileURLToPath(virtualRoot))
18-
19-
// load fixture into virtual CJS
20-
const virtualModules = new Map<string, string>()
21-
const decoder = new TextDecoder('utf-8')
22-
async function addVirtualModulesFromDir(dir: string) {
23-
const dirUrl = new URL('./' + dir, fixtureRoot)
24-
25-
for await (const dirEntry of Deno.readDir(dirUrl)) {
26-
const relPath = join(dir, dirEntry.name)
27-
if (dirEntry.isDirectory) {
28-
await addVirtualModulesFromDir(relPath + '/')
29-
} else if (dirEntry.isFile) {
30-
const fileURL = new URL('./' + dirEntry.name, dirUrl)
31-
virtualModules.set(relPath, decoder.decode(await Deno.readFile(fileURL)))
32-
}
9+
type RequireResult = Record<string, string>
10+
11+
const localRequire = createRequire(import.meta.url)
12+
const realRequireResult = localRequire('./fixture/cjs/entry.js') as RequireResult
13+
14+
const fixtureRoot = new URL('./fixture/cjs/', import.meta.url)
15+
const virtualRoot = new URL('file:///virtual-root/index.mjs')
16+
17+
const fixtureRootPath = fileURLToPath(fixtureRoot)
18+
const virtualRootPath = dirname(fileURLToPath(virtualRoot))
19+
20+
// load fixture into virtual CJS
21+
const virtualModules = new Map<string, string>()
22+
const decoder = new TextDecoder('utf-8')
23+
async function addVirtualModulesFromDir(dir: string) {
24+
const dirUrl = new URL('./' + dir, fixtureRoot)
25+
26+
for await (const dirEntry of Deno.readDir(dirUrl)) {
27+
const relPath = join(dir, dirEntry.name)
28+
if (dirEntry.isDirectory) {
29+
await addVirtualModulesFromDir(relPath + '/')
30+
} else if (dirEntry.isFile) {
31+
const fileURL = new URL('./' + dirEntry.name, dirUrl)
32+
virtualModules.set(relPath, decoder.decode(await Deno.readFile(fileURL)))
3333
}
3434
}
35+
}
36+
37+
await addVirtualModulesFromDir('')
38+
registerCJSModules(virtualRoot, virtualModules)
3539

36-
await addVirtualModulesFromDir('')
37-
registerCJSModules(virtualRoot, virtualModules)
40+
const virtualRequire = createRequire(virtualRoot)
41+
const virtualRequireResult = virtualRequire('./entry.js') as RequireResult
3842

39-
const virtualRequire = createRequire(virtualRoot)
40-
const virtualRequireResult = virtualRequire('./entry.js') as Record<string, string>
43+
const expectedVirtualRequireResult = {
44+
entry: '/virtual-root/entry.js',
4145

42-
const expectedVirtualRequireResult = {
43-
entry: '/virtual-root/entry.js',
44-
packageRoot: '/virtual-root/node_modules/package/index.js',
45-
packageInternalModule: '/virtual-root/node_modules/package/internal-module.js',
46-
packageMainRoot: '/virtual-root/node_modules/package-main/not-index.js',
47-
packageMainInternalModule: '/virtual-root/node_modules/package-main/internal-module.js',
48-
} as Record<string, string>
46+
packageExportsConditionsExportedModule:
47+
'/virtual-root/node_modules/package-exports-conditions/dist/exported-module.js',
48+
packageExportsConditionsRoot:
49+
'/virtual-root/node_modules/package-exports-conditions/root-export.js',
50+
packageExportsConditionsWildcardModuleNoExt:
51+
'/virtual-root/node_modules/package-exports-conditions/dist/wildcard/module.js',
52+
packageExportsConditionsWildcardModuleWithExt:
53+
'/virtual-root/node_modules/package-exports-conditions/dist/wildcard/module.js',
54+
packageExportsExportedModule:
55+
'/virtual-root/node_modules/package-exports/dist/exported-module.js',
56+
packageExportsRoot: '/virtual-root/node_modules/package-exports/root-export.js',
57+
packageExportsWildcardModuleNoExt:
58+
'/virtual-root/node_modules/package-exports/dist/wildcard/module.js',
59+
packageExportsWildcardModuleWithExt:
60+
'/virtual-root/node_modules/package-exports/dist/wildcard/module.js',
61+
packageRoot: '/virtual-root/node_modules/package/index.js',
62+
packageInternalModule: '/virtual-root/node_modules/package/internal-module.js',
63+
packageMainRoot: '/virtual-root/node_modules/package-main/main.js',
64+
packageMainInternalModule: '/virtual-root/node_modules/package-main/internal-module.js',
65+
} as RequireResult
4966

67+
Deno.test('Virtual CJS Module loader matches real CJS Module loader', async (t) => {
5068
// make sure we collect all the possible keys to spot any cases of potentially missing keys in one of the objects
5169
const allTheKeys = [
5270
...new Set([
@@ -56,16 +74,29 @@ Deno.test('Virtual CJS Module loader', async (t) => {
5674
]),
5775
]
5876

77+
function normalizeValue(value: string, basePath: string) {
78+
if (value === 'ERROR') {
79+
return value
80+
}
81+
82+
return relative(basePath, value)
83+
}
84+
5985
for (const key of allTheKeys) {
6086
const virtualValue = virtualRequireResult[key]
6187
const realValue = realRequireResult[key]
6288

63-
// values are filepaths, "real" require has actual file system paths, virtual ones has virtual paths starting with file:///virtual-root/
89+
// values are filepaths or "ERROR" strings, "real" require has actual file system paths, virtual ones has virtual paths starting with file:///virtual-root/
6490
// we compare remaining paths to ensure same relative paths are reported indicating that resolution works the same in
6591
// in real CommonJS and simulated one
66-
assertEquals(relative(fixtureRootPath, realValue), relative(virtualRootPath, virtualValue))
92+
assertEquals(
93+
normalizeValue(realValue, fixtureRootPath),
94+
normalizeValue(virtualValue, virtualRootPath),
95+
)
6796
}
97+
})
6898

99+
Deno.test('Virtual CJS Module loader matches expected results', async (t) => {
69100
// the main portion of testing functionality is in above assertions that compare real require and virtual one
70101
// below is additional explicit assertion mostly to make sure that test setup is correct
71102
assertEquals(virtualRequireResult, expectedVirtualRequireResult)

edge-runtime/lib/cjs.ts

Lines changed: 185 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ type RegisteredModule = {
1010
// lazily parsed json string
1111
parsedJson?: any
1212
}
13+
type ModuleResolutions = (subpath: string) => string
1314
const registeredModules = new Map<string, RegisteredModule>()
15+
const memoizedPackageResolvers = new WeakMap<RegisteredModule, ModuleResolutions>()
1416

1517
const require = createRequire(import.meta.url)
1618

@@ -30,6 +32,187 @@ function parseJson(matchedModule: RegisteredModule) {
3032
}
3133
}
3234

35+
function normalizePackageExports(exports: any) {
36+
if (typeof exports === 'string') {
37+
return { '.': exports }
38+
}
39+
40+
if (Array.isArray(exports)) {
41+
return Object.fromEntries(exports.map((entry) => [entry, entry]))
42+
}
43+
44+
return exports
45+
}
46+
47+
type Condition = string // 'import', 'require', 'default', 'node-addon' etc
48+
type SubpathMatcher = string
49+
type ConditionalTarget = { [key in Condition]: string | ConditionalTarget }
50+
type SubpathTarget = string | ConditionalTarget
51+
/**
52+
* @example
53+
* {
54+
* ".": "./main.js",
55+
* "./foo": {
56+
* "import": "./foo.js",
57+
* "require": "./foo.cjs"
58+
* }
59+
* }
60+
*/
61+
type NormalizedExports = Record<SubpathMatcher, SubpathTarget | Record<Condition, SubpathTarget>>
62+
63+
// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L555
64+
function isConditionalExportsMainSugar(exports: any) {
65+
if (typeof exports === 'string' || Array.isArray(exports)) {
66+
return true
67+
}
68+
if (typeof exports !== 'object' || exports === null) {
69+
return false
70+
}
71+
72+
// not doing validation at this point, if the package.json was misconfigured
73+
// we would not get to this point as it would throw when running `next build`
74+
const keys = Object.keys(exports)
75+
return keys.length > 0 && (keys[0] === '' || keys[0][0] !== '.')
76+
}
77+
78+
// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L671
79+
function patternKeyCompare(a: string, b: string) {
80+
const aPatternIndex = a.indexOf('*')
81+
const bPatternIndex = b.indexOf('*')
82+
const baseLenA = aPatternIndex === -1 ? a.length : aPatternIndex + 1
83+
const baseLenB = bPatternIndex === -1 ? b.length : bPatternIndex + 1
84+
if (baseLenA > baseLenB) {
85+
return -1
86+
}
87+
if (baseLenB > baseLenA) {
88+
return 1
89+
}
90+
if (aPatternIndex === -1) {
91+
return 1
92+
}
93+
if (bPatternIndex === -1) {
94+
return -1
95+
}
96+
if (a.length > b.length) {
97+
return -1
98+
}
99+
if (b.length > a.length) {
100+
return 1
101+
}
102+
return 0
103+
}
104+
105+
function applyWildcardMatch(target: string, bestMatchSubpath?: string) {
106+
return bestMatchSubpath ? target.replace('*', bestMatchSubpath) : target
107+
}
108+
109+
// https://github.com/nodejs/node/blob/323f19c18fea06b9234a0c945394447b077fe565/lib/internal/modules/helpers.js#L76
110+
const conditions = new Set(['require', 'node', 'node-addons', 'default'])
111+
112+
// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L480
113+
function matchConditions(target: SubpathTarget, bestMatchSubpath?: string) {
114+
if (typeof target === 'string') {
115+
return applyWildcardMatch(target, bestMatchSubpath)
116+
}
117+
118+
if (Array.isArray(target) && target.length > 0) {
119+
for (const targetItem of target) {
120+
return matchConditions(targetItem, bestMatchSubpath)
121+
}
122+
}
123+
124+
if (typeof target === 'object' && target !== null) {
125+
for (const [condition, targetValue] of Object.entries(target)) {
126+
if (conditions.has(condition)) {
127+
return matchConditions(targetValue, bestMatchSubpath)
128+
}
129+
}
130+
}
131+
132+
throw new Error('Invalid package target')
133+
}
134+
135+
function getPackageResolver(packageJsonMatchedModule: RegisteredModule) {
136+
const memoized = memoizedPackageResolvers.get(packageJsonMatchedModule)
137+
if (memoized) {
138+
return memoized
139+
}
140+
141+
// https://nodejs.org/api/packages.html#package-entry-points
142+
143+
const pkgJson = parseJson(packageJsonMatchedModule)
144+
145+
let exports: NormalizedExports | null = null
146+
if (pkgJson.exports) {
147+
// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L590
148+
exports = isConditionalExportsMainSugar(pkgJson.exports)
149+
? { '.': pkgJson.exports }
150+
: pkgJson.exports
151+
}
152+
153+
const resolveInPackage: ModuleResolutions = (subpath: string) => {
154+
if (exports) {
155+
const normalizedSubpath = subpath.length === 0 ? '.' : './' + subpath
156+
157+
// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L594
158+
// simple case with matching as-is
159+
if (
160+
normalizedSubpath in exports &&
161+
!normalizedSubpath.includes('*') &&
162+
!normalizedSubpath.endsWith('/')
163+
) {
164+
return matchConditions(exports[normalizedSubpath])
165+
}
166+
167+
// https://github.com/nodejs/node/blob/6fd67ec6e3ccbdfcfa0300b9b742040a0607a4bc/lib/internal/modules/esm/resolve.js#L610
168+
let bestMatchKey = ''
169+
let bestMatchSubpath
170+
for (const key of Object.keys(exports)) {
171+
const patternIndex = key.indexOf('*')
172+
if (patternIndex !== -1 && normalizedSubpath.startsWith(key.slice(0, patternIndex))) {
173+
const patternTrailer = key.slice(patternIndex + 1)
174+
if (
175+
normalizedSubpath.length > key.length &&
176+
normalizedSubpath.endsWith(patternTrailer) &&
177+
patternKeyCompare(bestMatchKey, key) === 1 &&
178+
key.lastIndexOf('*') === patternIndex
179+
) {
180+
bestMatchKey = key
181+
bestMatchSubpath = normalizedSubpath.slice(
182+
patternIndex,
183+
normalizedSubpath.length - patternTrailer.length,
184+
)
185+
}
186+
}
187+
}
188+
189+
if (bestMatchKey && typeof bestMatchSubpath === 'string') {
190+
const matchedTarget = exports[bestMatchKey]
191+
console.log({
192+
matchedTarget,
193+
bestMatchKey,
194+
bestMatchSubpath,
195+
subpath,
196+
})
197+
return matchConditions(matchedTarget, bestMatchSubpath)
198+
}
199+
200+
// if exports are defined, they are source of truth and any imports not allowed by it will fail
201+
throw new Error(`Cannot find module '${normalizedSubpath}'`)
202+
}
203+
204+
if (subpath.length === 0 && pkgJson.main) {
205+
return pkgJson.main
206+
}
207+
208+
return subpath
209+
}
210+
211+
memoizedPackageResolvers.set(packageJsonMatchedModule, resolveInPackage)
212+
213+
return resolveInPackage
214+
}
215+
33216
function seedCJSModuleCacheAndReturnTarget(matchedModule: RegisteredModule, parent: Module) {
34217
if (matchedModule.loaded) {
35218
return matchedModule.filepath
@@ -101,7 +284,6 @@ export function registerCJSModules(baseUrl: URL, modules: Map<string, string>) {
101284

102285
for (const [filename, source] of modules.entries()) {
103286
const target = join(basePath, filename)
104-
105287
registeredModules.set(target, { source, loaded: false, filepath: target })
106288
}
107289

@@ -135,14 +317,10 @@ export function registerCJSModules(baseUrl: URL, modules: Map<string, string>) {
135317

136318
let relativeTarget = moduleInPackagePath
137319

138-
let pkgJson: any = null
139320
if (maybePackageJson) {
140-
pkgJson = parseJson(maybePackageJson)
321+
const packageResolver = getPackageResolver(maybePackageJson)
141322

142-
// TODO: exports and anything else like that
143-
if (moduleInPackagePath.length === 0 && pkgJson.main) {
144-
relativeTarget = pkgJson.main
145-
}
323+
relativeTarget = packageResolver(moduleInPackagePath)
146324
}
147325

148326
const potentialPath = join(nodeModulePaths, packageName, relativeTarget)
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
!node_modules
2+
!dist

0 commit comments

Comments
 (0)