Skip to content

Provider-global imports are prepended to node_modules sources, breaking packages that declare a matching name #6979

Description

@chinigo

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:

  1. providers: { postgresql: "..." } in sst.config.tsone provider is enough.
  2. 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.
  3. 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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions