|
| 1 | +/** |
| 2 | + * Workaround for two Rolldown CJS interop issues in Vite 8 pre-bundled deps: |
| 3 | + * |
| 4 | + * 1. __toESM isNodeMode: Rolldown passes isNodeMode=1 to __toESM which skips |
| 5 | + * the __esModule check, double-wrapping .default on CJS modules that already |
| 6 | + * set __esModule: true. |
| 7 | + * |
| 8 | + * 2. Missing named exports: Rolldown wraps CJS barrel exports (like decentraland-ui) |
| 9 | + * as a single default export without generating individual ESM named exports. |
| 10 | + * This breaks `import { Foo } from 'cjs-package'` patterns. |
| 11 | + * |
| 12 | + * Run after `vite optimize` and before `vite` to fix the cached deps. |
| 13 | + */ |
| 14 | +const fs = require('fs') |
| 15 | +const path = require('path') |
| 16 | + |
| 17 | +const depsDir = path.join(__dirname, '..', 'node_modules', '.vite', 'deps') |
| 18 | + |
| 19 | +/** |
| 20 | + * Patch 1: Fix __toESM isNodeMode to always respect __esModule flag. |
| 21 | + */ |
| 22 | +function patchToESM(files) { |
| 23 | + let count = 0 |
| 24 | + for (const file of files) { |
| 25 | + const filePath = path.join(depsDir, file) |
| 26 | + const content = fs.readFileSync(filePath, 'utf-8') |
| 27 | + if (!content.includes('var __toESM')) continue |
| 28 | + const patched = content.replace( |
| 29 | + /var __toESM = \(mod, isNodeMode, target\) => \(target = mod != null \? __create\(__getProtoOf\(mod\)\) : \{\}, __copyProps\(isNodeMode \|\| !mod \|\| !mod\.__esModule/, |
| 30 | + 'var __toESM = (mod, _isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(!mod || !mod.__esModule' |
| 31 | + ) |
| 32 | + if (patched !== content) { |
| 33 | + fs.writeFileSync(filePath, patched) |
| 34 | + count++ |
| 35 | + console.log(` Patched __toESM in ${file}`) |
| 36 | + } |
| 37 | + } |
| 38 | + return count |
| 39 | +} |
| 40 | + |
| 41 | +/** |
| 42 | + * Extract named export keys from bundled CJS code when we can't require() the package. |
| 43 | + * Looks for patterns like `exports.Foo = ...` and `exports["Foo"] = ...` inside |
| 44 | + * __commonJSMin wrappers. Also follows `__exportStar(require_xxx(), exports)` calls |
| 45 | + * to find re-exported names from sub-modules within the same chunk. |
| 46 | + */ |
| 47 | +function extractExportNames(content) { |
| 48 | + const names = new Set() |
| 49 | + // Match: exports.Name = ... (but not exports.__esModule) |
| 50 | + const dotPattern = /exports\.([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/g |
| 51 | + let m |
| 52 | + while ((m = dotPattern.exec(content)) !== null) { |
| 53 | + const name = m[1] |
| 54 | + if (name !== '__esModule' && name !== 'default') { |
| 55 | + names.add(name) |
| 56 | + } |
| 57 | + } |
| 58 | + // Match: exports["Name"] = ... |
| 59 | + const bracketPattern = /exports\["([a-zA-Z_$][a-zA-Z0-9_$]*)"\]\s*=/g |
| 60 | + while ((m = bracketPattern.exec(content)) !== null) { |
| 61 | + const name = m[1] |
| 62 | + if (name !== '__esModule' && name !== 'default') { |
| 63 | + names.add(name) |
| 64 | + } |
| 65 | + } |
| 66 | + |
| 67 | + // Follow __exportStar references: __exportStar(require_xxx(), exports) |
| 68 | + // These copy all properties from a sub-module. Find the sub-module's exports |
| 69 | + // by looking at the referenced require_xxx function's body. |
| 70 | + const exportStarPattern = /__exportStar\((require_\w+)\(\)/g |
| 71 | + while ((m = exportStarPattern.exec(content)) !== null) { |
| 72 | + const fnName = m[1] |
| 73 | + // Find the function body: var require_xxx = __commonJSMin(((exports) => { ... })) |
| 74 | + const fnDefRe = new RegExp(`var ${fnName.replace(/\$/g, '\\$')} = /\\* @__PURE__ \\*/ __commonJSMin\\(\\(\\(exports\\) => \\{([\\s\\S]*?)\\}\\)\\);`) |
| 75 | + const fnMatch = content.match(fnDefRe) |
| 76 | + if (fnMatch) { |
| 77 | + const body = fnMatch[1] |
| 78 | + // Extract exports.X from this function body |
| 79 | + const innerDot = /exports\.([a-zA-Z_$][a-zA-Z0-9_$]*)\s*=/g |
| 80 | + let im |
| 81 | + while ((im = innerDot.exec(body)) !== null) { |
| 82 | + if (im[1] !== '__esModule' && im[1] !== 'default') names.add(im[1]) |
| 83 | + } |
| 84 | + } |
| 85 | + } |
| 86 | + |
| 87 | + return Array.from(names) |
| 88 | +} |
| 89 | + |
| 90 | +/** |
| 91 | + * Patch 2: Add named ESM re-exports for CJS barrel modules. |
| 92 | + * |
| 93 | + * Rolldown bundles CJS modules with only `export default require_xxx()` which |
| 94 | + * means `import { Foo } from 'package'` gets undefined. We detect these and |
| 95 | + * add explicit named re-exports by inspecting the actual CJS module at runtime. |
| 96 | + */ |
| 97 | +function patchNamedExports(files) { |
| 98 | + let count = 0 |
| 99 | + // Match files that have `export default require_xxx()` as their main export |
| 100 | + const defaultExportPattern = /export default (require_\w+)\(\);/ |
| 101 | + |
| 102 | + for (const file of files) { |
| 103 | + const filePath = path.join(depsDir, file) |
| 104 | + const content = fs.readFileSync(filePath, 'utf-8') |
| 105 | + const match = content.match(defaultExportPattern) |
| 106 | + if (!match) continue |
| 107 | + |
| 108 | + const requireFn = match[0] // e.g. "export default require_dist();" |
| 109 | + |
| 110 | + // Try to get export names by requiring the actual CJS package. |
| 111 | + // Also try with browser globals shimmed to handle packages that use `self`, `window`, etc. |
| 112 | + const pkgName = file.replace(/\.js$/, '').replace(/_/g, '/') |
| 113 | + let exportKeys = [] |
| 114 | + try { |
| 115 | + const cjsModule = require(pkgName) |
| 116 | + exportKeys = Object.keys(cjsModule).filter( |
| 117 | + k => k !== 'default' && k !== '__esModule' && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) |
| 118 | + ) |
| 119 | + } catch { |
| 120 | + // Package can't be loaded in Node (uses browser globals etc). |
| 121 | + // Try again with basic browser global shims. |
| 122 | + try { |
| 123 | + global.self = global |
| 124 | + global.window = global |
| 125 | + global.document = global.document || { createElement: () => ({}), addEventListener: () => {} } |
| 126 | + global.navigator = global.navigator || { userAgent: '' } |
| 127 | + // Clear require cache to retry with shims |
| 128 | + delete require.cache[require.resolve(pkgName)] |
| 129 | + const cjsModule = require(pkgName) |
| 130 | + exportKeys = Object.keys(cjsModule).filter( |
| 131 | + k => k !== 'default' && k !== '__esModule' && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(k) |
| 132 | + ) |
| 133 | + } catch { |
| 134 | + // Still can't load — fall back to static analysis plus |
| 135 | + // extracting exports from packages referenced via __exportStar |
| 136 | + exportKeys = extractExportNames(content) |
| 137 | + |
| 138 | + // Also include exports from referenced ESM chunk files. |
| 139 | + // CJS barrels use `import { ... } from "./chunk.js"` then |
| 140 | + // `__exportStar(require_xxx(), exports)` to re-export sub-packages. |
| 141 | + // Scan all imported chunk files for their ESM exports. |
| 142 | + const chunkImportRe = /from "(\.\/([\w-]+)\.js)"/g |
| 143 | + let cim |
| 144 | + while ((cim = chunkImportRe.exec(content)) !== null) { |
| 145 | + const chunkPath = path.join(depsDir, cim[2] + '.js') |
| 146 | + if (!fs.existsSync(chunkPath)) continue |
| 147 | + const chunkContent = fs.readFileSync(chunkPath, 'utf-8') |
| 148 | + // Extract ESM export names from `export { A, B, C }` |
| 149 | + const esmExportRe = /export \{([^}]+)\}/g |
| 150 | + let em |
| 151 | + while ((em = esmExportRe.exec(chunkContent)) !== null) { |
| 152 | + for (const binding of em[1].split(',')) { |
| 153 | + const name = binding.trim().split(/\s+as\s+/).pop().trim() |
| 154 | + if (name && /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) && |
| 155 | + name !== 'default' && name !== '__esModule') { |
| 156 | + exportKeys.push(name) |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | + // Also extract from `export var ...` patterns |
| 161 | + extractExportNames(chunkContent).forEach(k => exportKeys.push(k)) |
| 162 | + } |
| 163 | + // Deduplicate |
| 164 | + exportKeys = [...new Set(exportKeys)] |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + // Filter out names that conflict with existing ESM-level declarations. |
| 169 | + // Only check import bindings and top-level var statements that are NOT |
| 170 | + // inside __commonJSMin wrappers (i.e., the few lines at the module boundary). |
| 171 | + const existingDecls = new Set() |
| 172 | + // Collect import bindings |
| 173 | + const importRe = /import\s*\{([^}]+)\}/g |
| 174 | + let dm |
| 175 | + while ((dm = importRe.exec(content)) !== null) { |
| 176 | + for (const binding of dm[1].split(',')) { |
| 177 | + const asMatch = binding.trim().match(/(?:\w+\s+as\s+)?(\w+)/) |
| 178 | + if (asMatch) existingDecls.add(asMatch[1]) |
| 179 | + } |
| 180 | + } |
| 181 | + // Collect top-level var/function declarations outside CJS wrappers. |
| 182 | + // These are lines starting with "var " or "function " at column 0. |
| 183 | + const topDeclRe = /^(?:var|function)\s+([a-zA-Z_$]\w*)/gm |
| 184 | + while ((dm = topDeclRe.exec(content)) !== null) existingDecls.add(dm[1]) |
| 185 | + // Also collect names already exported via `export { ... }` statements |
| 186 | + const existingExportRe = /export \{([^}]+)\}/g |
| 187 | + while ((dm = existingExportRe.exec(content)) !== null) { |
| 188 | + for (const binding of dm[1].split(',')) { |
| 189 | + // "require_Foo as bar" -> bar is the exported name |
| 190 | + const parts = binding.trim().split(/\s+as\s+/) |
| 191 | + const exportedName = parts[parts.length - 1].trim() |
| 192 | + if (exportedName) existingDecls.add(exportedName) |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + // Filter out JS reserved words and existing declarations |
| 197 | + const reserved = new Set([ |
| 198 | + 'break', 'case', 'catch', 'continue', 'debugger', 'default', 'delete', |
| 199 | + 'do', 'else', 'finally', 'for', 'function', 'if', 'in', 'instanceof', |
| 200 | + 'new', 'return', 'switch', 'this', 'throw', 'try', 'typeof', 'var', |
| 201 | + 'void', 'while', 'with', 'class', 'const', 'enum', 'export', 'extends', |
| 202 | + 'import', 'super', 'implements', 'interface', 'let', 'package', 'private', |
| 203 | + 'protected', 'public', 'static', 'yield', 'await', 'async', 'of' |
| 204 | + ]) |
| 205 | + exportKeys = exportKeys.filter(k => !existingDecls.has(k) && !reserved.has(k)) |
| 206 | + |
| 207 | + if (exportKeys.length === 0) continue |
| 208 | + |
| 209 | + // Replace `export default require_xxx();` with destructured named exports. |
| 210 | + // If CJS sets __esModule + exports.default, use .default as ESM default (esbuild compat). |
| 211 | + const fnName = match[1] |
| 212 | + const hasEsModuleDefault = (content.includes('exports.__esModule') || content.includes('"__esModule"')) && |
| 213 | + !exportKeys.includes('default') && content.includes('exports.default') |
| 214 | + const defaultExpr = hasEsModuleDefault |
| 215 | + ? `__cjs_mod__.__esModule ? __cjs_mod__.default : __cjs_mod__` |
| 216 | + : `__cjs_mod__` |
| 217 | + const replacement = [ |
| 218 | + `var __cjs_mod__ = ${fnName}();`, |
| 219 | + `export default ${defaultExpr};`, |
| 220 | + `export var ${exportKeys.map(k => `${k} = __cjs_mod__["${k}"]`).join(',\n ')};` |
| 221 | + ].join('\n') |
| 222 | + |
| 223 | + const patched = content.replace(requireFn, replacement) |
| 224 | + if (patched !== content) { |
| 225 | + fs.writeFileSync(filePath, patched) |
| 226 | + count++ |
| 227 | + console.log(` Added ${exportKeys.length} named exports to ${file}`) |
| 228 | + } |
| 229 | + } |
| 230 | + return count |
| 231 | +} |
| 232 | + |
| 233 | +try { |
| 234 | + const files = fs.readdirSync(depsDir).filter(f => f.endsWith('.js')) |
| 235 | + |
| 236 | + const toesmCount = patchToESM(files) |
| 237 | + const namedCount = patchNamedExports(files) |
| 238 | + |
| 239 | + if (toesmCount + namedCount > 0) { |
| 240 | + console.log(`Patched ${toesmCount} chunk(s) for __toESM, ${namedCount} chunk(s) for named exports.`) |
| 241 | + |
| 242 | + // Update _metadata.json file hashes so Vite doesn't detect stale deps |
| 243 | + // and re-optimize on next start (which would overwrite our patches). |
| 244 | + const metadataPath = path.join(depsDir, '_metadata.json') |
| 245 | + try { |
| 246 | + const crypto = require('crypto') |
| 247 | + const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8')) |
| 248 | + const optimized = metadata.optimized || {} |
| 249 | + for (const [, entry] of Object.entries(optimized)) { |
| 250 | + if (entry.file) { |
| 251 | + const filePath = path.join(depsDir, '..', '..', entry.file) |
| 252 | + if (fs.existsSync(filePath)) { |
| 253 | + const content = fs.readFileSync(filePath) |
| 254 | + entry.fileHash = crypto.createHash('sha256').update(content).digest('hex').substring(0, 8) |
| 255 | + } |
| 256 | + } |
| 257 | + } |
| 258 | + // Also update the chunks section if present |
| 259 | + const chunks = metadata.chunks || {} |
| 260 | + for (const [, entry] of Object.entries(chunks)) { |
| 261 | + if (entry.file) { |
| 262 | + const filePath = path.join(depsDir, '..', '..', entry.file) |
| 263 | + if (fs.existsSync(filePath)) { |
| 264 | + const content = fs.readFileSync(filePath) |
| 265 | + entry.fileHash = crypto.createHash('sha256').update(content).digest('hex').substring(0, 8) |
| 266 | + } |
| 267 | + } |
| 268 | + } |
| 269 | + // Also update the browserHash so the v= query param changes, |
| 270 | + // busting the browser's HTTP cache for the patched dep files. |
| 271 | + const newBrowserHash = crypto.randomBytes(4).toString('hex') |
| 272 | + metadata.browserHash = newBrowserHash |
| 273 | + fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2)) |
| 274 | + console.log(`Updated _metadata.json (browserHash: ${newBrowserHash}).`) |
| 275 | + } catch (e) { |
| 276 | + console.warn('Warning: could not update _metadata.json:', e.message) |
| 277 | + } |
| 278 | + } else { |
| 279 | + console.log('No dep chunks needed patching.') |
| 280 | + } |
| 281 | +} catch (e) { |
| 282 | + if (e.code === 'ENOENT') { |
| 283 | + console.log('No .vite/deps directory found — deps will be optimized on first request.') |
| 284 | + } else { |
| 285 | + throw e |
| 286 | + } |
| 287 | +} |
0 commit comments