Modernizing Steem.js — Day 4: Transports, Runtime Globals & Dropping Bluebird

in Steem Dev3 days ago

Part 4 of my 7-part Steem.js modernization series. With signing and serialization now running on modern, edge-safe code, today we make the networking layer and the runtime assumptions portable too.

The problem: hidden Node assumptions

A library can have perfect crypto and still fail to load on an edge runtime because of small things scattered across the code:

  • a hard dependency on cross-fetch
  • a static import 'ws' that bundlers pull in even for browsers
  • process.on('warning', …) at module top level
  • window / global assignment that throws where neither exists
  • bluebird for promisification

I went through each.

HTTP transport → native fetch

Every modern runtime — Node 18+, browsers, Cloudflare Workers, Vercel Edge, Deno — ships a global fetch. So src/api/transports/http.js dropped cross-fetch and now uses globalThis.fetch directly (the existing options.fetchMethod override still works if you want to inject your own):

const fetchMethod = this.options.fetchMethod || globalThis.fetch;
const res = await fetchMethod(this.options.uri, {
  method: 'POST',
  body: JSON.stringify(payload),
});

One less dependency, and it works identically everywhere.

WebSocket transport → lazy, optional

WebSockets are trickier: browsers and Deno have a global WebSocket, but Node needs the ws package. The fix is to resolve the global first and only fall back to a dynamic, lazy import:

// ws.js — never statically bundled
let WS = globalThis.WebSocket;
if (!WS) {
  WS = (await import('ws')).default; // optional dependency, Node only
}

ws is now an optionalDependency and marked external in the build — so edge and browser bundles never pull it in, and Node users who want WebSocket transport get it automatically.

Guarding runtime globals

Non-portable globals are now feature-detected instead of assumed:

// src/index.js
if (typeof process !== 'undefined' && process.on) {
  process.on('warning', …);
}

The IIFE browser build's window/global assignment is likewise wrapped in a typeof globalThis guard. The library no longer assumes it's in Node, a browser, or anywhere specific.

Goodbye Bluebird, hello native promises

Steem.js's signature feature is its dual callback/promise API — every method works with a trailing cb or returns a Promise. That was powered by Bluebird's Promise.promisify / promisifyAll. Bluebird is pure-JS and isomorphic, so it worked — but it's a large dependency for something native promises now do well.

I replaced it with a tiny in-repo src/_promise.js:

export function promisify(fn) { /* returns a fn that resolves/rejects a native Promise */ }
export function promisifyAll(obj) { /* adds `${name}Async` variants */ }
export function nodeify(promise, cb) { /* bridges promise → callback */ }
export function delay(ms) { /* native setTimeout promise */ }

The api and broadcast modules now generate their fooAsync / fooWithAsync variants from this — so getAccounts(cb), getAccountsAsync(), getAccountsWith({...}) all behave exactly as before, with one fewer dependency.

The net effect

The dependency list is now tiny: @noble/*, @scure/base, bs58, retry, plus optional ws. No cross-fetch, no bluebird, no Node-builtin polyfills. The bundle was audited to contain zero Node-builtin imports and zero dynamic requires — the prerequisite for true edge compatibility.

Tomorrow: Phase 5 — shipping first-class TypeScript types for a dynamically-generated API.

Links

Support Secure Steem Development

If you value proactive engineering, UX polish, and performance optimizations for the STEEM ecosystem, please consider supporting my witness: blaze.apps

🗳️ Vote Here:
Vote for blaze.apps Witness