Attribution
- Claude wrote this bug report, everything below this "Attribution" stanza (apart from some minor edits).
- Regular old human me, Mike Chinigo, reviewed this report in detail. I vouch for it in its entirety.
- I also verified the linked minimal reproduction manually. Works on my box (i.e. fails as described).
- My (closed source commercial) repo has a hack fix around this problem, by using pnpm to patch the
sql-formatter dependency that causes the namespace conflict.
Thanks!
~Mike
Summary
During the run-phase config build, SST prepends one import * as <provider> from "@pulumi/<provider>" line per configured provider to the source text of every node_modules module. Any ESM module in the dependency graph that declares a top-level binding of the same name — for example sql-formatter, which exports const postgresql — then contains two declarations of that identifier in one scope, and esbuild rejects the build.
Minimal reproduction
https://github.com/chinigo/sst-provider-global-collision
Steps to reproduce
Full reproduction, including a repro.sh that runs the whole sequence: https://github.com/chinigo/sst-provider-global-collision
git clone https://github.com/chinigo/sst-provider-global-collision
cd sst-provider-global-collision
./repro.sh
Equivalently, by hand:
npm install
npx sst install # succeeds
npx sst deploy --stage repro # fails while bundling
The reproduction is three ingredients:
providers: { postgresql: "..." } in sst.config.ts — one provider is enough.
- A dependency whose ESM build declares a top-level binding named
postgresql. The repro uses sql-formatter@15.7.3, whose dist/esm/languages/postgresql/postgresql.formatter.js has export const postgresql = { at line 261.
run() reaching that dependency. (It must be pulled in via await import(), since SST rejects top-level imports in sst.config.ts.)
Expected behavior
sst deploy bundles the config successfully. A third-party package's choice of top-level identifiers is its own business, and should not have to avoid the names of the Pulumi providers the consuming app happens to configure.
Actual behavior
The build fails:
| Error node_modules/sql-formatter/dist/esm/languages/postgresql/postgresql.formatter.js:263:13: The symbol "postgresql" has already been declared
✕ Unexpected error occurred. Please run with --print-logs or check .sst/log/sst.log if available.
.sst/log/sst.log shows the config being built twice, with only the second build failing:
level=INFO msg="esbuild building" out=.../.sst/platform/sst.config.1787433733959.mjs
level=INFO msg="esbuild built" outfile=.../.sst/platform/sst.config.1787433733959.mjs
level=INFO msg="evaluating config"
level=INFO msg="config evaluated"
level=INFO msg="esbuild building" out=.../.sst/platform/sst.config.1787433734009.mjs
level=ERROR msg="esbuild error" text="The symbol \"postgresql\" has already been declared"
The reported line number is the module's own declaration shifted by the number of injected lines. Capturing the generated sst.config.*.mjs from a passing run (by patching the offending package) shows the injected imports, one copy per third-party module — 683 copies in our real application, each renamed by esbuild in the output:
// ../node_modules/sql-formatter/dist/esm/languages/postgresql/postgresql.formatter.js
import * as rabbitmq633 from "@pulumi/rabbitmq";
import * as postgresql635 from "@pulumi/postgresql";
...
Additional observations
These may help narrow it down:
- Only the run-phase build injects.
sst install succeeds on the same tree, because it stops after the first build. sst diff fails identically once the stage exists; on a fresh stage it only appears to pass because it exits at Stage not found before reaching the second build.
- First-party modules are not injected. An identical
export const postgresql = { ... } in a first-party file, imported by run() in the same failing build, never collides. Only node_modules sources are affected.
- CJS dependencies are unaffected, since esbuild wraps them in a function scope — the collision needs an ESM module whose top-level bindings share the output scope.
- The offset is exactly
providers + 1. With 1 provider the error moves to line 263, with 3 to line 265; our 8-provider application reported line 270 for the same declaration at line 261. That is consistent with one line per provider plus one sst import.
- The name space is the provider list, so the exposure is not limited to
postgresql: aws, command, random, grafana, rabbitmq, auth0 and dockerbuild are all plausible top-level identifiers in published packages.
Proposed fix
Use esbuild's inject option instead of prepending import statements to module source text. inject exists for exactly this purpose and is collision-safe by design: esbuild skips injection for any module that already declares the name, rather than producing a duplicate declaration.
We verified this directly. Bundling an entry that imports sql-formatter, with a shim exporting postgresql supplied via --inject:shim.js, builds cleanly — while concatenating the same provider imports onto the module's source reproduces the reported error at the reported line and column.
Two properties would follow:
- Packages could no longer break a build by choosing an identifier that matches a provider name.
- Reported error positions would stop being offset relative to the real file, which is what made this hard to diagnose — the cited line does not exist in the package as published.
If source-text prepending has to stay, restricting it to first-party sources would also resolve it, since first-party modules are the ones that actually reference the provider globals.
Environment
|
|
| SST |
4.1.0 |
esbuild (bundled in .sst/platform) |
0.21.5 |
| Node |
24.14.0 |
| Package manager |
pnpm 11.22.0 (repro repo uses npm) |
| OS |
macOS 26.6.2 arm64; also reproduced on GitHub Actions ubuntu-latest x86_64 |
| Offending package |
sql-formatter@15.7.3 |
Attribution
sql-formatterdependency that causes the namespace conflict.Thanks!
~Mike
Summary
During the run-phase config build, SST prepends one
import * as <provider> from "@pulumi/<provider>"line per configured provider to the source text of everynode_modulesmodule. Any ESM module in the dependency graph that declares a top-level binding of the same name — for examplesql-formatter, which exportsconst postgresql— then contains two declarations of that identifier in one scope, and esbuild rejects the build.Minimal reproduction
https://github.com/chinigo/sst-provider-global-collision
Steps to reproduce
Full reproduction, including a
repro.shthat runs the whole sequence: https://github.com/chinigo/sst-provider-global-collisiongit clone https://github.com/chinigo/sst-provider-global-collision cd sst-provider-global-collision ./repro.shEquivalently, by hand:
The reproduction is three ingredients:
providers: { postgresql: "..." }insst.config.ts— one provider is enough.postgresql. The repro usessql-formatter@15.7.3, whosedist/esm/languages/postgresql/postgresql.formatter.jshasexport const postgresql = {at line 261.run()reaching that dependency. (It must be pulled in viaawait import(), since SST rejects top-level imports insst.config.ts.)Expected behavior
sst deploybundles the config successfully. A third-party package's choice of top-level identifiers is its own business, and should not have to avoid the names of the Pulumi providers the consuming app happens to configure.Actual behavior
The build fails:
.sst/log/sst.logshows the config being built twice, with only the second build failing:The reported line number is the module's own declaration shifted by the number of injected lines. Capturing the generated
sst.config.*.mjsfrom a passing run (by patching the offending package) shows the injected imports, one copy per third-party module — 683 copies in our real application, each renamed by esbuild in the output:Additional observations
These may help narrow it down:
sst installsucceeds on the same tree, because it stops after the first build.sst difffails identically once the stage exists; on a fresh stage it only appears to pass because it exits atStage not foundbefore reaching the second build.export const postgresql = { ... }in a first-party file, imported byrun()in the same failing build, never collides. Onlynode_modulessources are affected.providers + 1. With 1 provider the error moves to line 263, with 3 to line 265; our 8-provider application reported line 270 for the same declaration at line 261. That is consistent with one line per provider plus onesstimport.postgresql:aws,command,random,grafana,rabbitmq,auth0anddockerbuildare all plausible top-level identifiers in published packages.Proposed fix
Use esbuild's
injectoption instead of prepending import statements to module source text.injectexists for exactly this purpose and is collision-safe by design: esbuild skips injection for any module that already declares the name, rather than producing a duplicate declaration.We verified this directly. Bundling an entry that imports
sql-formatter, with a shim exportingpostgresqlsupplied via--inject:shim.js, builds cleanly — while concatenating the same provider imports onto the module's source reproduces the reported error at the reported line and column.Two properties would follow:
If source-text prepending has to stay, restricting it to first-party sources would also resolve it, since first-party modules are the ones that actually reference the provider globals.
Environment
.sst/platform)ubuntu-latestx86_64sql-formatter@15.7.3