So last week I built up https://github.com/jmchilton/galaxy-tool-util-ts which has nice docs at https://jmchilton.github.io/galaxy-tool-util-ts/#/. To port a lot of functionality I'm building up for workflow validation to native TypeScript for #83 & #21. I was not really aware of CJS vs ESM issues before I started and I still only vaguely understand. My Claude allocations for the week is burning up fast so I have a ChatGPT "plan" for ingesting ESM modules more natively in this project - I'm not sure if it is worth doing that or if I should allow galaxy-tool-util modules above to dual publish as CJS / ESM. Any strong opinions either way?
Here’s a practical, low-risk migration plan for
galaxy-workflows-vscode to:
✅ Author in ESM (TypeScript)
✅ Ship CJS to VS Code (extension + server)
✅ Avoid runtime/module headaches entirely
This follows the “modern inside, boring outside” approach that works best for VS Code + LSP.
🧭 High-level strategy
You’re going to:
Convert source to ESM-style TS
Introduce a bundler (tsup)
Output CJS artifacts for runtime
Stop relying on Node module resolution at runtime
👉 Result:
Clean modern codebase
Stable VS Code execution
No ESM/CJS interop pain at runtime
🏗️ Step-by-step plan
- Install tsup
npm install --save-dev tsup
- Update TypeScript config (ESM authoring)
Update tsconfig.json:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2020",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true
}
}
Why this matters
"module": "ESNext" → lets you write real ESM
"moduleResolution": "Bundler" → avoids .js extension hell
3. Convert source to ESM style
Replace:
const foo = require('./foo')
module.exports = { foo }
With:
import { foo } from './foo'
export { foo }
⚠️ Important rules
❌ Don’t add .js extensions
import { foo } from './foo' // ✅ good
(tsup will resolve it)
❌ Avoid default exports (strong recommendation)
// avoid
export default something
// prefer
export function something() {}
👉 avoids CJS interop weirdness later
- Add tsup config
Create tsup.config.ts:
import { defineConfig } from 'tsup'
export default defineConfig({
entry: {
extension: 'src/extension.ts',
server: 'src/server.ts'
},
format: ['cjs'], // VS Code requires this
platform: 'node',
target: 'node18', // match VS Code runtime
sourcemap: true,
clean: true,
splitting: false, // IMPORTANT for VS Code
external: ['vscode'], // never bundle this
})
5. Update package.json
Replace build scripts:
{
"scripts": {
"build": "tsup",
"watch": "tsup --watch"
},
"main": "./dist/extension.js"
}
Optional but recommended
Remove:
"type": "module"
👉 Since output is CJS, this avoids confusion
- Fix server startup (important)
Your extension likely does something like:
const serverModule = context.asAbsolutePath('dist/server.js')
That’s still correct 👍
Because:
tsup outputs CJS
VS Code loads it via require
👉 No change needed if you keep CJS output
- Clean up old build pipeline
Remove:
tsc build step (optional)
webpack configs (if any)
manual copying scripts
👉 tsup replaces all of it
- Verify debugging still works
Update .vscode/launch.json if needed:
{
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
}
9. Test end-to-end
Check:
Extension activates
LSP starts
IPC works
No runtime import errors
🧠 Optional: future-proof for dual-mode library
Since you mentioned your library:
👉 This setup plays very nicely with dual-mode deps
Because:
tsup will bundle or normalize imports
your extension doesn’t care if deps are ESM or CJS
🚨 Common pitfalls (specific to this repo type)
- Accidentally bundling vscode
external: ['vscode'] // REQUIRED
- Dynamic require
require(somePath) // ❌ breaks bundling
Fix:
static imports
or mark as external
3. Multiple entrypoints + shared state
Since you have:
extension
server
👉 They will be bundled separately
If you rely on shared singletons across them:
that will break (they are separate processes anyway)
4. Path resolution issues
If you use:
__dirname
Replace with:
import { fileURLToPath } from 'url'
(only if needed—bundling often eliminates this)
🎯 Final architecture
src/
extension.ts (ESM authoring)
server.ts (ESM authoring)
dist/
extension.js (CJS bundled)
server.js (CJS bundled)
👍 Why this works so well
You get:
✅ Modern ESM code
✅ Zero runtime module issues
✅ No loader flags
✅ No dual-package complexity in this repo
✅ Compatibility with VS Code
So last week I built up https://github.com/jmchilton/galaxy-tool-util-ts which has nice docs at https://jmchilton.github.io/galaxy-tool-util-ts/#/. To port a lot of functionality I'm building up for workflow validation to native TypeScript for #83 & #21. I was not really aware of CJS vs ESM issues before I started and I still only vaguely understand. My Claude allocations for the week is burning up fast so I have a ChatGPT "plan" for ingesting ESM modules more natively in this project - I'm not sure if it is worth doing that or if I should allow galaxy-tool-util modules above to dual publish as CJS / ESM. Any strong opinions either way?
Here’s a practical, low-risk migration plan for
galaxy-workflows-vscode to:
✅ Author in ESM (TypeScript)
✅ Ship CJS to VS Code (extension + server)
✅ Avoid runtime/module headaches entirely
This follows the “modern inside, boring outside” approach that works best for VS Code + LSP.
🧭 High-level strategy
You’re going to:
Convert source to ESM-style TS
Introduce a bundler (tsup)
Output CJS artifacts for runtime
Stop relying on Node module resolution at runtime
👉 Result:
Clean modern codebase
Stable VS Code execution
No ESM/CJS interop pain at runtime
🏗️ Step-by-step plan
npm install --save-dev tsup
Update tsconfig.json:
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ES2020",
"outDir": "dist",
"rootDir": "src",
"esModuleInterop": true,
"resolveJsonModule": true,
"strict": true
}
}
Why this matters
"module": "ESNext" → lets you write real ESM
"moduleResolution": "Bundler" → avoids .js extension hell
3. Convert source to ESM style
Replace:
const foo = require('./foo')
module.exports = { foo }
With:
import { foo } from './foo'
export { foo }
⚠️ Important rules
❌ Don’t add .js extensions
import { foo } from './foo' // ✅ good
(tsup will resolve it)
❌ Avoid default exports (strong recommendation)
// avoid
export default something
// prefer
export function something() {}
👉 avoids CJS interop weirdness later
Create tsup.config.ts:
import { defineConfig } from 'tsup'
export default defineConfig({
entry: {
extension: 'src/extension.ts',
server: 'src/server.ts'
},
format: ['cjs'], // VS Code requires this
platform: 'node',
target: 'node18', // match VS Code runtime
sourcemap: true,
clean: true,
splitting: false, // IMPORTANT for VS Code
external: ['vscode'], // never bundle this
})
5. Update package.json
Replace build scripts:
{
"scripts": {
"build": "tsup",
"watch": "tsup --watch"
},
"main": "./dist/extension.js"
}
Optional but recommended
Remove:
"type": "module"
👉 Since output is CJS, this avoids confusion
Your extension likely does something like:
const serverModule = context.asAbsolutePath('dist/server.js')
That’s still correct 👍
Because:
tsup outputs CJS
VS Code loads it via require
👉 No change needed if you keep CJS output
Remove:
tsc build step (optional)
webpack configs (if any)
manual copying scripts
👉 tsup replaces all of it
Update .vscode/launch.json if needed:
{
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
}
9. Test end-to-end
Check:
Extension activates
LSP starts
IPC works
No runtime import errors
🧠 Optional: future-proof for dual-mode library
Since you mentioned your library:
👉 This setup plays very nicely with dual-mode deps
Because:
tsup will bundle or normalize imports
your extension doesn’t care if deps are ESM or CJS
🚨 Common pitfalls (specific to this repo type)
external: ['vscode'] // REQUIRED
require(somePath) // ❌ breaks bundling
Fix:
static imports
or mark as external
3. Multiple entrypoints + shared state
Since you have:
extension
server
👉 They will be bundled separately
If you rely on shared singletons across them:
that will break (they are separate processes anyway)
4. Path resolution issues
If you use:
__dirname
Replace with:
import { fileURLToPath } from 'url'
(only if needed—bundling often eliminates this)
🎯 Final architecture
src/
extension.ts (ESM authoring)
server.ts (ESM authoring)
dist/
extension.js (CJS bundled)
server.js (CJS bundled)
👍 Why this works so well
You get:
✅ Modern ESM code
✅ Zero runtime module issues
✅ No loader flags
✅ No dual-package complexity in this repo
✅ Compatibility with VS Code