Feature description
Native ESM support for graphile-worker, ideally shipped as a dual ESM/CJS package: an exports map with import/require/types conditions plus a real ESM build alongside the existing CJS one. Today it's CJS-only ("type": "commonjs", main: dist/index.js, no module/exports, built with bare tsc).
Since the codebase is already TypeScript, this is a build + ESM-correctness pass, not an application-logic rewrite. But it's also more than just adding an exports map. Concretely, for this codebase:
- Dual emit. The current
prepack runs bare tsc; a dual CJS+ESM output needs either two tsc passes (different module targets) or a build tool (tshy/tsdown/pkgroll).
exports map with import/require/types conditions, preserving engines.node >= 14 and existing CJS consumers.
- Explicit relative import extensions (
./foo → ./foo.js) — required by Node ESM resolution; mechanical but repo-wide if the source currently uses extensionless imports.
__dirname → import.meta.url for locating the shipped sql/ migration directory (and any other module-relative file resolution). ESM has no __dirname; this is a correctness change, not just packaging.
- Dynamic
require() → import() in the task/crontab/cosmiconfig loaders — the trickiest part, since it turns sync resolution async.
If the full dual build is too invasive near-term, even just publishing the existing named exports through an exports map (so bundlers and Node resolve deliberately rather than via legacy main) would already help bundler tracing, as an incremental first step.
Alternative: ESM-only. If maintaining two output formats isn't worth it, a clean ESM-only build (next major) is also a good outcome from our side. It solves the bundler-tracing problem outright. The trade-off is a Node-floor bump and dropping require() consumers; details in Breaking changes below.
Motivating example
We run graphile-worker (0.16.6) as the job queue in a Node 24, fully-ESM, TypeScript monorepo. The web service is bundled with Nitro/Rollup into a self-contained server bundle.
When a bundler statically analyzes the dependency graph it inlines ESM imports cleanly, but graphile-worker's internals use CommonJS require() that bundlers can't safely rewrite/trace, so the calls survive into the output as unresolved external requires:
require("pg") (from graphile-worker's pool handling) → crashes at runtime with Cannot find module 'pg' unless the bundler is specifically told to trace pg separately.
require("typescript") (via cosmiconfig's TS-config loader, reached during config search at import time) → also left external.
Our own import pg from "pg" and our ORM's pg usage bundle fine — it's specifically the CJS require()s inside graphile-worker that escape. We currently work around it with a bundler-specific traceDeps: ["pg"] directive, but that's a per-consumer band-aid for what is fundamentally a packaging gap.
This isn't specific to us: anyone deploying graphile-worker in a bundled ESM target (Nitro, esbuild, webpack, Vite SSR, serverless/edge containers) hits the same wall, and ESM-native is increasingly the default for new Node services.
Repro: any ESM app importing graphile-worker and bundled with Rollup/esbuild/Nitro will show __require("pg") / require("pg") left external in the output.
Breaking changes
Dual-package path (preferred): none expected. Dual packaging keeps existing CJS consumers (require("graphile-worker")) working unchanged while ESM consumers gain a real import. The one hazard to manage is the classic dual-package "two instances" problem if both entrypoints are loaded in the same process; avoidable by keeping shared runtime state out of module scope (or having the CJS entry re-export the ESM build).
ESM-only path (alternative, next major): breaking, but bounded.
- Drops CJS consumers.
require("graphile-worker") stops working; CJS users must migrate to import or pin the last CJS major. This is the real breaking change and the reason it warrants a major.
- Node floor bump. Everything
graphile-worker does has ESM equivalents since Node 14.8 (import.meta.url + fileURLToPath for the sql/ dir, dynamic import() + pathToFileURL for task/crontab/config loading, top-level await for the sync→async loader rework). The practical floor is higher: if require.resolve() for package specifiers is replaced with import.meta.resolve(), or cosmiconfig's TS-config loading is kept via module.register(), both need Node ≥ 20.6 (synchronous/stable; earlier it was flagged/async). Recommended engines.node: >= 20.19 as a hard floor, with the real target being a supported LTS (Node 18 is EOL, Node 20 reaches EOL mid-2026, so realistically ≥ 22).
- No API-surface changes beyond the module system itself — the named exports stay the same.
Supporting development
I [tick all that apply]:
Feature description
Native ESM support for
graphile-worker, ideally shipped as a dual ESM/CJS package: anexportsmap withimport/require/typesconditions plus a real ESM build alongside the existing CJS one. Today it's CJS-only ("type": "commonjs",main: dist/index.js, nomodule/exports, built with baretsc).Since the codebase is already TypeScript, this is a build + ESM-correctness pass, not an application-logic rewrite. But it's also more than just adding an
exportsmap. Concretely, for this codebase:prepackruns baretsc; a dual CJS+ESM output needs either twotscpasses (differentmoduletargets) or a build tool (tshy/tsdown/pkgroll).exportsmap withimport/require/typesconditions, preservingengines.node >= 14and existing CJS consumers../foo→./foo.js) — required by Node ESM resolution; mechanical but repo-wide if the source currently uses extensionless imports.__dirname→import.meta.urlfor locating the shippedsql/migration directory (and any other module-relative file resolution). ESM has no__dirname; this is a correctness change, not just packaging.require()→import()in the task/crontab/cosmiconfigloaders — the trickiest part, since it turns sync resolution async.If the full dual build is too invasive near-term, even just publishing the existing named exports through an
exportsmap (so bundlers and Node resolve deliberately rather than via legacymain) would already help bundler tracing, as an incremental first step.Alternative: ESM-only. If maintaining two output formats isn't worth it, a clean ESM-only build (next major) is also a good outcome from our side. It solves the bundler-tracing problem outright. The trade-off is a Node-floor bump and dropping
require()consumers; details in Breaking changes below.Motivating example
We run
graphile-worker(0.16.6) as the job queue in a Node 24, fully-ESM, TypeScript monorepo. The web service is bundled with Nitro/Rollup into a self-contained server bundle.When a bundler statically analyzes the dependency graph it inlines ESM imports cleanly, but
graphile-worker's internals use CommonJSrequire()that bundlers can't safely rewrite/trace, so the calls survive into the output as unresolved external requires:require("pg")(fromgraphile-worker's pool handling) → crashes at runtime withCannot find module 'pg'unless the bundler is specifically told to tracepgseparately.require("typescript")(viacosmiconfig's TS-config loader, reached during config search at import time) → also left external.Our own
import pg from "pg"and our ORM'spgusage bundle fine — it's specifically the CJSrequire()s insidegraphile-workerthat escape. We currently work around it with a bundler-specifictraceDeps: ["pg"]directive, but that's a per-consumer band-aid for what is fundamentally a packaging gap.This isn't specific to us: anyone deploying
graphile-workerin a bundled ESM target (Nitro, esbuild, webpack, Vite SSR, serverless/edge containers) hits the same wall, and ESM-native is increasingly the default for new Node services.Repro: any ESM app importing
graphile-workerand bundled with Rollup/esbuild/Nitro will show__require("pg")/require("pg")left external in the output.Breaking changes
Dual-package path (preferred): none expected. Dual packaging keeps existing CJS consumers (
require("graphile-worker")) working unchanged while ESM consumers gain a realimport. The one hazard to manage is the classic dual-package "two instances" problem if both entrypoints are loaded in the same process; avoidable by keeping shared runtime state out of module scope (or having the CJS entry re-export the ESM build).ESM-only path (alternative, next major): breaking, but bounded.
require("graphile-worker")stops working; CJS users must migrate toimportor pin the last CJS major. This is the real breaking change and the reason it warrants a major.graphile-workerdoes has ESM equivalents since Node 14.8 (import.meta.url+fileURLToPathfor thesql/dir, dynamicimport()+pathToFileURLfor task/crontab/config loading, top-level await for the sync→async loader rework). The practical floor is higher: ifrequire.resolve()for package specifiers is replaced withimport.meta.resolve(), orcosmiconfig's TS-config loading is kept viamodule.register(), both need Node ≥ 20.6 (synchronous/stable; earlier it was flagged/async). Recommendedengines.node:>= 20.19as a hard floor, with the real target being a supported LTS (Node 18 is EOL, Node 20 reaches EOL mid-2026, so realistically ≥ 22).Supporting development
I [tick all that apply]: