Skip to content

feat: make the browser build tree-shakeable, drop UMD - #34

Open
digitalSloth wants to merge 1 commit into
mainfrom
feature/browser-bundles
Open

feat: make the browser build tree-shakeable, drop UMD#34
digitalSloth wants to merge 1 commit into
mainfrom
feature/browser-bundles

Conversation

@digitalSloth

Copy link
Copy Markdown
Owner

Summary

  • Drop the pre-bundled UMD/webpack browser build (bundle.browser.js/.mjs) and ship modular ESM only. Bundlers (Vite, Rollup, Webpack) now resolve dist/index.js directly, enabling tree-shaking — a build importing only Zenon/Address/Hash no longer pulls in KeyStore/KeyFile/argon2.
  • Replace Node's crypto module and ed25519-hd-key with @noble/hashes (hand-rolled SLIP-0010 derivation), and the crypto/crypto-browserify AES-GCM driver in Encryptor with @noble/ciphers. These were the last Node-builtin dependencies blocking a browser bundler from resolving the modular SDK at all.
  • KeyFile's Argon2 KDF keeps its Node/browser split: native argon2 for Node/CLI (unchanged, ~21ms), argon2-browser's bundled subpath for the browser (avoids the wasm require() that broke esbuild's dependency optimizer).
  • package.json's exports["."] simplified to {types, default}; sideEffects: false added.

Fixes #33.

Breaking changes

  • No UMD window.ZnnSDK build. Script-tag consumers need a bundler or an ESM CDN.
  • require("znn-typescript-sdk") needs Node ≥ 20.19 (unflagged require(esm)), or switch to import.
  • KeyFile's browser Argon2 loads as a dynamic chunk — bundlers serving the SDK need output.publicPath (or the Vite equivalent) set correctly.

Next release should be 2.0.0.

Verification

  • npm test — 650 passing, no fixed-vector changes (existing keyStore/keyFile vectors prove old mnemonics/keyfiles still work).
  • deriveKey independently checked against the official SLIP-0010 ed25519 test vectors (including the 2147483647' boundary); non-hardened paths rejected, index overflow fails closed.
  • Encryptor's AES-256-GCM cross-tested against Node's own crypto output over 500 randomized rounds — byte-identical ciphertexts, bad-tag decryption still throws before returning plaintext.
  • npm run lint / npm run build clean.
  • Tree-shaking confirmed with a scratch webpack build: main chunk dropped from ~1.3 MB to ~196 KiB with KeyFile/argon2/KeyStore strings absent when unused.
  • esbuild --platform=browser resolution confirmed clean (no wasm/fs/stream errors).

Docs

READMEs and AGENTS.md updated: PoW file paths repointed to lib/pow.{js,wasm} (previously dist/browser/), UMD references removed, browser-builds section rewritten.

- src/crypto/crypto.ts: replace crypto/ed25519-hd-key with a SLIP-0010
  derivation over @noble/hashes; move the ed25519 sha512Sync assignment
  out of a top-level statement so sideEffects:false can't drop it.
- src/wallet/encryptor.ts: replace the Node crypto/crypto-browserify
  driver pair with @noble/ciphers/aes gcm, keeping encrypt/decrypt sync
  and the cipherHex||authTagHex wire format.
- src/wallet/keyFile.ts: browser branch now imports the argon2-browser
  bundled subpath lazily instead of the package root; native branch
  specifier held in a const per the esbuild escalation rule.
- src/types/argon2-bundled.d.ts: new ambient declaration for the
  bundled subpath.
- package.json: exports map -> {types, default}; browser field and
  build:browser script removed; sideEffects:false added; add
  @noble/ciphers, remove ed25519-hd-key/crypto-browserify and the
  webpack-browser-only devDependencies.
- webpack.browser.config.cjs: deleted (UMD and ESM browser bundles
  both dropped).
- tsconfig.json: add ts-node.files:true so the mocha/ts-node loader
  picks up the new ambient .d.ts (tsc/build were unaffected either
  way).
- README.md, AGENTS.md, docs/examples.md, test/zenon.spec.ts: repoint
  PoW paths from dist/browser to lib, drop the now-false UMD claims,
  rewrite the Browser Builds section.
- test/crypto/crypto.spec.ts, test/wallet/keyFile.spec.ts: cover
  deriveKey's path validation and the browser/native Argon2 driver
  parity.

@0x3639 0x3639 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested this against zwap.fun (the wallet-less consumer from #33: Vite 8.1.4 / Rolldown, production minify) plus a Node and a browser KeyFile smoke. It closes #33 for us. Details below, one suggestion at the end.

zwap.fun build, before vs after

Built from a tarball of this branch (npm packnpm i ../znn-typescript-sdk-1.0.5.tgzvite build), same machine, same config.

1.0.5 (published) this branch
SDK-bearing chunks bundle.browser 944 KB + argon2 634 KB sdk-node 105 KB + htlc 70 KB
argon2 chunk always emitted none
direct eval (vm-browserify) present, Rolldown [EVAL] warning none
elliptic / asn1 / KeyFile / KeyStore / Encryptor strings in output present none
total JS in dist/assets 2.06 MB 676 KB
build result ok (but blob) ok — the [REQUIRE_TLA] failure on argon2-browser/dist/argon2.wasm that the modular build used to hit is gone

The only bundler noise left is two warnings from dist/pow/pow.js (see suggestion). zwap.fun can also shrink its vite-plugin-node-polyfills include from ["crypto","buffer","stream","util"] to ["buffer"] and still build (total JS 608 KB).

KeyFile in a real browser

Minimal Vite 8 app, no polyfill plugin, importing KeyStore/KeyFile/Zenon/Address, run in Chrome against both vite build && vite preview and the dev server:

  • argon2-bundled.min is emitted as a separate lazy chunk (46 KB) — only because this app reaches KeyFile; zwap.fun's build has no such chunk.
  • KeyFile.setPassword(p).encrypt(ks).decrypt(json) round-trips the mnemonic (encrypt ~170 ms, decrypt ~140 ms).
  • Wrong password throws (aes/gcm: invalid ghash tag) before returning plaintext.
  • The UMD default interop works as written under Rolldown in both prod and dev.

Node, from the tarball (Node 22.12)

  • import("znn-typescript-sdk") and require("znn-typescript-sdk") (require-esm) both load, same Zenon instance.
  • KeyStore.fromMnemonic, KeyFile encrypt/decrypt via the native argon2 variable-specifier import (47 ms), PoW init() + generate() from lib/ all work.

Crypto replacements, cross-checked independently

  • AES-256-GCM: 200 randomized rounds (random key, nonce, 1–96 byte plaintext) — Encryptor.encrypt output is byte-identical to Node aes-256-gcm with AAD "zenon", and ciphertext produced by either side decrypts with the other. Existing keyfiles are safe.
  • SLIP-0010: 100 random hardened paths, depth 1–5, indices up to 2³¹−1 — Crypto.deriveKey and Crypto.getPublicKey match ed25519-hd-key@1.3.0 exactly.
  • npm test: 650 passing; lint clean.

Code read-through: ensureSha512Sync() is called on every @noble/ed25519 entry point that needs it (getPublicKey, sign; there is no verify in Crypto), so sideEffects: false is safe. No other module-level side effects in src/. dist/wallet/keyFile.d.ts doesn't reference the argon2-bundled ambient module, so consumers' typecheck is unaffected by src/types/*.d.ts not being emitted.

Suggestion (non-blocking): silence the Node-only imports in pow.ts

Rolldown still resolves the fs/url/path dynamic imports under isNode():

[plugin rolldown:vite-resolve] Module "path" has been externalized for browser compatibility, imported by ".../znn-typescript-sdk/dist/pow/pow.js"
[plugin rolldown:vite-resolve] Module "fs" has been externalized for browser compatibility, imported by ".../znn-typescript-sdk/dist/pow/pow.js"

and, when a polyfill plugin is present, emits a never-fetched ~51 KB url polyfill chunk. Adding the node: prefix and /* @vite-ignore */ removes both warnings and the chunk (48 fewer modules transformed) with no behaviour change — PoW init()/generate() re-verified on Node 22 from the packed tarball:

const { readFileSync } = await import(/* webpackIgnore: true */ /* @vite-ignore */ "node:fs");
const { fileURLToPath } = await import(/* webpackIgnore: true */ /* @vite-ignore */ "node:url");
const { dirname, join } = await import(/* webpackIgnore: true */ /* @vite-ignore */ "node:path");

Also checked esbuild 0.28 --platform=browser --bundle on dist/index.js: clean exit with both the current and the prefixed form. Ready to cherry-pick from 0x3639/znn-typescript-sdk@ee56e57 (branch feat/tree-shakeable-browser, based on this PR's head).

Small behaviour changes worth a line in the 2.0.0 notes (none affect KeyStore/KeyFile)

  • Crypto.randomBytes(n) now sits on crypto.getRandomValues, which caps a single call at 65,536 bytes (QuotaExceededError above that, Node and browsers alike). Node's randomBytes had no such cap. Internal callers only ask for 12–64 bytes; a chunked loop would restore the old range if you want to keep the public API identical.
  • The ed.etc.sha512Sync hook is now installed lazily inside getPublicKey/sign instead of at import time. A consumer that imports the SDK and then calls @noble/ed25519's sync API directly (e.g. ed.verify) before constructing a KeyPair will hit etc.sha512Sync not set, where 1.0.5 happened to have set it for them. Reasonable trade for sideEffects: false; a Crypto.verify wrapper would give those users a supported path.
  • hexToBytes in deriveKey is stricter than the old Buffer.from(seed, "hex") (throws on 0x-prefixed or odd-length hex instead of silently truncating). Only invalid input is affected.

Pre-existing, not introduced here

  • In an environment that is both isBrowser() and Node (jsdom under vitest/jest, Electron renderer with nodeIntegration), the Emscripten glue in argon2-bundled.min.js keys off process.versions.node, takes its Node path, and aborts trying to fetch //argon2.wasm — the process exits rather than the promise rejecting. The old argon2-browser entry behaves the same, and the PR's parity test works around it by nulling process.versions.node. Worth a README note for people running KeyFile under jsdom; zwap.fun's vitest suite doesn't touch KeyFile so it's not affected.

Nits

  • tsconfig.json ts-node.files: true: the test script runs via tsx, so this is inert; harmless either way.
  • README: under Vite the Argon2 chunk URL follows base automatically, so the output.publicPath note mainly applies to webpack users.

Happy to run a canary of the 2.0.0 tarball against zwap.fun again before release.

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.

Browser consumers can't tree-shake: browser export condition points at a monolithic webpack bundle

2 participants