Skip to content

feat: fix esm exports#1470

Open
skurzyp-blockydevs wants to merge 2 commits intohashgraph:mainfrom
skurzyp-blockydevs:fix/esm-exports
Open

feat: fix esm exports#1470
skurzyp-blockydevs wants to merge 2 commits intohashgraph:mainfrom
skurzyp-blockydevs:fix/esm-exports

Conversation

@skurzyp-blockydevs
Copy link
Copy Markdown

@skurzyp-blockydevs skurzyp-blockydevs commented Apr 15, 2026

Important

This PR is not solving the problem! Please see comment below with further findings.

Disclaimer

Please consider this PR as a suggested approach to resolving this issue. I'm not familiar with the codebase and not able to test if it functions correctly. This implmenetation helped me build a packages that works fine for ESM projects and I'll proceed with testing it while bulding the stable-coin-studio-plugin for Hedera Agent Kit.

I'm aware that it changes multiple files and might have broke sth. Rationalities for those changes are presented below.

Related issue(s):

Fixes #1459, #1468

fix(sdk): migrate build system to tsup for ESM/CJS dual-package compatibility

Summary

Resolves critical ESM compatibility issues in @hashgraph/stablecoin-npm-sdk that prevented the package from being imported in any modern Node.js ESM project ("type": "module"). The fix migrates both the SDK and Contracts packages from raw tsc-based transpilation to a tsup (esbuild) bundled build system, following established patterns from the hedera-agent-kit project.


Problem

The SDK build (build/esm/) had three categories of bugs that made it impossible to import in Node.js ESM:

1. Missing .js extensions in 162+ relative imports

Node.js ESM requires explicit file extensions in all relative import specifiers (Node.js docs). The tsc output omitted them entirely:

// build/esm/src/index.js — broken
import { Network } from './port/in/Network'; // ❌ No extension

2. Illegal require() calls in ESM scope

HederaWalletConnectTransactionAdapter.ts used a CJS require() pattern guarded by a typeof window check. In an ESM bundle, require is not defined and throws a ReferenceError at parse time, before any runtime check can execute:

// Before — broken in ESM
const { SupportedWallets } = require('@hashgraph/stablecoin-npm-sdk') as any;
if (typeof window !== 'undefined') {
  const hwc = require('@hashgraph/hedera-wallet-connect'); // ❌ ReferenceError
}

3. Broken directory imports via exports wildcards

The @hashgraph/stablecoin-npm-contracts package exposed "./typechain-types/*" wildcard exports, and the SDK imported a directory:

import * as Factories from '@hashgraph/stablecoin-npm-contracts/typechain-types/factories/contracts'; // ❌

This violates the Node.js exports specification which does not support directory imports.


Solution

Migrate both packages to tsup, which uses esbuild internally. The bundler:

  • Resolves all internal relative paths at build time, eliminating the .js extension requirement
  • Produces proper .mjs files for ESM output, removing any ambiguity with Node.js module detection
  • Bundles internal dependencies that had their own broken ESM paths (e.g. @hashgraph/hedera-custodians-integration)

Changes

packages/contracts

tsup.config.ts (new)

  • Builds two entry points: typechain-types/index.ts and typechain-types/factories/index.ts
  • Outputs dual-format ESM (.mjs) + CJS (.js) with declaration files (.d.ts / .d.mts)

package.json

  • Updated main, module, types fields to point to dist/
  • Replaced the broken wildcard "./typechain-types/*" export with explicit named exports:
    • "." → main type index
    • "./factories" → factory contracts index
  • Updated build script: npm run compile && rimraf dist && tsup
  • devDependencies: added tsup

packages/sdk

tsup.config.ts (new)

Dual-configuration build:

  • ESM target: es2022, output dist/esm/index.mjs
  • CJS target: node16, output dist/cjs/index.js
  • external: ['@hashgraph/stablecoin-npm-contracts'] (workspace peer)
  • noExternal patterns for @hashgraph/*, @hiero-ledger/*, tsyringe, reflect-metadata — these are bundled in to work around broken ESM paths in their own outputs

package.json

  • Updated main, module, types to point to dist/cjs/ and dist/esm/
  • Replaced single-format exports with modern nested structure:
    ".": {
      "import": { "types": "..index.d.mts", "default": "..index.mjs" },
      "require": { "types": "..index.d.ts", "default": "..index.js" }
    }
    
  • Updated build script: rimraf dist && tsup
  • Updated clean script: targets dist/ instead of build/
  • devDependencies: added tsup, @swc/core, @swc/helpers (required for emitDecoratorMetadata support)

tsconfig.json

  • Changed moduleResolution from "node" to "bundler" (required for tsup compatibility)
  • Changed outDir from ./build/esm to ./dist
  • Added tsup.config.ts to include array

.eslintignore

  • Added tsup.config.ts (not part of tsconfig.project, excluded from linting)
  • Added dist/ (build output must not be linted)

Source Code Fixes

HederaWalletConnectTransactionAdapter.ts

Traditional synchronous require() calls and module-level typeof window guards were replaced with inline dynamic import() calls at the actual point of use.

// After
import { SupportedWallets } from '../../../../domain/context/network/Wallet';

// Inside initAdaptersAndProvider() — called only in browser context
const { HederaAdapter, HederaChainDefinition, hederaNamespace, HederaProvider }
= await import('@hashgraph/hedera-wallet-connect');

const { createAppKit } = await import('@reown/appkit');

Benefits:

  • Deterministic: Imports are awaited exactly where they are needed, eliminating race conditions.
  • ESM-Compatible: Dynamic import() is natively supported in ESM and correctly handled by the tsup bundler.
  • Node-Safe: Because dynamic imports are evaluated lazily at runtime, they never execute in a Node.js environment during package initialization, removing the need for broad typeof window !== 'undefined' blocks at the module level.
  • No Circular Imports: The SupportedWallets circular dependency was resolved by importing it directly from its source domain file instead of the SDK's public index.

TransactionService.ts

Fixed the directory import to use the new explicit contracts export:

// Before
import * as Factories from '@hashgraph/stablecoin-npm-contracts/typechain-types/factories/contracts'; // ❌

// After
import * as Factories from '@hashgraph/stablecoin-npm-contracts/factories'; // ✅

import type / export type fixes across public API files

Several public-facing files re-exported TypeScript interfaces and type aliases using plain import/export. While tsc is aware they are type-only and erases them silently, esbuild validates every import as a runtime binding and throws No matching export errors for constructs that produce no JavaScript output.

Affected files and their types:

File Symbols fixed
port/in/Account.ts StableCoinListViewModel, AccountViewModel (both interface)
port/in/Event.ts WalletEvent (type alias)
port/in/Network.ts InitializationData (interface)
port/in/StableCoin.ts StableCoinViewModel, StableCoinListViewModel, PaginationViewModel (all interface)
port/in/response/index.ts ConfigInfoViewModel, HoldViewModel (interface)
port/in/request/ConnectRequest.ts BaseRequest, RequestAccount (interface)
port/in/request/CreateRequest.ts RequestPublicKey (interface)
port/in/request/UpdateRequest.ts RequestPublicKey (interface)
port/in/request/GetTransactionsRequest.ts RequestPublicKey (interface)

The fix in each case is to add the type keyword:

// Before
import { InitializationData } from '../out/TransactionAdapter.js';
export { InitializationData };

// After
import type { InitializationData } from '../out/TransactionAdapter.js';
export type { InitializationData };

These changes are semantically correct and improve code clarity. Adding verbatimModuleSyntax: true to tsconfig.json would enforce this pattern automatically going forward.


.gitignore

Added **/dist/ to root and package-level .gitignore files to exclude the new tsup output directory.


Verification

After building with npm run build:contracts && npm run build:sdk, the ESM bundle can be loaded in Node.js without errors:

node -e "import('./packages/sdk/dist/esm/index.mjs').then(m => console.log('SDK Loaded! Exports:', Object.keys(m).length))"
# → SDK Loaded! Exports: 101

This was previously impossible and would throw ERR_UNSUPPORTED_DIR_IMPORT or ReferenceError: require is not defined.


Notes

  • @hiero-ledger/sdk is bundled (not externalized) into the SDK output as requested. This avoids runtime resolution failures caused by broken ESM paths in its own node_modules output.
  • @hashgraph/stablecoin-npm-contracts is externalized since it is a workspace peer and is always present alongside the SDK.
  • The SWC compiler (@swc/core) is added as a dev dependency to correctly handle emitDecoratorMetadata, which is required for the tsyringe DI container used throughout the SDK.
# fix(sdk): migrate build system to `tsup` for ESM/CJS dual-package compatibility

Summary

Resolves critical ESM compatibility issues in @hashgraph/stablecoin-npm-sdk that prevented the package from being imported in any modern Node.js ESM project ("type": "module"). The fix migrates both the SDK and Contracts packages from raw tsc-based transpilation to a tsup (esbuild) bundled build system, following established patterns from the hedera-agent-kit project.


Problem

The SDK build (build/esm/) had three categories of bugs that made it impossible to import in Node.js ESM:

1. Missing .js extensions in 162+ relative imports

Node.js ESM requires explicit file extensions in all relative import specifiers ([Node.js docs](https://nodejs.org/api/esm.html#mandatory-file-extensions)). The tsc output omitted them entirely:

// build/esm/src/index.js — broken
import { Network } from './port/in/Network'; // ❌ No extension

2. Illegal require() calls in ESM scope

HederaWalletConnectTransactionAdapter.ts used a CJS require() pattern guarded by a typeof window check. In an ESM bundle, require is not defined and throws a ReferenceError at parse time, before any runtime check can execute:

// Before — broken in ESM
const { SupportedWallets } = require('@hashgraph/stablecoin-npm-sdk') as any;
if (typeof window !== 'undefined') {
  const hwc = require('@hashgraph/hedera-wallet-connect'); // ❌ ReferenceError
}

3. Broken directory imports via exports wildcards

The @hashgraph/stablecoin-npm-contracts package exposed "./typechain-types/*" wildcard exports, and the SDK imported a directory:

import * as Factories from '@hashgraph/stablecoin-npm-contracts/typechain-types/factories/contracts'; // ❌

This violates the Node.js exports specification which does not support directory imports.


Solution

Migrate both packages to tsup, which uses esbuild internally. The bundler:

  • Resolves all internal relative paths at build time, eliminating the .js extension requirement
  • Produces proper .mjs files for ESM output, removing any ambiguity with Node.js module detection
  • Bundles internal dependencies that had their own broken ESM paths (e.g. @hashgraph/hedera-custodians-integration)

Changes

packages/contracts

tsup.config.ts (new)

  • Builds two entry points: typechain-types/index.ts and typechain-types/factories/index.ts
  • Outputs dual-format ESM (.mjs) + CJS (.js) with declaration files (.d.ts / .d.mts)

package.json

  • Updated main, module, types fields to point to dist/
  • Replaced the broken wildcard "./typechain-types/*" export with explicit named exports:
    • "." → main type index
    • "./factories" → factory contracts index
  • Updated build script: npm run compile && rimraf dist && tsup
  • devDependencies: added tsup

packages/sdk

tsup.config.ts (new)

Dual-configuration build:

  • ESM target: es2022, output dist/esm/index.mjs
  • CJS target: node16, output dist/cjs/index.js
  • external: ['@hashgraph/stablecoin-npm-contracts'] (workspace peer)
  • noExternal patterns for @hashgraph/*, @hiero-ledger/*, tsyringe, reflect-metadata — these are bundled in to work around broken ESM paths in their own outputs

package.json

  • Updated main, module, types to point to dist/cjs/ and dist/esm/
  • Replaced single-format exports with modern nested structure:
    ".": {
      "import": { "types": "..index.d.mts", "default": "..index.mjs" },
      "require": { "types": "..index.d.ts", "default": "..index.js" }
    }
  • Updated build script: rimraf dist && tsup
  • Updated clean script: targets dist/ instead of build/
  • devDependencies: added tsup, @swc/core, @swc/helpers (required for emitDecoratorMetadata support)

tsconfig.json

  • Changed moduleResolution from "node" to "bundler" (required for tsup compatibility)
  • Changed outDir from ./build/esm to ./dist
  • Added tsup.config.ts to include array

.eslintignore

  • Added tsup.config.ts (not part of tsconfig.project, excluded from linting)
  • Added dist/ (build output must not be linted)

Source Code Fixes

HederaWalletConnectTransactionAdapter.ts

Traditional synchronous require() calls and module-level typeof window guards were replaced with inline dynamic import() calls at the actual point of use.

// After
import { SupportedWallets } from '../../../../domain/context/network/Wallet';

// Inside initAdaptersAndProvider() — called only in browser context
const { HederaAdapter, HederaChainDefinition, hederaNamespace, HederaProvider } 
    = await import('@hashgraph/hedera-wallet-connect');

const { createAppKit } = await import('@reown/appkit');

Benefits:

  • Deterministic: Imports are awaited exactly where they are needed, eliminating race conditions.
  • ESM-Compatible: Dynamic import() is natively supported in ESM and correctly handled by the tsup bundler.
  • Node-Safe: Because dynamic imports are evaluated lazily at runtime, they never execute in a Node.js environment during package initialization, removing the need for broad typeof window !== 'undefined' blocks at the module level.
  • No Circular Imports: The SupportedWallets circular dependency was resolved by importing it directly from its source domain file instead of the SDK's public index.

TransactionService.ts

Fixed the directory import to use the new explicit contracts export:

// Before
import * as Factories from '@hashgraph/stablecoin-npm-contracts/typechain-types/factories/contracts'; // ❌

// After
import * as Factories from '@hashgraph/stablecoin-npm-contracts/factories'; // ✅

import type / export type fixes across public API files

Several public-facing files re-exported TypeScript interfaces and type aliases using plain import/export. While tsc is aware they are type-only and erases them silently, esbuild validates every import as a runtime binding and throws No matching export errors for constructs that produce no JavaScript output.

Affected files and their types:

File Symbols fixed
port/in/Account.ts StableCoinListViewModel, AccountViewModel (both interface)
port/in/Event.ts WalletEvent (type alias)
port/in/Network.ts InitializationData (interface)
port/in/StableCoin.ts StableCoinViewModel, StableCoinListViewModel, PaginationViewModel (all interface)
port/in/response/index.ts ConfigInfoViewModel, HoldViewModel (interface)
port/in/request/ConnectRequest.ts BaseRequest, RequestAccount (interface)
port/in/request/CreateRequest.ts RequestPublicKey (interface)
port/in/request/UpdateRequest.ts RequestPublicKey (interface)
port/in/request/GetTransactionsRequest.ts RequestPublicKey (interface)

The fix in each case is to add the type keyword:

// Before
import { InitializationData } from '../out/TransactionAdapter.js';
export { InitializationData };

// After
import type { InitializationData } from '../out/TransactionAdapter.js';
export type { InitializationData };

These changes are semantically correct and improve code clarity. Adding verbatimModuleSyntax: true to tsconfig.json would enforce this pattern automatically going forward.


.gitignore

Added **/dist/ to root and package-level .gitignore files to exclude the new tsup output directory.


Verification

After building with npm run build:contracts && npm run build:sdk, the ESM bundle can be loaded in Node.js without errors:

node -e "import('./packages/sdk/dist/esm/index.mjs').then(m => console.log('SDK Loaded! Exports:', Object.keys(m).length))"
# → SDK Loaded! Exports: 101

This was previously impossible and would throw ERR_UNSUPPORTED_DIR_IMPORT or ReferenceError: require is not defined.


Notes

  • @hiero-ledger/sdk is bundled (not externalized) into the SDK output as requested. This avoids runtime resolution failures caused by broken ESM paths in its own node_modules output.
  • @hashgraph/stablecoin-npm-contracts is externalized since it is a workspace peer and is always present alongside the SDK.
  • The SWC compiler (@swc/core) is added as a dev dependency to correctly handle emitDecoratorMetadata, which is required for the tsyringe DI container used throughout the SDK.

Checklist

  • Documented (Code comments, README, etc.)
  • Tested (unit, integration, etc.)

@netlify
Copy link
Copy Markdown

netlify Bot commented Apr 15, 2026

Deploy Preview for docsstablecoinstudio ready!

Name Link
🔨 Latest commit 993de25
🔍 Latest deploy log https://app.netlify.com/projects/docsstablecoinstudio/deploys/69e606b4a7e59e00081d11e9
😎 Deploy Preview https://deploy-preview-1470--docsstablecoinstudio.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@swirlds-automation
Copy link
Copy Markdown

swirlds-automation commented Apr 15, 2026

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues
Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

- Refactor imports to use `type` keyword
- update package.json for build process standardization
- introduce tsup configuration for consistent TypeScript output formats

Signed-off-by: skurzyp-blockydevs <stanislaw.kurzyp@blockydevs.com>
…tories directory

Signed-off-by: skurzyp-blockydevs <stanislaw.kurzyp@blockydevs.com>
@sonarqubecloud
Copy link
Copy Markdown

❌ The last analysis has failed.

See analysis details on SonarQube Cloud

@skurzyp-blockydevs
Copy link
Copy Markdown
Author

Tested it and it improves the situation on the build side and allows the SDK package to compile cleanly in an ESM context.
However, ESM usage is still broken at runtime due to unresolved CommonJS patterns in transitive dependencies.

When using the SDK in a fully ESM project (e.g. "type": "module" with tsx), the following error still occurs:

Error: Dynamic require of "crypto" is not supported
    at .../@hashgraph/stablecoin-npm-sdk/dist/esm/chunk-*.mjs:12
    at .../tweetnacl/nacl-fast.js

This originates from a dynamic require() call inside a dependency (tweetnacl), which is being bundled into the ESM build. Since Node.js ESM does not support dynamic require, this causes the application to crash at runtime.

Key points

  • The SDK now builds as ESM, but the output still includes code paths that rely on require()
  • These come from sub-dependencies (e.g. tweetnacl) that are not ESM-compatible
  • The issue manifests specifically when running in a strict ESM environment (no CJS interop)

Context

This was reproduced in an ESM-based plugin setup using:

  • "type": "module"
  • dual export package (CJS + ESM via exports)
  • execution via tsx

Even with the fixes introduced in this PR, the runtime remains unusable in ESM projects due to the above error.

Conclusion

This PR addresses part of the problem (build-time compatibility), but full ESM support is still blocked by:

  • dynamic require() usage in bundled output
  • lack of ESM-safe dependencies or proper externalization

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ESM build is broken, cannot import @hashgraph/stablecoin-npm-sdk in Node.js ESM

2 participants