Electron

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/cli
pnpm add @wvb/electron
pnpm add -D @wvb/cli
yarn add @wvb/electron
yarn add -D @wvb/cli

Register 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.

src/main.ts
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. hosts is required — a Record<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.

src/preload.ts
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.

src/renderer.ts
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:

OptionDefault
builtinDirprocess.resourcesPath/bundles when packaged, else process.cwd()/bundles
remoteDirapp.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.wvb

Ship 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.

Troubleshooting

  • Blank window or ERR_FAILED — confirm wvb(...) runs and whenProtocolRegistered() resolves before loadURL, and the URL's bundle name matches the packed file (app://app.wvb → bundle app).
  • Renderer cannot reach the bridge API — the preload is not loaded. Check webPreferences.preload and that it calls preload().
  • remote_not_initialized / updater_not_initialized — a remote.* / updater.* call was made but no updater block was passed to wvb(...). Add the over-the-air updates config.
  • Works in dev, fails when packaged — the bundles resource or the native @wvb/node binary was not included in the package. See Packaging.

On this page