Skip to content

Support ESM Dependencies? #84

@jmchilton

Description

@jmchilton

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

  1. Install tsup
    npm install --save-dev tsup
  2. 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

  1. 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

  1. 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

  1. Clean up old build pipeline

Remove:

tsc build step (optional)
webpack configs (if any)
manual copying scripts

👉 tsup replaces all of it

  1. 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)

  1. Accidentally bundling vscode
    external: ['vscode'] // REQUIRED
  2. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions