Electron
Serve your Electron UI from a .wvb bundle through a custom protocol, with dev-server proxying and over-the-air updates.
This guide wires Webview Bundle into an Electron app: serving your UI from a .wvb bundle through
a custom protocol, switching to a live dev server during development, and (optionally) updating
bundles over the air.
A complete, runnable example lives in
examples/electron-forge-vite.
Install
npm install @wvb/electron
# and the CLI, to pack bundles (dev dependency):
npm install -D @wvb/cli@wvb/electron depends on @wvb/node, the native N-API binding, which ships prebuilt binaries for
common platforms. No Rust toolchain is required to consume it.
1. Register the protocol in the main process
Call wvb(...) (an alias of webviewBundle(...)) before your windows load. It registers your
custom schemes as privileged and wires up the protocol handlers.
// src/main.ts
import path from 'node:path';
import { app, BrowserWindow } from 'electron';
import { bundleProtocol, localProtocol, wvb } from '@wvb/electron';
wvb({
// Where bundles live on disk (see "Bundle source" below). Defaults are usually fine.
source: {
builtinDir: path.join(process.resourcesPath, 'bundles'),
},
protocols: [
// In development, proxy `app-local://simple.wvb/...` to the Vite dev server so you get
// hot reload. `MAIN_WINDOW_VITE_DEV_SERVER_URL` is provided by Electron Forge's Vite plugin.
localProtocol('app-local', {
hosts: {
'simple.wvb': MAIN_WINDOW_VITE_DEV_SERVER_URL,
},
}),
// In production, serve `app://<bundle>/...` straight from the bundle.
bundleProtocol('app', {
onError: e => console.error(e),
}),
],
});
async function createWindow() {
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
// `app://<bundle-name>.wvb/<path>` — the first host label is the bundle name.
await win.loadURL('app://simple.wvb');
}
app.on('ready', createWindow);bundleProtocol(scheme)serves files directly out of bundles in the source. A URL likeapp://simple.wvb/index.htmlresolves to bundlesimple(the first host label), file/index.html. A trailing slash or an extension-less path resolves toindex.html.localProtocol(scheme, { hosts })proxies matching hosts to a localhost dev server instead. Use it during development so you keep the same scheme while getting your bundler's hot reload.
A common pattern is to register both and choose which URL to load based on app.isPackaged.
2. Add the preload script
The preload bridges a small, safe API into the renderer (used for source/remote/updater calls).
// src/preload.ts
import { preload } from '@wvb/electron/preload';
preload();Point your BrowserWindow's webPreferences.preload at the compiled preload (as above), and keep
contextIsolation: true.
3. Call the API from the renderer (optional)
If you want the UI to drive updates (e.g. a "Check for updates" button), import the renderer API. It forwards to the main process over IPC, so it only works when the preload script is loaded.
// src/renderer.ts
import { remote, source, updater } from '@wvb/electron/renderer';
// What's installed locally right now?
const version = await source.loadVersion('app');
// Is a newer version deployed?
const info = await updater.getUpdate('app');
if (info.isAvailable) {
await updater.downloadUpdate('app'); // downloads, verifies, installs
// reload the window to pick up the new bundle
}The same source, remote, and updater objects are also reachable in the main process from the
instance returned by wvb(...):
const instance = wvb({
/* … */
});
instance.source; // BundleSource
instance.remote; // Remote | null
instance.updater; // Updater | null
await instance.whenProtocolRegistered();4. Configure over-the-air updates (optional)
Add an updater block pointing at your remote server. See
Remote updates for how to stand one up (including a local one for testing).
wvb({
source: { builtinDir: path.join(process.resourcesPath, 'bundles') },
updater: {
remote: { endpoint: 'https://updates.example.com' },
// integrity / signature verification options also live here
},
protocols: [bundleProtocol('app')],
});5. Pack and ship bundles
Build your web app, then pack the output into a bundle and place it in the bundles directory you
configured as builtinDir:
# build your renderer first (e.g. `vite build`), then:
npx wvb pack ./dist --outfile bundles/app/app_1.0.0.wvbWhen packaging the app, make sure the bundles directory is included as an extra resource and that
the native @wvb/node binary is unpacked from the ASAR archive. With Electron Forge + Vite:
// forge.config.ts
const config: ForgeConfig = {
packagerConfig: {
asar: true,
extraResource: ['bundles'], // ship the builtin bundles
},
plugins: [
// …vite plugin…
new AutoUnpackNativesPlugin({}), // unpack @wvb/node's .node binary from the ASAR
],
};The Forge Vite plugin ignores files outside .vite/ by default, which can exclude node_modules.
The example's forge.config.ts shows a packagerConfig.ignore override that keeps node_modules
and .tgz files so the native module is bundled. See
examples/electron-forge-vite/forge.config.ts.
Where bundles live
source accepts builtinDir (shipped, read-only) and remoteDir (downloaded updates). A good
default is to ship builtinDir under process.resourcesPath and let remoteDir default to a
writable app-data location. Downloaded versions always take priority over builtin ones, so an
installed update is served automatically after updater.downloadUpdate(...).
Troubleshooting
- Blank window /
ERR_FAILED— confirmwvb(...)runs beforeloadURL, and that the bundle name in the URL matches the packed file (app://simple.wvb→ bundlesimple). Cannot access to webview bundle apiin the renderer — the preload script isn't loaded; checkwebPreferences.preloadand that it callspreload().- Works in dev, fails when packaged — the
bundlesresource or the native@wvb/nodebinary wasn't included; verifyextraResourceandAutoUnpackNativesPlugin(see above).