Publishing my first npm SDK: lessons from @ravenhouse/omni-sdk
Turning a pile of contract calls into a real SDK: subpath exports, dual ESM and CJS builds, types that double as documentation, and the dependency pain nobody warns you about.
I have shipped a lot of application code. @ravenhouse/omni-sdk
was the first time I shipped a library: something other people import, pin a version of, and
build on top of. It is now past 60 releases and powers Raven Bridge in
production. Here is what building it taught me that application work never did.
Why extract an SDK at all#
Raven Bridge, the marketplace, and various scripts all talked to the same contracts, the same Aztec node, the same L1 portals. That wiring was drifting: three copies, each subtly different, each with its own bugs. The moment you have more than one consumer of the same on-chain logic, you want exactly one versioned source of truth for it. That is the whole case for an SDK.
Nine doors, not one#
The biggest design decision was refusing to put everything behind a single import. A
framework-agnostic bridge core, a React component library, contract artifacts, and a Vite plugin
have no business being coupled. If they were, a backend script that only wanted the Ethereum
utilities would drag React and megabytes of Aztec artifacts into its bundle.
So the SDK ships nine subpath exports, each independently importable and independently tree-shakeable:
{
"exports": {
".": "./dist/index.js", // core RavenBridge client
"./contracts/l1": "./dist/contracts/l1/...", // L1 contract bindings
"./contracts/l2": "./dist/contracts/l2/...", // L2 contract artifacts
"./ethereum": "./dist/ethereum/...", // viem-based L1 utils
"./verification": "./dist/verification/...", // zkPassport IdentityVerifier
"./react": "./dist/react/...", // React component library
"./config": "./dist/config/...", // network + token config
"./vite": "./dist/vite.js", // Vite plugin
"./styles.css": "./dist/styles.css" // prebuilt Tailwind styles
}
}The payoff: import { RavenBridge } from "@ravenhouse/omni-sdk" in a Node script pulls in none
of the React layer, while a frontend can import { VerifyIdentityModal } from "@ravenhouse/omni-sdk/react" and get just that. Same package, no dead weight.
Dual ESM and CJS, or half your users can't import you#
You do not get to assume everyone is on ESM. Publish ESM-only and every CommonJS consumer hits a
wall; publish CJS-only and you break import in modern toolchains and lose tree-shaking. A real
SDK ships both, plus type declarations for each entry point. I build with
tsup, which emits .js (ESM), .cjs, and .d.ts for every one of
those nine entries in a single pass:
"./verification": {
"types": "./dist/verification/index.d.ts",
"import": "./dist/verification/index.js",
"require": "./dist/verification/index.cjs"
}Miss any one of those three conditions and someone's import silently resolves to the wrong file or no types. This is the least glamorous part of an SDK and the part that generates the most bug reports when you get it wrong.
Let the types be the docs#
Nobody reads your wiki. They do read the tooltip their editor shows when they hover a parameter. So I put the documentation where it cannot be missed: on the types. The core client is a small, honest surface, and every field of its params is annotated with what it does and, more importantly, the rules:
export interface BridgeL1ToL2Params {
/** Whether to bridge into a private note (vs a public balance) on L2. */
isPrivate: boolean
/**
* User-memorable secret used to derive the claim secret. Arbitrary strings
* are accepted; they are hashed into the field. Lose this and the L2 claim
* cannot be reconstructed.
*/
secret?: string
/**
* Called after the L1 deposit confirms. Errors thrown here do NOT abort the
* bridge, so a failed analytics write can never cost a user their funds.
*/
onDepositComplete?: (checkpoint: DepositCheckpoint) => void | Promise<void>
}That last comment is a design decision encoded as a type. A consumer learns the safety rule in their editor, before they ship the bug, instead of after.
The dependency pain nobody warns you about#
Here is the lesson application code never taught me: in a library, your dependencies are your
users' problem too. The omni-sdk pins Aztec's 5.x packages and has to override viem to
Aztec's own fork:
"overrides": { "viem": "npm:@aztec/viem@2.38.2" },
"resolutions": { "viem": "npm:@aztec/viem@2.38.2" }In an app you can paper over a version clash and move on. In a published package, a bad peer range or an un-deduped transitive dependency becomes a broken install in a stranger's project, and you find out through an issue, not a stack trace. I keep the core unopinionated for the same reason: no hidden global state, no required API key, so it composes into someone else's app instead of fighting it.
Sixty releases is mostly discipline#
The releases are not sixty features. They are sixty small, verified, backwards-compatible increments. Every one runs through the same gate before it ships:
tsc --noEmit # types are honest
vitest # behavior is intact
tsup # a clean, reproducible buildPublishing a library changes how you think. Every export is a promise, every type is a contract, and every release is something you cannot easily take back. It made me a more careful engineer than any amount of application code did.
The full design is in the omni-sdk case study, and the live version and download counts are on npm.
Written by Yash Mittal. If this was useful, I'm on X and open to a conversation.