13 KiB
The browser host
Yaak running in a plain tab: no install, no local process. The desktop's own
model layer — yaak-models, SQLite included — runs compiled to wasm inside a
worker the tab talks to, so a browser stores exactly what a desktop install
stores, migrations and all.
Select it at build time and run the frontend alone:
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
The flag resolves @yaakapp-internal/platform to ../index.web.ts, which
installs this host instead of the Tauri one. It is a separate entry rather than
a branch inside index.ts so that a web build never pulls @tauri-apps/* into
the module graph at all — a folded branch would drop the code but keep the
imports it guarded.
Desktop builds are untouched: without the flag, packages/platform/src/index.ts
installs the Tauri host exactly as before.
How it fits together
tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaakapp-internal/web (wasm)
│ ◀── model_writes ── crates/yaak-web → yaak-models → SQLite
│ └─ pages in IndexedDB
└── send.ts ──POST rendered request──▶ yaak-send-proxy (crates-server) ──▶ the internet
◀── NDJSON: events, response, body, cookies ──
| File | What it is |
|---|---|
index.ts |
The Platform implementation. |
commands.ts |
The command table: model commands forward to the worker; the rest is fixed answers and refusals-with-a-reason. |
connection.ts |
A tab's end of the wire: request/response over a MessagePort, event delivery, and the tab's identity (label). |
send.ts |
Sending: the worker renders (prepare_http_send), the proxy executes, this file stores what comes back where the desktop stores it. |
proxy.ts |
The proxy's location and wire shapes, mirrored by hand from crates-server/yaak-send-proxy/src/wire.rs. |
worker.ts |
The process that owns the database. Loads the wasm, opens the DB once, answers each port, fans model_writes out to every port. |
protocol.ts |
The message shapes both sides import. |
errors.ts |
UnsupportedCommandError, the structured refusal. |
storage.ts |
navigator.storage.persist(). |
The Rust side is crates/yaak-web (@yaakapp-internal/web): boot(),
rpc(cmd, payload, label) returning { result, events }, blob get/put, and
prepare_http_send(payload) — the database half of a send (environment chain,
inherited headers and auth, request settings, cookie jar, rendering), which is
yaak_models::render::render_http_request, the same function the desktop
renders with.
Its pkg/ is committed; rebuilding needs a clang with a WebAssembly backend
(brew install llvm), and build-wasm.cjs skips with a notice when there
isn't one, so a desktop npm run bootstrap never depends on it.
Behaviours worth knowing before changing anything:
- The worker is a
SharedWorker, and only that. The browser hands every tab on the origin the same one, which is what makes "one database owner" true without anyone coordinating — and makes the browser look like the desktop: one process holds the data, every window talks to it, it pushes writes to all of them. It still takes a Web Lock before opening, for the one overlap the browser doesn't rule out (a reloading tab's dying predecessor). There is deliberately no fallback to a per-tab worker: two kinds of worker that can both come up is a race. Every current browser, desktop and mobile, hasSharedWorkerand Web Locks (Chrome for Android since 148, April 2026); older ones get a clear "unsupported browser" message rather than a second SQLite over the same pages. - Every write is stamped with the calling tab's
labelasUpdateSource::Window, exactly like a desktop window label, so the frontend store's echo handling is unchanged. - Cascade rules, duplicate naming, id generation, serde defaults, and the lazy first-run bootstrap are all the Rust code's. Nothing about what a model is is decided in TypeScript.
- Persistence is
relaxed-idb: SQLite pages live in IndexedDB, writes land in memory and flush shortly after. A tab closing mid-flush loses at most the last few writes.
Commands
109 commands are declared in @yaakapp-internal/rpc-schema. This host answers
32, declines 43 by name with a reason, and refuses the remaining 34 generically.
Implemented (32)
| Group | Commands |
|---|---|
| Models | models_workspace_models, models_upsert, models_delete, models_duplicate, models_get_settings, models_get_graphql_introspection, models_upsert_graphql_introspection, models_grpc_events, models_websocket_events |
| Sending | cmd_send_http_request (through the send proxy; see below) |
| App | cmd_metadata, cmd_get_workspace_meta, cmd_default_headers, cmd_get_themes, cmd_check_for_updates, cmd_dismiss_notification, cmd_plugin_init_errors |
| Bodies | cmd_http_response_body, cmd_http_response_body_path, cmd_http_request_body, cmd_get_http_response_events, cmd_get_sse_events |
| Plugin surfaces (empty results) | cmd_http_request_actions, cmd_websocket_request_actions, cmd_grpc_request_actions, cmd_workspace_actions, cmd_folder_actions, cmd_template_function_summaries, cmd_get_http_authentication_summaries, cmd_get_http_authentication_config |
| Text | cmd_format_json, cmd_render_template |
Some of these answer honestly rather than fully, and the difference matters:
cmd_render_templatereturns the template unrendered. Resolving variables and calling template functions is plugin work. The preview shows the raw${[…]}rather than a wrong value.cmd_get_http_authentication_summariesreturns the auth methods Yaak ships as plugins, so the picker is truthful about the product — butcmd_get_http_authentication_configreturns an empty form, because the plugin that defines the form isn't running.cmd_template_function_summariesreturns one provider contributing no functions. Both summary commands are polled every second until they return something, so an empty list is a poll that never stops rather than a quiet no.cmd_metadatareports empty strings for the data, log, plugin and project directories. There is no filesystem behind this host.
Declined by name (43)
Each returns an UnsupportedCommandError carrying cmd, a user-facing
message, and the capability a caller should have checked. The UI turns it
into a toast.
| Reason | Commands |
|---|---|
| Sending, the parts not wired yet | cmd_send_ephemeral_request, cmd_delete_send_history, cmd_delete_all_http_responses, cmd_import_url |
| No plugin runtime | cmd_reload_plugins, cmd_plugin_info, cmd_plugins_search, cmd_plugins_install, cmd_plugins_install_from_directory, cmd_plugins_uninstall, cmd_plugins_updates, cmd_plugins_update_all, cmd_template_function_config, cmd_template_tokens_to_string, cmd_call_http_request_action, cmd_call_websocket_request_action, cmd_call_grpc_request_action, cmd_call_workspace_action, cmd_call_folder_action, cmd_call_http_authentication_action, cmd_curl_to_request, cmd_format_graphql |
| No filesystem | cmd_import_data, cmd_export_data, cmd_save_response, cmd_save_base64_to_binary |
| Needs a real socket | cmd_grpc_reflect, cmd_grpc_go, cmd_delete_all_grpc_connections, cmd_ws_connect, cmd_ws_send, cmd_ws_close, cmd_ws_delete_connections |
| Workspace encryption | cmd_enable_encryption, cmd_disable_encryption, cmd_reveal_workspace_key, cmd_set_workspace_key, cmd_secure_template, cmd_decrypt_template |
| One tab, no windows | cmd_new_child_window, cmd_new_main_window, cmd_restart |
| Other | cmd_send_feedback |
Refused generically (34)
The 30 cmd_git_* commands and cmd_sync_calculate, cmd_sync_calculate_fs,
cmd_sync_apply, cmd_sync_watch. Nothing in the app reaches them unless a
workspace has a sync directory, which a browser tab cannot set.
Anything added to the schema later also lands here, and the error names the
command — an unlisted command is a gap in commands.ts, and whoever hits it
should be able to see which.
Capabilities
Reported honestly, so callers gate on the question rather than on the host:
| True | False |
|---|---|
httpSending, timeline, cookieJar |
grpc, websocket, git, sync, tlsOptions, localFiles, multiWindow, windowChrome, interfaceZoom, plugins, encryption, updater, clipboardRead, systemFonts, license |
interfaceZoom: false leaves Cmd/Ctrl +, - and 0 to the browser instead
of swallowing them, and drops those three rows from the hotkeys screen.
multiWindow: false means the host cannot open a second window on demand —
what cmd_new_child_window does for Settings and workspace switching. It is not
a claim that nothing else is looking: other tabs may well be open on the same
worker, and it pushes every write to all of them regardless.
windowChrome: false means the browser owns the frame around the page, so the
header draws no window controls and reserves no room for macOS traffic lights.
Multiple tabs
Each tab mints a label at load (tab_xxxxxxxx) and sends it with every command;
the worker stamps writes with it as UpdateSource::Window { label }, standing in
for the desktop's window label. The worker fans each write out to every
connected tab, and the receiving tab's store applies or ignores it exactly as a
desktop window would.
The label is deliberately not kept in sessionStorage: duplicating a tab
copies session storage, and two tabs sharing one identity would each mistake the
other's writes for an echo of their own and drop them.
Known gaps
- Storage persistence is requested, not guaranteed.
navigator.storage.persist()runs at boot; browsers grant it on their own heuristics and often decline onlocalhost. pkg/yaak_web_bg.wasmis 3.8 MB and committed (nowasm-opt, matchingyaak-templates). It will churn on every model-layer change; a CI-built artifact is the real answer.- Settings opens in the same tab and is left with the browser's Back button.
- Settings shows Data Directory / Logs Directory rows with empty values; the
Create Workspace dialog offers directory sync and encryption. Should be gated
on
localFiles/sync/encryption. cmd_render_templatereturns the template unrendered. Resolving variables and calling template functions is plugin work.- A declined command logs an unhandled rejection next to its toast — the
app's own
createFastMutation.mutate, same on desktop. yaak-rpc-schemadoes not come to wasm (it pulls the git/gRPC/plugin crates for their types), so the crate declares the handful of request shapes it needs locally, andcommands.tsstays typed againstRpcSchema.
Sending
A page cannot see a response the way a desktop app can — CORS exposes a handful
of headers, redirects are followed silently, there is no timeline — so the
network half of a send runs on a small stateless proxy,
crates-server/yaak-send-proxy. This layer stays the only place data lives:
send.tscreates thehttp_responserow (stateinitialized), as the desktop does, so anything that goes wrong lands in the response pane.- The worker resolves and renders the request (
prepare_http_send): the environment chain, inherited headers and auth, request settings, the cookie jar. This is the desktop'sHttpSendInputs, in Rust, on the same model layer, withyaak_models::render::render_http_request. Variables (${[ name ]}) render here with no plugins involved. - The rendered request, the settings and the jar's cookies are POSTed to the proxy. It streams back timeline events, the response head, body chunks and a terminal frame carrying the jar as the send left it.
- Each frame is written where the desktop writes it: the response row as it
progresses,
http_response_eventrows for the timeline (which is whytimelineis true), the body under the response id viablob_put, and the cookie jar throughmodels_upsert. Every write fans out to every tab.
What sends today: any saved request whose templates are variables and whose
authentication is none, or an inline header. Sending a request that needs a
template function (${[ timestamp() ]}) or an authentication plugin (bearer,
basic, OAuth, …) is refused before anything leaves the tab, with a message naming
what it needs; those light up when plugins run in the browser. Requests with a
file body or multipart file fields are refused by the proxy (it has no access to
your files, and must not read its own). And a request to localhost or a LAN
address can't work from a browser: the proxy runs elsewhere and refuses private
ranges outright — reaching your own machine's APIs is what the desktop app is for.
The proxy URL is VITE_YAAK_SEND_PROXY_URL at build time, defaulting to
http://127.0.0.1:9227 (see proxy.ts). Run one with cargo run -p yaak-send-proxy.