Setup
Serve your Electron UI from a .wvb bundle through a custom protocol, with dev-server proxying and over-the-air updates.
@wvb/electron wires Webview Bundle into Electron in three moves: serve your UI from a .wvb bundle through a custom protocol, proxy to a dev server while developing, and (optionally) update bundles over the air.
Install
@wvb/electron requires Electron 15+ and pulls in @wvb/node (prebuilt N-API binaries — no Rust toolchain). @wvb/cli packs bundles at build time.
npm install @wvb/electron
npm install -D @wvb/clipnpm add @wvb/electron
pnpm add -D @wvb/cliyarn add @wvb/electron
yarn add -D @wvb/cliRegister the protocol in the main process
Call wvb(...) (alias of webviewBundle(...)) before any window loads. It registers your schemes as privileged, builds the source, and wires the protocol handlers and IPC.
import path from 'node:path';
import { app, BrowserWindow } from 'electron';
import { bundleProtocol, localProtocol, wvb } from '@wvb/electron';
const instance = wvb({
source: {
builtinDir: path.join(process.resourcesPath, 'bundles'),
},
protocols: [
// Dev: proxy `app-local://app.wvb/...` to the Vite dev server for hot reload.
localProtocol('app-local', {
hosts: { 'app.wvb': MAIN_WINDOW_VITE_DEV_SERVER_URL },
}),
// Prod: serve `app://<bundle>.wvb/...` straight from the bundle.
bundleProtocol('app', { onError: e => console.error('[wvb]', e) }),
],
});
async function createWindow() {
await instance.whenProtocolRegistered();
const win = new BrowserWindow({
width: 800,
height: 600,
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
await win.loadURL('app://app.wvb');
}
app.whenReady().then(createWindow);URL shape is <scheme>://<bundle-name>.wvb/<path> — app://app.wvb/index.html resolves to bundle app, file /index.html.
bundleProtocol(scheme, options?)serves files directly from bundles in the source.localProtocol(scheme, { hosts })proxies matching hosts to a dev server.hostsis required — aRecord<string, string>(or a function returning one) mapping host to URL.
Choose which URL to load based on app.isPackaged:
await win.loadURL(app.isPackaged ? 'app://app.wvb' : 'app-local://app.wvb');whenProtocolRegistered() resolves after every protocol is registered and app.whenReady() fires — always await it before navigating.
Override per-protocol privileges with the privileges option:
bundleProtocol('app', {
privileges: { stream: true }, // merged over the defaults below
});Each scheme is registered as privileged with these defaults: standard, secure, bypassCSP,
allowServiceWorkers, supportFetchAPI, corsEnabled, codeCache all true; stream false.
See Protocol handling for the full model.
Add the preload script
The preload exposes a safe transport (window.wvbElectron.invoke) that the bridge uses for source, remote, and updater calls.
import { preload } from '@wvb/electron/preload';
preload();Point webPreferences.preload at the compiled preload and keep contextIsolation: true with nodeIntegration: false.
Call the API from the renderer
Import the bridge in renderer code to drive updates from the UI — for example, a "Check for updates" button. It forwards to the main process over IPC, so it works only when the preload is loaded.
import { source, remote, updater } from '@wvb/bridge';
// What is installed locally right now?
const current = await source.loadVersion('app');
// Is a newer version deployed on the remote?
const update = await updater.getUpdate('app');
if (update) {
const downloaded = await updater.download('app'); // download + verify + stage
await updater.install('app', downloaded.version); // activate
// Reload the window to pick up the new bundle — your app's responsibility.
}source, remote, and updater come from @wvb/bridge, which auto-detects the Electron
transport installed by @wvb/electron/preload. Result shapes for getUpdate/download are in
the Node API reference.
The same surfaces are reachable in the main process on the instance returned by wvb(...):
instance.source; // BundleSource
instance.remote; // Remote | null (null unless `updater` is configured)
instance.updater; // Updater | null (null unless `updater` is configured)
await instance.whenProtocolRegistered();Configure over-the-air updates
Add an updater block pointing at your remote. Its presence is what enables instance.remote and instance.updater; omit it and the renderer's remote.* / updater.* calls fail with a "not initialized" bridge error.
wvb({
source: { builtinDir: path.join(process.resourcesPath, 'bundles') },
updater: {
remote: { endpoint: 'https://updates.example.com' },
channel: 'stable',
// integrity and signature verification also live here:
// integrityPolicy, integrityChecker, signatureVerifier
},
protocols: [bundleProtocol('app')],
});Pin a signing key to verify who published an update:
updater: {
remote: { endpoint: 'https://updates.example.com' },
signatureVerifier: {
algorithm: 'ed25519',
key: '/* raw 32-byte or PEM public key */',
},
},SignatureAlgorithm is one of ecdsaSecp256R1, ecdsaSecp384R1, ed25519, rsaPkcs1V15, rsaPss. See Remote bundles for integrity and signature details, and Building a remote for standing a server up — including a local one for testing.
Where bundles live
source accepts builtinDir (shipped, read-only) and remoteDir (downloaded updates), both with Electron-aware defaults:
| Option | Default |
|---|---|
builtinDir | process.resourcesPath/bundles when packaged, else process.cwd()/bundles |
remoteDir | app.getPath('userData')/bundles |
Downloaded versions take priority over builtin ones, so an installed update is served automatically after updater.download(...) and updater.install(...). See Bundle sources for resolution details.
Pack and ship bundles
Build your web app, then pack the output into the directory you set as builtinDir:
# Build your renderer first (e.g. `vite build`), then:
npx wvb pack ./dist --outfile bundles/app/app_1.0.0.wvbShip the bundles directory with your app so the protocol can serve it at runtime. How you stage those files into the package depends on your packaging tool — see Packaging.
Packaging
Packaging an Electron app that uses @wvb/electron needs two things to land in the final build: the builtin bundles directory has to ship with the app, and the native @wvb/node binary has to stay out of the ASAR archive so it can load at runtime. Dedicated integrations handle both and install builtin bundles at package time, so you never stage .wvb files by hand.
Electron Forge
Install builtin bundles with the @wvb/electron-forge plugin.
Electron Builder
Install builtin bundles with the @wvb/electron-builder afterPack hook.
Troubleshooting
- Blank window or
ERR_FAILED— confirmwvb(...)runs andwhenProtocolRegistered()resolves beforeloadURL, and the URL's bundle name matches the packed file (app://app.wvb→ bundleapp). - Renderer cannot reach the bridge API — the preload is not loaded. Check
webPreferences.preloadand that it callspreload(). remote_not_initialized/updater_not_initialized— aremote.*/updater.*call was made but noupdaterblock was passed towvb(...). Add the over-the-air updates config.- Works in dev, fails when packaged — the
bundlesresource or the native@wvb/nodebinary was not included in the package. See Packaging.