Skip to content

build: rstest node environment silently breaks named imports from goog-style CJS modules (google-protobuf *_pb) #956

Description

@bpowers

Problem

rstest's testEnvironment: 'node' externalizes node_modules dependencies, so an ESM import { X } from '<cjs-dep>' in code under test goes through Node's native ESM-CJS interop. Node detects named exports from CJS via cjs-module-lexer static analysis -- but google-protobuf's generated *_pb.js modules (e.g. google-protobuf/google/protobuf/timestamp_pb) export via goog.object.extend(exports, proto.google.protobuf), which is invisible to that static analysis. The imported binding is silently undefined at runtime (the namespace object contains only default).

Production is unaffected: the server compiles with tsc to CommonJS require, which works fine. This makes it a test-environment-only landmine that surfaces as a confusing TypeError: ...Timestamp is not a constructor deep inside otherwise-correct code. In src/server/authn.ts's case the throw was additionally swallowed by a broad catch, so it manifested only as a wrong HTTP status.

Why it matters

  • Silent failure mode: the import succeeds, the binding is undefined, and the failure surfaces far from the cause -- potentially masked entirely by error handling (as happened in authn.ts).
  • Test/production divergence: tests exercise a different module-loading path than production, so a passing or failing test says nothing reliable about the CJS-interop-affected code path.
  • Same family of hazard as build: rstest 'prefer-alias' disables tsconfig paths, silently resolving unaliased @simlin/* imports to stale built lib/ #911 (rstest module resolution silently diverging from production), but a distinct root cause: ESM-CJS named-export interop rather than alias resolution.

Components affected

  • src/server (mitigated -- see below): authn.ts, api.ts, and project-creation.ts all import Timestamp from google-protobuf/google/protobuf/timestamp_pb.
  • Residual risk: any other workspace package that runs rstest with testEnvironment: 'node' and imports a google-protobuf subpath -- or any other CJS dep whose exports cjs-module-lexer cannot see -- will hit the same silent-undefined failure cold.

Mitigation applied (server only)

On branch production-risk-burndown, src/server/rstest.config.mts gained output.bundleDependencies: ['google-protobuf/google/protobuf/timestamp_pb']. Bundling routes the module through rspack's dynamic CJS interop (the same path the local bundled src/server/schemas/*_pb.js files already take), while the jspb runtime stays external so only one copy exists.

Possible approaches

  • A shared rstest base config (or documented convention in docs/dev/typescript.md) that bundles known goog-style CJS deps for every package using testEnvironment: 'node'.
  • A lint/CI check flagging named ESM imports from google-protobuf/** subpaths in packages tested under rstest.
  • Longer term: migrate off goog-style generated protobuf JS (e.g. protobuf-es), which removes the lexer-invisible export pattern entirely.

Context

Identified while adding tests for #927 on branch production-risk-burndown: the temp-user-creation path in src/server/authn.ts threw under rstest because Timestamp was undefined.

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