feat: make the browser build tree-shakeable, drop UMD - #34
Conversation
- 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
left a comment
There was a problem hiding this comment.
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 pack → npm i ../znn-typescript-sdk-1.0.5.tgz → vite 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.minis emitted as a separate lazy chunk (46 KB) — only because this app reachesKeyFile; 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
defaultinterop works as written under Rolldown in both prod and dev.
Node, from the tarball (Node 22.12)
import("znn-typescript-sdk")andrequire("znn-typescript-sdk")(require-esm) both load, sameZenoninstance.KeyStore.fromMnemonic,KeyFileencrypt/decrypt via the nativeargon2variable-specifier import (47 ms), PoWinit()+generate()fromlib/all work.
Crypto replacements, cross-checked independently
- AES-256-GCM: 200 randomized rounds (random key, nonce, 1–96 byte plaintext) —
Encryptor.encryptoutput is byte-identical to Nodeaes-256-gcmwith 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.deriveKeyandCrypto.getPublicKeymatched25519-hd-key@1.3.0exactly. 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 oncrypto.getRandomValues, which caps a single call at 65,536 bytes (QuotaExceededErrorabove that, Node and browsers alike). Node'srandomByteshad 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.sha512Synchook is now installed lazily insidegetPublicKey/signinstead 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 aKeyPairwill hitetc.sha512Sync not set, where 1.0.5 happened to have set it for them. Reasonable trade forsideEffects: false; aCrypto.verifywrapper would give those users a supported path. hexToBytesinderiveKeyis stricter than the oldBuffer.from(seed, "hex")(throws on0x-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 inargon2-bundled.min.jskeys offprocess.versions.node, takes its Node path, and aborts trying to fetch//argon2.wasm— the process exits rather than the promise rejecting. The oldargon2-browserentry behaves the same, and the PR's parity test works around it by nullingprocess.versions.node. Worth a README note for people runningKeyFileunder jsdom; zwap.fun's vitest suite doesn't touchKeyFileso it's not affected.
Nits
tsconfig.jsonts-node.files: true: the test script runs viatsx, so this is inert; harmless either way.- README: under Vite the Argon2 chunk URL follows
baseautomatically, so theoutput.publicPathnote mainly applies to webpack users.
Happy to run a canary of the 2.0.0 tarball against zwap.fun again before release.
Summary
bundle.browser.js/.mjs) and ship modular ESM only. Bundlers (Vite, Rollup, Webpack) now resolvedist/index.jsdirectly, enabling tree-shaking — a build importing onlyZenon/Address/Hashno longer pulls inKeyStore/KeyFile/argon2.cryptomodule anded25519-hd-keywith@noble/hashes(hand-rolled SLIP-0010 derivation), and thecrypto/crypto-browserifyAES-GCM driver inEncryptorwith@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: nativeargon2for Node/CLI (unchanged, ~21ms),argon2-browser's bundled subpath for the browser (avoids the wasmrequire()that broke esbuild's dependency optimizer).package.json'sexports["."]simplified to{types, default};sideEffects: falseadded.Fixes #33.
Breaking changes
window.ZnnSDKbuild. Script-tag consumers need a bundler or an ESM CDN.require("znn-typescript-sdk")needs Node ≥ 20.19 (unflaggedrequire(esm)), or switch toimport.KeyFile's browser Argon2 loads as a dynamic chunk — bundlers serving the SDK needoutput.publicPath(or the Vite equivalent) set correctly.Next release should be
2.0.0.Verification
npm test— 650 passing, no fixed-vector changes (existingkeyStore/keyFilevectors prove old mnemonics/keyfiles still work).deriveKeyindependently checked against the official SLIP-0010 ed25519 test vectors (including the2147483647'boundary); non-hardened paths rejected, index overflow fails closed.Encryptor's AES-256-GCM cross-tested against Node's owncryptooutput over 500 randomized rounds — byte-identical ciphertexts, bad-tag decryption still throws before returning plaintext.npm run lint/npm run buildclean.KeyFile/argon2/KeyStorestrings absent when unused.--platform=browserresolution confirmed clean (no wasm/fs/streamerrors).Docs
READMEs and AGENTS.md updated: PoW file paths repointed to
lib/pow.{js,wasm}(previouslydist/browser/), UMD references removed, browser-builds section rewritten.