doc/desktop-architecture.md
How deno desktop actually wires a native window to a Deno runtime. This skips
the obvious (what a WebView is, what Deno.serve does) and documents the
non-obvious mechanics: the load model, the ABI handshake, the two transports,
and the lifecycle edges.
The usual embedding has the app dlopen a UI lib. Desktop is inverted. The
executable is the native backend (laufey for CEF, laufey_webview for the
system WebView); the Deno runtime is a cdylib named libdenort
(crate-type = ["cdylib"], cli/rt_desktop/). The backend is launched with
--runtime <path-to-dylib>, dlopens it, and calls into it through a C ABI.
Every launcher the packager writes is just that one invocation:
exec "$DIR/laufey_webview" --runtime "$DIR/libdenort.so" "$@" # desktop.rs:1407
So the binary that owns main(), the event loop, and the window is not Deno —
Deno is a guest the backend boots and drives.
The dylib exports the Laufey C ABI — laufey_runtime_init / _start /
_shutdown — generated by the laufey::main!(|| { … }) macro (lib.rs:1095).
The closure body is the runtime entrypoint the backend calls after init.
Version safety is two-layered:
lib.rs:47):
const _: () = assert!(laufey::LAUFEY_API_VERSION == 26, …). If the linked
laufey crate's ABI version drifts from what the shipped backend speaks,
cargo build fails loudly instead of producing a dylib that silently won't
launch.desktop.rs:29, LAUFEY_VERSION injected by cli/build.rs):
the backend binary version is pinned, and downloads are integrity-checked
against in-repo SHA-256 digests (cli/laufey_sums.lock, LAUFEY_PINNED_SUMS)
— no TOFU on the GitHub releases page. LaufeyBackendResolver resolves in
order: LAUFEY_DEV_DIR checkout → cached download → fresh download.The dylib finds itself on disk via dladdr on one of its own functions
(get_dylib_path, lib.rs:986) — needed for the auto-update sentinel and for
locating the embedded payload.
User code + assets are not files next to the dylib; they're a section inside
the dylib named d3n0l4nd, read back with
libsui::find_section_in_current_image (find_section_in_dylib,
lib.rs:1512). extract_standalone_with_finder parses it into the standalone
metadata + VFS, exactly like deno compile, but sourced from the loaded image
rather than argv0.
Inside laufey::main! everything up to the tokio runtime is deliberately
single-threaded, because two process-global operations happen there that are
unsafe once threads exist:
lib.rs:1222): allocate a random
loopback port, then set_var("DENO_SERVE_ADDRESS", "tcp:127.0.0.1:<port>").
setenv is not thread-safe on glibc (Rust 1.81+ marks it unsafe), so it
must precede the mio IO thread and inspector thread. The user's Deno.serve
/ export default { fetch } later binds this pre-set address with no
coordination needed.lib.rs:1262): process-wide, so it must
happen before any task resolves a relative path. Frameworks (Next .next/,
Vite dist/) resolve build output relative to cwd.Then run_desktop runs two futures concurrently under tokio::select!
(lib.rs:1848): denort::run::run_with_options(…) (the Deno runtime, with
auto_serve: true, serve_port, op_state_init) and laufey::run() (the
native event loop). A third spawned task, navigate_fut, bridges them.
navigate_fut (lib.rs:1773) waits for the server before pointing the window
at it. It does a full GET / HTTP/1.1 and checks for a 2xx/3xx status line
— not a bare TCP connect — because dev servers like Vite accept the socket
before they can actually serve. 60 attempts × 250ms, then
Window::navigate(url) on the initial window id. Under
--inspect-brk/--inspect-wait it first blocks until a DevTools client
attaches to the mux.
App content and JS↔native calls travel on completely separate paths.
Content — loopback HTTP. The WebView is a real browser pointed at
http://127.0.0.1:<port>/. Nothing special.
Bindings — mpsc events + per-call oneshot. Deno.desktop/window APIs and
webview→Deno function calls do not use HTTP:
DesktopEvent
(ops/desktop.rs:119), capacity 1024. High-frequency events (mouse move,
wheel) use try_send and are dropped on backpressure rather than blocking
or OOMing the runtime (DesktopEventSender::try_send, ops/desktop.rs:244).DESKTOP_JS awaiting
op_desktop_recv_event() (desktop.rs:702), dispatching each event to the
matching DOM-style EventTarget. The op promise is unrefOpPromise'd so the
pump never keeps the event loop alive by itself.A bind call round-trip (the interesting one):
window.bind(name, fn) stores fn in a per-window
Map and calls laufey add_binding_async(name) (desktop.rs:229,
lib.rs:361).window.bindings.<name>(...). The native async binding
serializes args (laufey::Value → JSON), allocates a call_id from a global
AtomicU32 and registers a oneshot sender under it (register_bind_call,
ops/desktop.rs:297), then sends
DesktopEvent::BindCall { window_id, name, args, call_id }."bindCall" arm looks up the handler, awaits it, and calls
op_desktop_resolve_bind_call(call_id, result) /
op_desktop_reject_bind_call(call_id, err) (desktop.rs:736).oneshot for that call_id and fires it
(ops/desktop.rs:1051); back in the binding future, resp_rx.await resolves
and calls js_call.resolve(...) / .reject(...) (lib.rs:391).The call_id keying is what lets many concurrent binding calls from the same
window multiplex over the one event channel and route their replies back
correctly.
The JS namespace the webview sees is bindings — set_js_namespace("bindings")
at lib.rs:1212, so calls are window.bindings.foo().
cli/rt/hmr.rs watches the source dir (notify, debounced) and classifies each
change into ChangeOutcome (hmr.rs:331), which the desktop side maps to a
ReloadKind callback (lib.rs:1642):
Debugger.setScriptSource
after deno_ast transpile. Hot-patched in place, no reload.location.reload() on every window in open_windows
(lib.rs:1648), which re-fetches from the still-running server.let changed). The
module graph can't be patched and a reload wouldn't help (the stale runtime
keeps serving), so restart_desktop_app() calls exit(75) (lib.rs:1537).
The deno desktop --hmr supervisor owns the child, catches code 75, and
relaunches — exiting via sentinel rather than re-exec keeps the process group,
Ctrl-C handling, and temp-entrypoint cleanup with the supervisor
(HMR_RESTART_EXIT_CODE, lib.rs:1529).For framework dev servers (is_framework_dev), Deno-level HMR is disabled
entirely — the framework's own websocket HMR runs and cwd is left at the source
dir so its watcher sees real files.
lib.rs:1145): framework dev servers fork worker
processes that re-exec this same dylib. A worker is detected by the
combination of argv shaped like <exe> run … script.js and a parent
worker env var (NODE_CHANNEL_FD / NEXT_PRIVATE_WORKER) — the env var alone
was a false-positive trap when a user shell already had it set. Detected
workers run headless (no window).get_raw_window_handle, lib.rs:467): laufey's
native handle is converted to raw-window-handle types per platform — AppKit
/ Win32 / X11 / Wayland (LAUFEY_WINDOW_HANDLE_WAYLAND, lib.rs:518).
Wayland is detected at runtime, giving native Wayland rather than XWayland.--inspect: cli/tools/desktop_devtools.rs runs a CDP multiplexer in the
parent fronting both the Deno inspector and the renderer's debug port behind
one /unified websocket.