Find the cost of adding an npm package to your app's bundle size teardown.kelinci.dev
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: CJS module detection

Mary d32cd721 c25225ab

+761 -111
+1
package.json
··· 23 23 "valibot": "^1.2.0" 24 24 }, 25 25 "devDependencies": { 26 + "@oxc-project/types": "^0.110.0", 26 27 "@tailwindcss/vite": "^4.1.18", 27 28 "@types/node": "^24.10.1", 28 29 "@types/semver": "^7.7.1",
+8
pnpm-lock.yaml
··· 36 36 specifier: ^1.2.0 37 37 version: 1.2.0(typescript@5.9.3) 38 38 devDependencies: 39 + '@oxc-project/types': 40 + specifier: ^0.110.0 41 + version: 0.110.0 39 42 '@tailwindcss/vite': 40 43 specifier: ^4.1.18 41 44 version: 4.1.18(vite@7.3.1(@types/node@24.10.9)(jiti@2.6.1)(lightningcss@1.31.1)) ··· 820 823 821 824 '@napi-rs/wasm-runtime@1.1.1': 822 825 resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} 826 + 827 + '@oxc-project/types@0.110.0': 828 + resolution: {integrity: sha512-6Ct21OIlrEnFEJk5LT4e63pk3btsI6/TusD/GStLi7wYlGJNOl1GI9qvXAnRAxQU9zqA2Oz+UwhfTOU2rPZVow==} 823 829 824 830 '@oxfmt/darwin-arm64@0.26.0': 825 831 resolution: {integrity: sha512-AAGc+8CffkiWeVgtWf4dPfQwHEE5c/j/8NWH7VGVxxJRCZFdmWcqCXprvL2H6qZFewvDLrFbuSPRCqYCpYGaTQ==} ··· 2404 2410 '@emnapi/core': 1.8.1 2405 2411 '@emnapi/runtime': 1.8.1 2406 2412 '@tybys/wasm-util': 0.10.1 2413 + 2414 + '@oxc-project/types@0.110.0': {} 2407 2415 2408 2416 '@oxfmt/darwin-arm64@0.26.0': 2409 2417 optional: true
+60 -45
src/components/package-bundle.tsx
··· 1 1 import { createSignal, For, Match, onCleanup, Show, Switch } from 'solid-js'; 2 2 3 - import { LucideCheck, LucideCircleAlert, LucideLoader } from '../icons/lucide'; 3 + import { LucideCheck, LucideCircleAlert, LucideInfo, LucideLoader } from '../icons/lucide'; 4 4 import { formatBytes } from '../lib/format'; 5 5 import { LRUCache } from '../lib/lru'; 6 6 import { createQuery } from '../lib/query'; ··· 219 219 </Show> 220 220 </div> 221 221 222 - {/* export selection */} 223 - <Show when={initialBundle()?.exports} keyed> 224 - {(allExports) => ( 225 - <div class="flex flex-col gap-3"> 226 - <div class="flex items-center justify-between"> 227 - <span class="text-base-300 font-medium text-neutral-foreground-2"> 228 - Exports ({allExports.length}) 229 - </span> 230 - <div class="flex gap-1"> 231 - <Button appearance="subtle" size="small" onClick={selectAll}> 232 - All 233 - </Button> 234 - <Button appearance="subtle" size="small" onClick={selectNone}> 235 - None 236 - </Button> 222 + <Switch> 223 + <Match when={bundleData().isCjs}> 224 + <div class="flex items-center gap-2 text-base-200 text-neutral-foreground-3"> 225 + <LucideInfo class="size-4" /> 226 + <span>CommonJS module — tree-shaking unavailable</span> 227 + </div> 228 + </Match> 229 + 230 + <Match when={!initialBundle()?.exports.length}> 231 + <div class="flex items-center gap-2 text-base-200 text-neutral-foreground-3"> 232 + <LucideInfo class="size-4" /> 233 + <span>No exports detected — side-effects only module</span> 234 + </div> 235 + </Match> 236 + 237 + <Match when={initialBundle()?.exports}> 238 + {(allExports) => ( 239 + <div class="flex flex-col gap-3"> 240 + <div class="flex items-center justify-between"> 241 + <span class="text-base-300 font-medium text-neutral-foreground-2"> 242 + Exports ({allExports().length}) 243 + </span> 244 + <div class="flex gap-1"> 245 + <Button appearance="subtle" size="small" onClick={selectAll}> 246 + All 247 + </Button> 248 + <Button appearance="subtle" size="small" onClick={selectNone}> 249 + None 250 + </Button> 251 + </div> 237 252 </div> 238 - </div> 239 - <div class="flex flex-wrap gap-1.5"> 240 - <For each={allExports}> 241 - {(exp) => { 242 - const selected = () => isExportSelected(exp); 243 - return ( 244 - <button 245 - onClick={() => toggleExport(exp)} 246 - class="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-base-300 transition duration-100" 247 - classList={{ 248 - 'border-brand-stroke-1 bg-brand-background-2 text-brand-foreground-2 hover:bg-brand-background-2-hover hover:text-brand-foreground-2-hover active:bg-brand-background-2-pressed active:text-brand-foreground-2-pressed': 249 - selected(), 250 - 'border-neutral-stroke-1 bg-neutral-background-1 text-neutral-foreground-2 hover:bg-neutral-background-1-hover hover:text-neutral-foreground-2-hover active:bg-neutral-background-1-pressed active:text-neutral-foreground-2-pressed': 251 - !selected(), 252 - }} 253 - > 254 - <LucideCheck 255 - class="duration-fast size-3.5 transition" 253 + <div class="flex flex-wrap gap-1.5"> 254 + <For each={allExports()}> 255 + {(exp) => { 256 + const selected = () => isExportSelected(exp); 257 + return ( 258 + <button 259 + onClick={() => toggleExport(exp)} 260 + class="inline-flex items-center gap-1 rounded-md border px-2 py-1 text-base-300 transition duration-100" 256 261 classList={{ 257 - 'opacity-100': selected(), 258 - 'opacity-0': !selected(), 262 + 'border-brand-stroke-1 bg-brand-background-2 text-brand-foreground-2 hover:bg-brand-background-2-hover hover:text-brand-foreground-2-hover active:bg-brand-background-2-pressed active:text-brand-foreground-2-pressed': 263 + selected(), 264 + 'border-neutral-stroke-1 bg-neutral-background-1 text-neutral-foreground-2 hover:bg-neutral-background-1-hover hover:text-neutral-foreground-2-hover active:bg-neutral-background-1-pressed active:text-neutral-foreground-2-pressed': 265 + !selected(), 259 266 }} 260 - /> 261 - <span>{exp}</span> 262 - </button> 263 - ); 264 - }} 265 - </For> 267 + > 268 + <LucideCheck 269 + class="duration-fast size-3.5 transition" 270 + classList={{ 271 + 'opacity-100': selected(), 272 + 'opacity-0': !selected(), 273 + }} 274 + /> 275 + <span>{exp}</span> 276 + </button> 277 + ); 278 + }} 279 + </For> 280 + </div> 266 281 </div> 267 - </div> 268 - )} 269 - </Show> 282 + )} 283 + </Match> 284 + </Switch> 270 285 </div> 271 286 )} 272 287 </Match>
+11
src/icons/lucide.tsx
··· 107 107 ); 108 108 } 109 109 110 + export function LucideInfo(props: JSX.IntrinsicElements['svg']) { 111 + return ( 112 + <svg width="1em" height="1em" viewBox="0 0 24 24" {...props}> 113 + <g fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"> 114 + <circle cx="12" cy="12" r="10" /> 115 + <path d="M12 16v-4m0-4h.01" /> 116 + </g> 117 + </svg> 118 + ); 119 + } 120 + 110 121 export function LucideLoader(props: JSX.IntrinsicElements['svg']) { 111 122 return ( 112 123 <svg width="1em" height="1em" viewBox="0 0 24 24" {...props}>
+55 -66
src/npm/bundler.ts
··· 4 4 5 5 import { BundleError } from './errors'; 6 6 import { progress } from './events'; 7 + import { analyzeModule } from './module-type'; 7 8 8 9 const { volume } = memfs!; 9 10 ··· 56 57 brotliSize?: number; 57 58 /** exported names from the entry chunk */ 58 59 exports: string[]; 60 + /** whether the entry module is CommonJS */ 61 + isCjs: boolean; 59 62 } 60 63 61 64 // #endregion ··· 65 68 const VIRTUAL_ENTRY_ID = '\0virtual:entry'; 66 69 67 70 /** 68 - * creates a virtual entry point that imports and re-exports from a specific subpath. 69 - * 70 - * @param packageName the package name 71 - * @param subpath the export subpath (e.g., ".", "./utils") 72 - * @param selectedExports list of specific exports to include, or null for all 73 - * @param includeDefault whether to include default export (only used when selectedExports is null) 74 - * @returns the entry point code 75 - */ 76 - function createVirtualEntry( 77 - packageName: string, 78 - subpath: string, 79 - selectedExports: string[] | null, 80 - includeDefault: boolean, 81 - ): string { 82 - const importPath = subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`; 83 - 84 - if (selectedExports === null) { 85 - // re-export everything 86 - let code = `export * from '${importPath}';\n`; 87 - if (includeDefault) { 88 - code += `export { default } from '${importPath}';\n`; 89 - } 90 - return code; 91 - } 92 - 93 - // specific exports selected (empty array = export nothing) 94 - // quote names to handle non-identifier exports 95 - const quoted = selectedExports.map((e) => JSON.stringify(e)); 96 - return `export { ${quoted.join(', ')} } from '${importPath}';\n`; 97 - } 98 - 99 - /** 100 71 * get compressed size using a compression stream. 101 72 */ 102 73 async function getCompressedSize(code: string, format: CompressionFormat): Promise<number> { ··· 176 147 selectedExports: string[] | null, 177 148 options: BundleOptions, 178 149 ): Promise<BundleResult> { 150 + // track whether module is CJS (set in load hook) 151 + let isCjs = false; 152 + 179 153 // bundle with rolldown 180 154 const bundle = await rolldown({ 181 155 input: { main: VIRTUAL_ENTRY_ID }, ··· 194 168 return; 195 169 } 196 170 197 - // check if the module has a default export 198 - let includeDefault = false; 199 - if (selectedExports === null) { 200 - const importPath = subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`; 201 - const resolved = await this.resolve(importPath); 171 + const importPath = subpath === '.' ? packageName : `${packageName}${subpath.slice(1)}`; 172 + 173 + // resolve the entry module 174 + const resolved = await this.resolve(importPath); 175 + if (!resolved) { 176 + throw new BundleError(`failed to resolve entry module: ${importPath}`); 177 + } 178 + 179 + // JSON files only have a default export 180 + if (resolved.id.endsWith('.json')) { 181 + return `export { default } from '${importPath}';\n`; 182 + } 183 + 184 + // read the source file 185 + let source: string; 186 + try { 187 + source = volume.readFileSync(resolved.id, 'utf8') as string; 188 + } catch { 189 + throw new BundleError(`failed to read entry module: ${resolved.id}`); 190 + } 202 191 203 - if (resolved) { 204 - try { 205 - const source = volume.readFileSync(resolved.id, 'utf8') as string; 206 - const ast = this.parse(source); 192 + // parse and analyze the module 193 + let ast; 194 + try { 195 + ast = this.parse(source); 196 + } catch { 197 + throw new BundleError(`failed to parse entry module: ${resolved.id}`); 198 + } 207 199 208 - for (const node of ast.body) { 209 - // export default ... 210 - if (node.type === 'ExportDefaultDeclaration') { 211 - includeDefault = true; 212 - break; 213 - } 200 + const moduleInfo = analyzeModule(ast); 201 + isCjs = moduleInfo.type === 'cjs'; 214 202 215 - // export { default } from '...' or export { foo as default } 216 - if (node.type === 'ExportNamedDeclaration') { 217 - for (const spec of node.specifiers) { 218 - const exported = spec.exported; 219 - const name = exported.type === 'Literal' ? exported.value : exported.name; 203 + // CJS modules can't be tree-shaken effectively, just re-export default 204 + if (moduleInfo.type === 'cjs') { 205 + return `export { default } from '${importPath}';\n`; 206 + } 220 207 221 - if (name === 'default') { 222 - includeDefault = true; 223 - break; 224 - } 225 - } 208 + // unknown/side-effects only modules have no exports 209 + if (moduleInfo.type === 'unknown') { 210 + return `export {} from '${importPath}';\n`; 211 + } 226 212 227 - if (includeDefault) { 228 - break; 229 - } 230 - } 231 - } 232 - } catch { 233 - // couldn't read/parse file, skip default export 234 - } 213 + // ESM module handling 214 + if (selectedExports === null) { 215 + // re-export everything 216 + let code = `export * from '${importPath}';\n`; 217 + if (moduleInfo.hasDefaultExport) { 218 + code += `export { default } from '${importPath}';\n`; 235 219 } 220 + return code; 236 221 } 237 222 238 - return createVirtualEntry(packageName, subpath, selectedExports, includeDefault); 223 + // specific exports selected (empty array = export nothing) 224 + // quote names to handle non-identifier exports 225 + const quoted = selectedExports.map((e) => JSON.stringify(e)); 226 + return `export { ${quoted.join(', ')} } from '${importPath}';\n`; 239 227 }, 240 228 }, 241 229 ], ··· 288 276 gzipSize: totalGzipSize, 289 277 brotliSize: totalBrotliSize, 290 278 exports: entryChunk.exports, 279 + isCjs, 291 280 }; 292 281 } 293 282
+230
src/npm/module-type.test.ts
··· 1 + import { parseAst } from '@rolldown/browser/parseAst'; 2 + import { describe, expect, it } from 'vitest'; 3 + 4 + import { analyzeModule, type ModuleInfo } from './module-type'; 5 + 6 + function analyze(code: string): ModuleInfo { 7 + const ast = parseAst(code); 8 + return analyzeModule(ast); 9 + } 10 + 11 + describe('analyzeModule', () => { 12 + describe('ESM detection', () => { 13 + it('detects export const', () => { 14 + const info = analyze('export const foo = 1'); 15 + expect(info.type).toBe('esm'); 16 + expect(info.namedExports).toEqual(['foo']); 17 + expect(info.hasDefaultExport).toBe(false); 18 + }); 19 + 20 + it('detects export default class', () => { 21 + const info = analyze('export default class {}'); 22 + expect(info.type).toBe('esm'); 23 + expect(info.hasDefaultExport).toBe(true); 24 + }); 25 + 26 + it('detects export default function', () => { 27 + const info = analyze('export default function() {}'); 28 + expect(info.type).toBe('esm'); 29 + expect(info.hasDefaultExport).toBe(true); 30 + }); 31 + 32 + it('detects export default expression', () => { 33 + const info = analyze('export default 42'); 34 + expect(info.type).toBe('esm'); 35 + expect(info.hasDefaultExport).toBe(true); 36 + }); 37 + 38 + it('detects re-exports', () => { 39 + const info = analyze("export { a, b } from './mod'"); 40 + expect(info.type).toBe('esm'); 41 + expect(info.namedExports).toEqual(['a', 'b']); 42 + }); 43 + 44 + it('detects star exports', () => { 45 + const info = analyze("export * from './mod'"); 46 + expect(info.type).toBe('esm'); 47 + // star exports don't add specific names 48 + expect(info.namedExports).toEqual([]); 49 + }); 50 + 51 + it('detects export { default } from as hasDefaultExport', () => { 52 + const info = analyze("export { default } from './mod'"); 53 + expect(info.type).toBe('esm'); 54 + expect(info.hasDefaultExport).toBe(true); 55 + }); 56 + 57 + it('detects export { foo as default }', () => { 58 + const info = analyze("export { foo as default } from './mod'"); 59 + expect(info.type).toBe('esm'); 60 + expect(info.hasDefaultExport).toBe(true); 61 + }); 62 + 63 + it('detects import statement', () => { 64 + const info = analyze("import foo from './mod'"); 65 + expect(info.type).toBe('esm'); 66 + }); 67 + 68 + it('detects named imports', () => { 69 + const info = analyze("import { a, b } from './mod'"); 70 + expect(info.type).toBe('esm'); 71 + }); 72 + 73 + it('detects import.meta.url', () => { 74 + const info = analyze('console.log(import.meta.url)'); 75 + expect(info.type).toBe('esm'); 76 + }); 77 + 78 + it('detects multiple exports', () => { 79 + const info = analyze(` 80 + export const foo = 1; 81 + export const bar = 2; 82 + export function baz() {} 83 + `); 84 + expect(info.type).toBe('esm'); 85 + expect(info.namedExports).toEqual(['foo', 'bar', 'baz']); 86 + }); 87 + 88 + it('detects export function', () => { 89 + const info = analyze('export function foo() {}'); 90 + expect(info.type).toBe('esm'); 91 + expect(info.namedExports).toEqual(['foo']); 92 + }); 93 + 94 + it('detects export class', () => { 95 + const info = analyze('export class Foo {}'); 96 + expect(info.type).toBe('esm'); 97 + expect(info.namedExports).toEqual(['Foo']); 98 + }); 99 + 100 + it('does not count dynamic import alone as ESM', () => { 101 + const info = analyze("import('./dynamic')"); 102 + expect(info.type).toBe('unknown'); 103 + }); 104 + }); 105 + 106 + describe('CJS detection', () => { 107 + it('detects exports.foo assignment', () => { 108 + const info = analyze('exports.foo = 1'); 109 + expect(info.type).toBe('cjs'); 110 + expect(info.namedExports).toEqual(['foo']); 111 + }); 112 + 113 + it('detects module.exports.bar assignment', () => { 114 + const info = analyze('module.exports.bar = 2'); 115 + expect(info.type).toBe('cjs'); 116 + expect(info.namedExports).toEqual(['bar']); 117 + }); 118 + 119 + it('detects module.exports = { a, b }', () => { 120 + const info = analyze('module.exports = { a, b }'); 121 + expect(info.type).toBe('cjs'); 122 + expect(info.namedExports).toEqual(['a', 'b']); 123 + }); 124 + 125 + it('detects module.exports = { a: 1, b: 2 }', () => { 126 + const info = analyze('module.exports = { a: 1, b: 2 }'); 127 + expect(info.type).toBe('cjs'); 128 + expect(info.namedExports).toEqual(['a', 'b']); 129 + }); 130 + 131 + it('detects Object.defineProperty(exports, "x", ...)', () => { 132 + const info = analyze('Object.defineProperty(exports, "x", { value: 1 })'); 133 + expect(info.type).toBe('cjs'); 134 + expect(info.namedExports).toEqual(['x']); 135 + }); 136 + 137 + it('detects Object.defineProperty(module.exports, "y", ...)', () => { 138 + const info = analyze('Object.defineProperty(module.exports, "y", { get: () => 1 })'); 139 + expect(info.type).toBe('cjs'); 140 + expect(info.namedExports).toEqual(['y']); 141 + }); 142 + 143 + it('collects multiple CJS exports', () => { 144 + const info = analyze(` 145 + exports.foo = 1; 146 + exports.bar = 2; 147 + module.exports.baz = 3; 148 + `); 149 + expect(info.type).toBe('cjs'); 150 + expect(info.namedExports).toEqual(['foo', 'bar', 'baz']); 151 + }); 152 + 153 + it('detects module.exports = require(...) re-export', () => { 154 + const info = analyze("module.exports = require('./other')"); 155 + expect(info.type).toBe('cjs'); 156 + // can't know exports statically from re-export 157 + expect(info.namedExports).toEqual([]); 158 + }); 159 + 160 + it('detects conditional require re-export (React pattern)', () => { 161 + const info = analyze(` 162 + 'use strict'; 163 + if (process.env.NODE_ENV === 'production') { 164 + module.exports = require('./cjs/react.production.js'); 165 + } else { 166 + module.exports = require('./cjs/react.development.js'); 167 + } 168 + `); 169 + expect(info.type).toBe('cjs'); 170 + }); 171 + 172 + it('detects conditional require with block statements', () => { 173 + const info = analyze(` 174 + if (condition) { 175 + module.exports = require('./a'); 176 + } else { 177 + module.exports = require('./b'); 178 + } 179 + `); 180 + expect(info.type).toBe('cjs'); 181 + }); 182 + 183 + it('detects conditional require without braces', () => { 184 + const info = analyze(` 185 + if (condition) 186 + module.exports = require('./a'); 187 + else 188 + module.exports = require('./b'); 189 + `); 190 + expect(info.type).toBe('cjs'); 191 + }); 192 + }); 193 + 194 + describe('unknown detection', () => { 195 + it('returns unknown for empty file', () => { 196 + const info = analyze(''); 197 + expect(info.type).toBe('unknown'); 198 + expect(info.namedExports).toEqual([]); 199 + expect(info.hasDefaultExport).toBe(false); 200 + }); 201 + 202 + it('returns unknown for side-effects only', () => { 203 + const info = analyze("console.log('side effect')"); 204 + expect(info.type).toBe('unknown'); 205 + }); 206 + 207 + it('returns unknown for only dynamic imports', () => { 208 + const info = analyze("const mod = import('./dynamic')"); 209 + expect(info.type).toBe('unknown'); 210 + }); 211 + 212 + it('returns unknown for iife', () => { 213 + const info = analyze('(function() { console.log("hi"); })()'); 214 + expect(info.type).toBe('unknown'); 215 + }); 216 + }); 217 + 218 + describe('mixed scenarios', () => { 219 + it('ESM takes precedence over CJS patterns', () => { 220 + // some files might have both patterns (e.g., dual builds) 221 + const info = analyze(` 222 + export const foo = 1; 223 + exports.bar = 2; 224 + `); 225 + expect(info.type).toBe('esm'); 226 + // ESM export is captured, CJS is ignored when ESM detected first 227 + expect(info.namedExports).toContain('foo'); 228 + }); 229 + }); 230 + });
+395
src/npm/module-type.ts
··· 1 + import type { Expression, Program, Statement, StaticMemberExpression } from '@oxc-project/types'; 2 + 3 + // #region types 4 + 5 + export type ModuleType = 'esm' | 'cjs' | 'unknown'; 6 + 7 + /** 8 + * information about a module's format and exports. 9 + */ 10 + export interface ModuleInfo { 11 + /** detected module format */ 12 + type: ModuleType; 13 + /** whether the module has a default export */ 14 + hasDefaultExport: boolean; 15 + /** 16 + * detected named exports. 17 + * for ESM: export names from export statements. 18 + * for CJS: static property assignments to exports/module.exports. 19 + */ 20 + namedExports: string[]; 21 + } 22 + 23 + // #endregion 24 + 25 + // #region helpers 26 + 27 + /** 28 + * checks if a node is a string literal with type "Literal". 29 + */ 30 + function isStringLiteral(node: unknown): node is { type: 'Literal'; value: string } { 31 + return ( 32 + typeof node === 'object' && 33 + node !== null && 34 + (node as { type: string }).type === 'Literal' && 35 + typeof (node as { value: unknown }).value === 'string' 36 + ); 37 + } 38 + 39 + /** 40 + * checks if an expression is an identifier with the given name. 41 + */ 42 + function isIdentifier(node: Expression | null | undefined, name: string): boolean { 43 + if (!node) { 44 + return false; 45 + } 46 + // IdentifierReference and IdentifierName both have type "Identifier" and name property 47 + return node.type === 'Identifier' && (node as { name: string }).name === name; 48 + } 49 + 50 + /** 51 + * checks if an expression is `exports` or `module.exports`. 52 + */ 53 + function isExportsObject(node: Expression): boolean { 54 + if (isIdentifier(node, 'exports')) { 55 + return true; 56 + } 57 + 58 + // module.exports 59 + if (node.type === 'MemberExpression') { 60 + const memberExpr = node as StaticMemberExpression; 61 + if (!memberExpr.computed) { 62 + const obj = memberExpr.object; 63 + const prop = memberExpr.property; 64 + return isIdentifier(obj, 'module') && prop.type === 'Identifier' && prop.name === 'exports'; 65 + } 66 + } 67 + 68 + return false; 69 + } 70 + 71 + /** 72 + * gets the property name from a static member expression. 73 + */ 74 + function getStaticPropertyName(node: StaticMemberExpression): string | null { 75 + if (node.computed) { 76 + // computed property like exports["foo"] 77 + const prop = node.property as unknown as Expression; 78 + if (isStringLiteral(prop)) { 79 + return prop.value; 80 + } 81 + return null; 82 + } 83 + 84 + // non-computed like exports.foo 85 + const prop = node.property; 86 + if (prop.type === 'Identifier') { 87 + return prop.name; 88 + } 89 + 90 + return null; 91 + } 92 + 93 + /** 94 + * extracts property names from an object expression (for `module.exports = { a, b }`). 95 + */ 96 + function extractObjectPropertyNames(node: Expression): string[] { 97 + if (node.type !== 'ObjectExpression') { 98 + return []; 99 + } 100 + 101 + const names: string[] = []; 102 + for (const prop of node.properties) { 103 + if (prop.type === 'SpreadElement') { 104 + continue; 105 + } 106 + 107 + if (prop.type === 'Property') { 108 + const key = prop.key; 109 + if (key.type === 'Identifier') { 110 + names.push((key as { name: string }).name); 111 + } else if (isStringLiteral(key)) { 112 + names.push(key.value); 113 + } 114 + } 115 + } 116 + 117 + return names; 118 + } 119 + 120 + // #endregion 121 + 122 + // #region detection 123 + 124 + /** 125 + * checks if an expression is a require() call. 126 + */ 127 + function isRequireCall(expr: Expression): boolean { 128 + return expr.type === 'CallExpression' && isIdentifier(expr.callee as Expression, 'require'); 129 + } 130 + 131 + /** 132 + * checks an expression for CJS export patterns. 133 + * returns the export names found, or null if not a CJS pattern. 134 + */ 135 + function checkCjsExpression(expr: Expression): string[] | null { 136 + // assignment expressions: exports.foo = ... or module.exports = ... 137 + if (expr.type === 'AssignmentExpression' && expr.operator === '=') { 138 + const left = expr.left; 139 + 140 + // exports.foo = ... or module.exports.foo = ... 141 + if (left.type === 'MemberExpression') { 142 + const memberExpr = left as unknown as StaticMemberExpression; 143 + const obj = memberExpr.object; 144 + 145 + // direct assignment to exports.propertyName 146 + if (isExportsObject(obj)) { 147 + const propName = getStaticPropertyName(memberExpr); 148 + if (propName !== null) { 149 + return [propName]; 150 + } 151 + return []; 152 + } 153 + 154 + // module.exports = require('...') - CJS re-export 155 + if (isExportsObject(left as unknown as Expression)) { 156 + if (isRequireCall(expr.right)) { 157 + // re-export, we can't know the exports statically 158 + return []; 159 + } 160 + // module.exports = { a, b } 161 + return extractObjectPropertyNames(expr.right); 162 + } 163 + } 164 + } 165 + 166 + // Object.defineProperty(exports, 'name', ...) or Object.defineProperty(module.exports, 'name', ...) 167 + if (expr.type === 'CallExpression') { 168 + const callee = expr.callee; 169 + 170 + if (callee.type === 'MemberExpression') { 171 + const memberCallee = callee as StaticMemberExpression; 172 + if (!memberCallee.computed && isIdentifier(memberCallee.object, 'Object')) { 173 + const prop = memberCallee.property; 174 + if (prop.type === 'Identifier' && prop.name === 'defineProperty') { 175 + const args = expr.arguments; 176 + if (args.length >= 2) { 177 + const target = args[0]; 178 + const propArg = args[1]; 179 + 180 + if ( 181 + target.type !== 'SpreadElement' && 182 + isExportsObject(target) && 183 + propArg.type !== 'SpreadElement' && 184 + isStringLiteral(propArg) 185 + ) { 186 + return [propArg.value]; 187 + } 188 + } 189 + } 190 + } 191 + } 192 + } 193 + 194 + return null; 195 + } 196 + 197 + /** 198 + * checks a statement for CJS patterns and extracts export info. 199 + * returns the export names found, or null if not a CJS pattern. 200 + */ 201 + function checkCjsStatement(stmt: Statement): string[] | null { 202 + // handle expression statements 203 + if (stmt.type === 'ExpressionStatement') { 204 + return checkCjsExpression(stmt.expression); 205 + } 206 + 207 + // handle if statements - check both branches for CJS patterns 208 + // e.g., if (process.env.NODE_ENV === 'production') module.exports = require('./prod') 209 + if (stmt.type === 'IfStatement') { 210 + let result: string[] | null = null; 211 + 212 + // check consequent 213 + if (stmt.consequent.type === 'ExpressionStatement') { 214 + result = checkCjsExpression(stmt.consequent.expression); 215 + } else if (stmt.consequent.type === 'BlockStatement') { 216 + for (const s of stmt.consequent.body) { 217 + const r = checkCjsStatement(s); 218 + if (r !== null) { 219 + result = result ? [...result, ...r] : r; 220 + } 221 + } 222 + } 223 + 224 + // check alternate 225 + if (stmt.alternate) { 226 + if (stmt.alternate.type === 'ExpressionStatement') { 227 + const r = checkCjsExpression(stmt.alternate.expression); 228 + if (r !== null) { 229 + result = result ? [...result, ...r] : r; 230 + } 231 + } else if (stmt.alternate.type === 'BlockStatement') { 232 + for (const s of stmt.alternate.body) { 233 + const r = checkCjsStatement(s); 234 + if (r !== null) { 235 + result = result ? [...result, ...r] : r; 236 + } 237 + } 238 + } else if (stmt.alternate.type === 'IfStatement') { 239 + const r = checkCjsStatement(stmt.alternate); 240 + if (r !== null) { 241 + result = result ? [...result, ...r] : r; 242 + } 243 + } 244 + } 245 + 246 + return result; 247 + } 248 + 249 + return null; 250 + } 251 + 252 + /** 253 + * analyzes an Oxc AST to determine the module format and exports. 254 + * 255 + * @param ast the parsed program AST 256 + * @returns module info with type, default export flag, and named exports 257 + */ 258 + export function analyzeModule(ast: Program): ModuleInfo { 259 + let type: ModuleType = 'unknown'; 260 + let hasDefaultExport = false; 261 + const namedExports: string[] = []; 262 + 263 + for (const node of ast.body) { 264 + // ESM: import declarations 265 + if (node.type === 'ImportDeclaration') { 266 + type = 'esm'; 267 + continue; 268 + } 269 + 270 + // ESM: export default 271 + if (node.type === 'ExportDefaultDeclaration') { 272 + type = 'esm'; 273 + hasDefaultExport = true; 274 + continue; 275 + } 276 + 277 + // ESM: export all (export * from '...') 278 + if (node.type === 'ExportAllDeclaration') { 279 + type = 'esm'; 280 + // star exports don't add to namedExports since we can't know them statically 281 + continue; 282 + } 283 + 284 + // ESM: named exports 285 + if (node.type === 'ExportNamedDeclaration') { 286 + type = 'esm'; 287 + 288 + // export { a, b } or export { a } from '...' 289 + for (const spec of node.specifiers) { 290 + const exported = spec.exported; 291 + let name: string; 292 + 293 + if (exported.type === 'Identifier') { 294 + name = (exported as { name: string }).name; 295 + } else if (isStringLiteral(exported)) { 296 + name = exported.value; 297 + } else { 298 + continue; 299 + } 300 + 301 + if (name === 'default') { 302 + hasDefaultExport = true; 303 + } else { 304 + namedExports.push(name); 305 + } 306 + } 307 + 308 + // export const foo = ... or export function bar() {} 309 + if (node.declaration) { 310 + const decl = node.declaration; 311 + 312 + if (decl.type === 'VariableDeclaration') { 313 + for (const declarator of decl.declarations) { 314 + if (declarator.id.type === 'Identifier') { 315 + namedExports.push((declarator.id as { name: string }).name); 316 + } 317 + } 318 + } else if (decl.type === 'FunctionDeclaration' || decl.type === 'ClassDeclaration') { 319 + if (decl.id) { 320 + namedExports.push((decl.id as { name: string }).name); 321 + } 322 + } 323 + } 324 + 325 + continue; 326 + } 327 + 328 + // ESM: import.meta usage 329 + if (node.type === 'ExpressionStatement') { 330 + if (containsImportMeta(node.expression)) { 331 + type = 'esm'; 332 + continue; 333 + } 334 + } 335 + 336 + // CJS detection (only if not already ESM) 337 + if (type !== 'esm') { 338 + const cjsExports = checkCjsStatement(node); 339 + if (cjsExports !== null) { 340 + type = 'cjs'; 341 + namedExports.push(...cjsExports); 342 + } 343 + } 344 + } 345 + 346 + return { type, hasDefaultExport, namedExports }; 347 + } 348 + 349 + /** 350 + * recursively checks if an expression contains import.meta. 351 + */ 352 + function containsImportMeta(expr: Expression): boolean { 353 + if (expr.type === 'MetaProperty') { 354 + const meta = expr.meta; 355 + const prop = expr.property; 356 + return meta.name === 'import' && prop.name === 'meta'; 357 + } 358 + 359 + if (expr.type === 'MemberExpression') { 360 + const memberExpr = expr as StaticMemberExpression; 361 + return containsImportMeta(memberExpr.object); 362 + } 363 + 364 + if (expr.type === 'CallExpression') { 365 + // check callee and arguments 366 + if (containsImportMeta(expr.callee as Expression)) { 367 + return true; 368 + } 369 + for (const arg of expr.arguments) { 370 + if (arg.type !== 'SpreadElement' && containsImportMeta(arg)) { 371 + return true; 372 + } 373 + } 374 + } 375 + 376 + if (expr.type === 'BinaryExpression' || expr.type === 'LogicalExpression') { 377 + return containsImportMeta(expr.left as Expression) || containsImportMeta(expr.right); 378 + } 379 + 380 + if (expr.type === 'UnaryExpression') { 381 + return containsImportMeta(expr.argument); 382 + } 383 + 384 + if (expr.type === 'ConditionalExpression') { 385 + return ( 386 + containsImportMeta(expr.test) || 387 + containsImportMeta(expr.consequent) || 388 + containsImportMeta(expr.alternate) 389 + ); 390 + } 391 + 392 + return false; 393 + } 394 + 395 + // #endregion
+1
src/npm/worker-protocol.ts
··· 76 76 gzipSize: v.number(), 77 77 brotliSize: v.optional(v.number()), 78 78 exports: v.array(v.string()), 79 + isCjs: v.boolean(), 79 80 }); 80 81 81 82 // #endregion