Architecture
This page explains how OpenBridge is structured internally, how data flows through the system, and why the pieces are split the way they are.
Data flow diagram
Packages
| Package | Responsibility | Key exports |
|---|---|---|
@nubisco/openbridge-core | Plugin types, registry, lifecycle engine | Plugin, PluginManifest, PluginContext, PluginRegistry, PluginLifecycle, loadPlugin(), loadPluginsFromDirectory() |
@nubisco/openbridge-sdk | Plugin authoring surface | definePlugin(), re-exports of all core types |
@nubisco/openbridge-config | Zod-validated config load/save | loadConfig(), saveConfig(), ConfigSchema |
@nubisco/openbridge-logger | Scoped structured logger | Logger.create(name), Logger.subscribe(fn), Logger.getEntries() |
@nubisco/openbridge-compatibility-homebridge | Homebridge API shim | HomebridgeAPI, PlatformAccessory, Service, Characteristic |
apps/daemon orchestrates everything: it imports from all packages, owns the Fastify server, manages the HAP bridge, and is the only app that touches the filesystem for config and plugin discovery.
apps/ui is a fully static Vue 3 app. In production it is built to apps/ui/dist/ and served by the daemon. In development it runs on its own Vite dev server (port 5174) that proxies API calls to the daemon.
apps/cli provides the openbridge command. Currently a thin wrapper — most operations are done via the daemon API.
Data flow, step by step
Config load —
@nubisco/openbridge-configreads~/.openbridge/config.json, parses it, and validates it against the Zod schema. Invalid config throws with a structured error listing every invalid field.Logger init —
@nubisco/openbridge-loggeris initialized. It creates an in-memory ring buffer and starts accepting subscriptions. Anything that callsLogger.create()gets a scoped logger that writes to the shared buffer.Plugin discovery — the daemon scans
~/.openbridge/plugins/openbridge/(and anylocalPluginSources). For each subdirectory with adist/index.js, it callsloadPlugin()which dynamically imports the module and readsmodule.default.Plugin registration — each loaded plugin is added to
PluginRegistrywith statusPending. Plugins fromplatforms[]are loaded through the Homebridge shim instead.Lifecycle: setup —
PluginLifecyclecallsplugin.setup(ctx)on each plugin in order. Each gets its ownctxwithconfigfrom the matchingplugins[]entry (or{}if none) and a scopedlogfrom@nubisco/openbridge-logger.Lifecycle: start — after all
setup()calls complete,start(ctx)is called on each plugin. Plugins may begin polling, opening sockets, or registering HAP accessories here.HAP bridge starts — hap-nodejs publishes the bridge via Bonjour/mDNS on
hapPort. Accessories registered by plugins duringstart()become visible to HomeKit.HTTP server starts — Fastify starts listening on
port. All REST routes and WebSocket handlers are registered. The built UI is served fromapps/ui/dist/.Shutdown — on SIGINT or SIGTERM,
PluginLifecyclecallsstop(ctx)on each plugin in reverse start order, waits for all to resolve, then closes the Fastify server and HAP bridge.
Storage layout
~/.openbridge/
config.json ← Main config (read on startup, written by API)
hap-storage/ ← HAP pairing data (managed by hap-nodejs)
*.json
plugins/
openbridge/ ← Native plugins (auto-scanned)
my-plugin/
dist/
index.js
package.json
homebridge/ ← Homebridge npm packages
node_modules/
homebridge-shelly-ds9/
homebridge-tplink-smarthome/Plugin discovery order
For native plugins, the daemon discovers plugins in this priority order:
- Explicit
pathentries inplugins[]— loaded first, path is used directly. ~/.openbridge/plugins/openbridge/— each subdirectory withdist/index.js.localPluginSourcesdirectories — same scan as step 2, in array order.
If the same manifest.name is found more than once, the first discovery wins and a warning is logged.
Log pipeline
Every log call flows through this pipeline:
plugin.ctx.log.info('message')
│
▼
@nubisco/openbridge-logger ← Logger.create('plugin-name')
│
├──▶ stdout (formatted with timestamp + level + name)
│
├──▶ In-memory ring buffer (capped at N entries)
│ └──▶ GET /api/logs?plugin=&limit= (REST query)
│
└──▶ Subscribers (added via Logger.subscribe())
└──▶ /ws/logs WebSocket (streamed JSON)
└──▶ /ws/terminal WebSocket (ANSI formatted, for xterm.js)HAP bridge lifecycle
The HAP bridge is the component that HomeKit actually talks to. Its lifecycle is separate from the plugin lifecycle:
- Bridge created — a
Bridgeobject is created from hap-nodejs with the name, username, and pincode from config. - Accessories registered — plugins call HAP APIs during
start()to add accessories to the bridge. - Bridge published —
bridge.publish()starts the Bonjour advertisement and opens the HAP socket onhapPort. - Pairing — when the user scans the QR code or enters the PIN in the Home app, hap-nodejs handles the Secure Remote Password (SRP) handshake and stores the paired client credentials in
~/.openbridge/hap-storage/. - Characteristic reads/writes — the Home app sends HAP requests to read or control accessories. hap-nodejs routes these to the handlers registered by plugins.
- Shutdown —
bridge.unpublish()closes the HAP socket. Paired client credentials remain in storage for the next startup.
Related pages
- Concepts — vocabulary for the pieces described here
- Plugin API Reference — the interfaces used in step 5 and 6
- Homebridge Compatibility — how the shim fits in