On this page
Bindings
deno desktop ships in Deno v2.9.0 and is not in a stable release yet. To try
it now, run deno upgrade canary to install the
canary build. The command, configuration
keys, and TypeScript APIs may still change before the feature is stable.
win.bind(name, handler) exposes a Deno-side function to the webview. From the
webview, call it as bindings.<name>(args), and the call returns a Promise
that resolves with the handler's return value.
win.bind("readSettings", async () => {
const text = await Deno.readTextFile("settings.json");
return JSON.parse(text);
});
win.bind("saveSettings", async (settings) => {
await Deno.writeTextFile("settings.json", JSON.stringify(settings, null, 2));
});
const settings = await bindings.readSettings();
settings.theme = "dark";
await bindings.saveSettings(settings);
How it works Jump to heading
Bindings are not IPC. The Deno runtime and the rendering backend run as threads / processes inside the same address space (CEF) or coordinated process group (WebView). Calls go through in-process channels, and the backend dispatches them from its run loop.
This avoids the cross-process round-trip that socket-based IPC frameworks
(Electron's ipcMain / ipcRenderer, Tauri's invoke) impose. Arguments and
results are still encoded as they cross the realm boundary, but the transport is
in-process: no socket, no cross-process scheduling.
In practical terms: bindings are fast enough that you do not need to worry about call frequency for typical app workloads.
The webview proxy Jump to heading
bindings on the webview side is a Proxy. Any property access creates a
function on demand:
bindings.foo; // function
bindings.foo("a", 1); // Promise<unknown>
The proxy does not validate names: typing bindings.readSetings instead of
bindings.readSettings does not throw at the property access; it throws when
you call it (the call rejects because no such binding is registered).
Argument and return value semantics Jump to heading
Arguments and return values are encoded as JSON as they cross between the webview and the Deno runtime. This means:
- Plain objects, arrays, strings, numbers, booleans, and
null: passed through as-is. Uint8Array: supported, for passing binary data.undefinedand optional properties: dropped during serialization.Date,Map,Set,RegExp, typed arrays other thanUint8Array,ArrayBuffer: not preserved. Convert them to a JSON-compatible shape (aDatebecomes a string, aMapbecomes{}) before sending.- Functions, DOM nodes, prototypes, and cyclic references: not transferable.
- Errors thrown by a handler: delivered to the webview as
{ name, message, stack }(see Errors below), not as anErrorinstance.
Stick to plain data and Uint8Array on both sides.
Async handlers Jump to heading
Handlers can be sync or async. The webview always sees a Promise:
win.bind("now", () => Date.now()); // sync
win.bind("delay", async (ms) => { // async
await new Promise((r) => setTimeout(r, ms));
});
const t = await bindings.now();
await bindings.delay(500);
Errors Jump to heading
A handler that throws, synchronously or via a rejected promise, causes the webview-side call to reject:
win.bind("readFile", async (path) => {
return await Deno.readTextFile(path);
});
try {
await bindings.readFile("/missing");
} catch (e) {
console.error(e); // NotFound: …
}
The error reaches the webview as a plain { name, message, stack } object. To
distinguish error types, check error.name.
Unbinding Jump to heading
win.unbind("readSettings");
Removes the binding. Subsequent bindings.readSettings() calls reject.
Permissions Jump to heading
Bindings run inside the Deno runtime, so they inherit the process's permissions.
A binding that calls Deno.readTextFile
requires --allow-read to have been granted at startup. The webview cannot
escalate the runtime's permissions through bindings.
For desktop apps you typically run with broad permissions baked into the
compiled binary (deno desktop does not currently enforce a separate permission
prompt at runtime). If you expose bindings that act on the filesystem or
network, validate inputs as carefully as you would in any trust-boundary code.
Per-window bindings Jump to heading
Bindings are per-window. A binding registered on winA is not callable from
winB's webview. To share, register on each window:
function bindShared(win: Deno.BrowserWindow) {
win.bind("now", () => Date.now());
win.bind("readSettings", readSettings);
}
const main = new Deno.BrowserWindow(); // adopts the startup window
bindShared(main);
const settings = new Deno.BrowserWindow();
bindShared(settings);
Type safety Jump to heading
There is no built-in type bridge between the Deno side's win.bind() and the
webview side's bindings.<name>(). The two sides are separate JS realms.
A small shared declaration file gives you both ends:
export interface Bindings {
readSettings(): Promise<Settings>;
saveSettings(s: Settings): Promise<void>;
now(): Promise<number>;
}
declare global {
// Make `bindings` typed in the webview.
const bindings: Bindings;
}
export interface Settings {
theme: "light" | "dark";
}
Reference it from the webview's tsconfig / Deno project config and use the
same Bindings interface to type-check your win.bind calls. Mismatches
between the registration and the declaration will be caught at compile time on
the Deno side.
Migrating from Electron Jump to heading
If you are coming from Electron's ipcMain.handle('channel', handler) /
ipcRenderer.invoke('channel', ...), the mental model is identical:
| Electron | deno desktop |
|---|---|
ipcMain.handle('channel', (e, ...args) => result) |
win.bind('channel', (...args) => result) |
ipcRenderer.invoke('channel', ...args) |
bindings.channel(...args) |
contextBridge.exposeInMainWorld('api', {...}) |
Not needed; bindings is exposed by default. |
The event object Electron passes as the first arg has no equivalent because
there is no separate process to attribute the call to. Per-window context lives
on the win you registered the binding on.