Zenbu.js: Electron Apps That Ship Their Own Source Code

May 11, 2026

|repo-review

by Florian Narr

Zenbu.js: Electron Apps That Ship Their Own Source Code

Zenbu.js is a framework for building Electron apps where the source code ships to the user's machine uncompiled and stays live-editable. The app runs from ~/.zenbu/<app-name>, hot-reloads on every save, and supports a plugin system that can modify behavior without touching the host source.

Why I starred it

Most desktop app frameworks optimize for shipping a sealed binary. Zenbu.js inverts the premise: the app you distribute is intentionally incomplete, expecting users (or coding agents) to finish it. That's a specific bet on how software gets extended in an AI-tooling world — where an LLM can write a plugin rather than waiting for a PR to merge.

The README pitches it in three lines: agents can customize apps on demand, users exploring in different directions get more done collectively, and extensible code tends to be more maintainable. That last point is the most interesting — it's a structural argument, not a product argument.

How it works

The launcher (packages/core/src/launcher.ts) is the first thing Electron runs after boot. It doesn't bundle a snapshot of the app. Instead it clones the app's source repo via isomorphic-git into ~/.zenbu/apps/<name>/, runs pnpm install against it, and then dynamically imports @zenbujs/core/dist/setup-gate.mjs from that directory. The comment in the file spells out why this matters:

"It must NOT import @zenbujs/core. Doing so would create two distinct module identities (this bundled copy + the apps-dir copy) and split-brain the runtime singleton, dynohot HMR, and instanceof checks."

So there's exactly one copy of the framework live at runtime — the one in the user's home directory. Updates are git pull. The source the user can edit is the source the app is actually running.

Hot reload in the main process goes through a custom Node.js module loader at packages/core/src/loaders/zenbu.ts. It's a --loader hook that watches service files for changes and auto-injects runtime.register(...) calls. When a service file changes, only that service's slot gets re-evaluated, not the whole process.

The ServiceRuntime in packages/core/src/runtime.ts is the core abstraction. Services declare their dependencies via static deps, and the runtime does topological reconciliation — re-evaluating dependents whenever a dependency changes. The slot system tracks status (blocked, evaluating, ready, failed) and runs cleanup callbacks with a reason ("reload" vs "shutdown") so services can distinguish between the two.

export class WindowService extends Service.create({
  key: "window",
  deps: {
    baseWindow: BaseWindowService,
    http: HttpService,
  },
}) {
  evaluate() {
    // this.ctx.baseWindow and this.ctx.http are fully typed
  }
}

The plugin system works through an "advice" model borrowed from AOP. Plugins can wrap, replace, or augment React components from the host app — before, after, around, or replace. The advice config (packages/core/src/services/advice-config.ts) resolves module paths relative to each plugin's directory, stamped at registration time, so plugins never need to pass import.meta around. When advice changes, the Vite dev server's module graph gets invalidated for the affected view.

The renderer side runs separate Vite dev server instances per view (managed by ReloaderService). Each renderer gets a deduplicated React instance via Vite's resolve.dedupe, so plugins importing react from their own node_modules don't end up with a second copy.

The DB layer (packages/core/src/services/db.ts) wraps something called @zenbu/kyju — an internal package not documented publicly. It watches a migrations directory with @parcel/watcher and reloads the DB service when migrations change.

Using it

npx create-zenbu-app
cd my-app
zen dev

The zen CLI has a small set of commands: dev (Electron + HMR), build:source (stage TS for distribution), build:electron (package as .app), publish:source (push staged source to mirror repo), and link (regenerate registry types from zenbu.config.ts).

Plugins are wired via definePlugin in a config file. Any service in a plugin can call this.advise(...) to inject behavior into the host without editing the host's source.

Rough edges

The alpha badge is honest — this is not close to production-ready. A few things stood out:

  • Tests are nearly absent. packages/core/test/ has a single file: package-managers.test.ts. The runtime, the loader, and the advice system have no test coverage.
  • @zenbu/kyju (the DB layer) is a workspace-internal package with no public docs. You're taking the whole DB story on faith.
  • Currently Electron-only. Node.js, Tauri, and browser runtimes are listed as "WIP" in the README.
  • The launcher.ts has a fixme this is nonsense comment in the EPIPE workaround section, which at least signals self-awareness.
  • Recent git activity shows a lot of README and website churn. The core framework code looks more settled, but there's no changelog and no semver discipline yet.

The @parcel/watcher shutdown repro script sitting in the monorepo root (scripts/repro-parcel-watcher-electron-shutdown.mjs) also suggests there are known stability issues with process teardown.

Bottom line

Zenbu.js is interesting if you're building internal tools or AI-augmented apps where you want users to modify behavior in place. It's not ready for anything you'd ship to people who aren't comfortable opening a terminal.

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