Zenbu.js: Rewriting Every Function So Plugins Can Swap It Live

May 11, 2026

|repo-review

by Florian Narr

Zenbu.js: Rewriting Every Function So Plugins Can Swap It Live

Zenbu.js is the framework behind Zenbu, an Electron dev environment built so users can edit or plug into the app's own source code on their machine, live, without a rebuild. No bundling step at install time — the app ships as the TypeScript you wrote, gets dynamically compiled on first launch, and re-runs affected code the moment a file changes.

Why I starred it

Most "extensible" apps bolt on a plugin API after the fact: a curated set of hooks, a sandboxed script runtime, a marketplace. Zenbu.js inverts that. The README's pitch is that if your app already hot-reloads its own source, and every function is already indirected behind a swappable reference, then a plugin is just code that calls the same indirection layer the framework itself uses. You don't design a plugin API — extensibility is a side effect of how the app runs in dev mode, shipped to production.

That's a real architectural bet, not a slogan, so I wanted to see the layer that makes it true.

How it works

The bet lives in packages/advice, a package with an on-the-nose name: it's AOP — aspect-oriented programming — for JavaScript, applied at build time via a Babel plugin.

packages/advice/src/transform/index.ts walks every top-level FunctionDeclaration, ExportDefaultDeclaration, and single-declarator arrow/function VariableDeclarator, and rewrites each one from:

function fetchUser(id) { /* ... */ }

into:

__def("services/user.ts", "fetchUser", function fetchUser(id) { /* ... */ })
const fetchUser = __ref("services/user.ts", "fetchUser")

__def and __ref come from packages/advice/src/runtime/index.ts. __def registers an implementation under a (moduleId, name) key; __ref returns a stable wrapper function that never changes identity, no matter how many times the underlying implementation is redefined. Every call to fetchUser after the transform actually calls entry.wrapper, which dispatches through applyAdviceChain in runtime/chain.ts.

That indirection buys three things at once, and the transform can't tell them apart — which is the point:

  • Hot reload. When a file changes, HMR re-evaluates the module and calls __def again. The wrapper reference held by every caller is untouched, so React component identity survives a hot-reloaded function body — same trick react-refresh plays, but generalized to every top-level function, not just components.
  • Plugin overrides. replace(moduleId, name, fn) sets entry.replacement, which chain.ts checks ahead of entry.impl. A plugin's prelude script can call replace() before the target module even loads, and the override survives subsequent __def calls from HMR — the test suite in advice.test.ts locks this down explicitly ("survives multiple __def calls (HMR with active replacement)").
  • AOP-style interception. advise(moduleId, name, "before" | "after" | "around", fn) adds advice without touching impl or replacement at all.

The around case is where the code gets genuinely careful. buildChain() in chain.ts:26 composes befores, afters, and arounds into nested closures, and the file has a comment explaining why the composed chain is memoized on the entry instead of rebuilt per call: when around-advice wraps a React component, the next argument it receives is normally a fresh function on every render (React reconciles <X/> by identity, so a new next means unmount-remount). Zenbu's own use case was a CodeMirror-backed chat composer — remounting EditorView on every keystroke because next changed identity was "disastrous." So chain.ts caches the composed chain keyed on the shape of entry.advice and only reads entry.impl/entry.replacement live inside the innermost closure, at call time. It's the kind of fix that only shows up after someone hit the bug in production, not from reading an AOP paper.

This connects straight back to the README's architecture: apps stored in ~/.zenbu/<app-name> are git-tracked, dynamically compiled by @zenbujs/hmr — described in its own package.json as "heavily modified fork of dynohot" — and re-run on change via packages/hmr/loader/dispatch.ts, which auto-registers a Node loader hook on import. The advice layer is what lets a plugin inject into that already-running, already-hot-reloading process without the app author pre-declaring an extension point.

Using it

The advice runtime by itself is small enough to use standalone:

import { advise, replace } from "@zenbu/advice/runtime"

// Intercept and transform a return value
advise("m", "fn", "after", (result: number) => result * 2)

// Wrap the call entirely, control whether the original runs
advise("m", "fn", "around", (original, ...args) => {
  console.time("fn")
  const out = original(...args)
  console.timeEnd("fn")
  return out
})

// Swap the implementation outright, e.g. from a plugin's prelude
replace("m", "fetchUser", async (id: string) => mockUser)

Standing up an actual app is a one-liner:

npx create-zenbu-app

which scaffolds an Electron shell wired to @zenbujs/corepackages/core/src/launcher.ts, services/plugin-manager.ts, and services/window.ts handle the app-shell side (windows, plugin loading, the RPC bridge in rpc.ts).

Rough edges

Zenbu.js is explicitly alpha (Status: Alpha badge, right in the README), and the compatibility table backs that up: Electron is "🧪 Alpha," while Node.js, Tauri, and browser-native are all "🚧 WIP." The roadmap lists non-sandboxed out-of-process plugins and "improved sandboxing controls" as future work — right now, plugins run in-process with full access to the same APIs as the app, which is exactly what makes the advice layer so seamless and exactly what makes it a trust boundary you can't currently harden.

Test coverage is lopsided in a way that tells you what the team considers fragile. packages/kyju — their embedded reactive database, mentioned almost in passing in the roadmap as "the database" — has 31 test files covering proxies, migrations, replication, and CLI tooling. packages/core, the actual Electron orchestration layer at ~17,600 lines across registry, plugin-manager, updater, and launcher, has three. The advice package itself is well-tested (a 608-line test file covering replace/advise interaction with HMR), but the app-shell code that wires everything together is comparatively unguarded.

The repo is young — first commit April 17, 2026 — and moved fast early (333 commits by late June, including one candidly named "remove slop comments"), but the last push was June 28. Two months of silence on a project this early, with 32 open issues, is worth watching before betting production plans on it.

Bottom line

If you're building an Electron app you want end users or coding agents to modify without you designing a plugin API up front, the advice package is worth reading on its own — it's a compact, well-tested answer to "how do I make arbitrary functions swappable without breaking React identity." The rest of Zenbu.js is a legitimate but early attempt to build a whole framework around that idea; give it a few more months before shipping on it.

zenbu-labs/zenbu.js on GitHub
zenbu-labs/zenbu.js