Make the web dev server work from another machine (#665)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-09-15 10:29:07 -07:00
committed by GitHub
co-authored by Claude Opus 5
parent 48889b6550
commit a822cc93d4
7 changed files with 154 additions and 78 deletions
+44 -43
View File
@@ -5,10 +5,11 @@ model layer — `yaak-models`, SQLite included — runs compiled to wasm inside
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:
Select it at build time. `vp run web:dev` does that and starts the send server
alongside the frontend:
```shell
YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client
vp run web:dev
```
The flag resolves `@yaakapp-internal/platform` to `../index.web.ts`, which
@@ -30,17 +31,17 @@ tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaak
◀── 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 server executes, this file stores what comes back where the desktop stores it. |
| `server.ts` | Where the Yaak server is, and the wire shapes it speaks (generated from `crates-server/yaak-web/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()`. |
| 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 server executes, this file stores what comes back where the desktop stores it. |
| `server.ts` | Where the Yaak server is, and the wire shapes it speaks (generated from `crates-server/yaak-web/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-wasm` (`@yaakapp-internal/wasm`): `boot()`,
`rpc(cmd, payload, label)` returning `{ result, events }`, blob get/put, and
@@ -70,7 +71,7 @@ Behaviours worth knowing before changing anything:
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.
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.
@@ -82,14 +83,14 @@ Behaviours worth knowing before changing anything:
### 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 Yaak server; 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` |
| 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 Yaak server; 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` |
| Text | `cmd_format_json`, `cmd_render_template` |
Some of these answer honestly rather than fully, and the difference matters:
@@ -112,15 +113,15 @@ 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` |
| 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)
@@ -136,14 +137,14 @@ should be able to see which.
Reported honestly, so callers gate on the question rather than on the host:
| True | False |
| --- | --- |
| 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 —
`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.
@@ -159,7 +160,7 @@ 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
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.
@@ -207,7 +208,7 @@ network half of a send runs on a small stateless server,
**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,
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 server (it has no access to
@@ -217,11 +218,11 @@ private ranges outright — that is what the desktop app is for. A self-hosted
server on your own network can be started with `--allow-private-networks`, which
is the one case where those addresses are the user's to reach.
**Where the tab sends** (`server.ts`): a production build posts to `/v1/http/send`
on its own origin, because the server can serve the app itself
(`yaak-web --serve dist/apps/yaak-client`, which is what the
`ghcr.io/mountain-loop/yaak-web` image runs) — same origin, so no CORS and
nothing to configure. A dev build falls back to `http://127.0.0.1:9227`, since
the Vite server is a different origin and serves no `/v1`; run one with
`cargo run -p yaak-web`. `VITE_YAAK_WEB_URL` overrides both, for a
deployment that keeps the app and the server apart.
**Where the tab sends** (`server.ts`): its own origin, in development as well as
production, so there is no address to learn and no CORS in the loop. A
production build is served by the sender itself (`yaak-web --serve
dist/apps/yaak-client`, which is what the `ghcr.io/mountain-loop/yaak-web`
image runs). In development the Vite server passes `/v1` through to `yaak-web`
instead, following `YAAK_WEB_BIND` to find it — `vp run web:dev` starts both.
`VITE_YAAK_WEB_URL` overrides this, for a deployment that keeps the app and the
server apart.
+2 -1
View File
@@ -11,6 +11,7 @@
*/
import type { Unsubscribe } from "../types";
import { randomId } from "./ids";
import { type FromWorker, type ToWorker, WORKER_NAME } from "./protocol";
/**
@@ -45,7 +46,7 @@ export class WorkerConnection {
* and two tabs claiming one identity would each drop the other's writes as
* echoes.
*/
readonly label = `tab_${crypto.randomUUID().slice(0, 8)}`;
readonly label = `tab_${randomId(4)}`;
/** True once the worker has said anything at all. */
private heard = false;
+14
View File
@@ -0,0 +1,14 @@
/**
* Random ids that do not need a secure context.
*
* `crypto.randomUUID` exists only on HTTPS and localhost, so a tab opened at
* `http://some-host:1424` — a self-hosted instance reached from another machine —
* throws on it before anything renders. `crypto.getRandomValues` carries no such
* restriction, and nothing here needs UUID semantics: these ids identify a tab and
* a stream within one page, and only have to not collide with each other.
*/
export function randomId(bytes = 8): string {
const buf = new Uint8Array(bytes);
crypto.getRandomValues(buf);
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
}
+22 -5
View File
@@ -28,6 +28,7 @@ import type {
import { commandSupport, runCommand } from "./commands";
import { WorkerConnection } from "./connection";
import { unsupported } from "./errors";
import { randomId } from "./ids";
import { serverBaseUrl } from "./server";
import { requestPersistence } from "./storage";
@@ -184,10 +185,18 @@ export function createWebPlatform(): Platform {
clipboard: {
writeText: (text) => navigator.clipboard.writeText(text),
readText: async () => {
throw unsupported("clipboard.readText", "Paste instead — Yaak in a browser can't read the clipboard on its own", "clipboardRead");
throw unsupported(
"clipboard.readText",
"Paste instead — Yaak in a browser can't read the clipboard on its own",
"clipboardRead",
);
},
clear: async () => {
throw unsupported("clipboard.clear", "Yaak in a browser can't modify the clipboard", "clipboardRead");
throw unsupported(
"clipboard.clear",
"Yaak in a browser can't modify the clipboard",
"clipboardRead",
);
},
},
@@ -200,7 +209,11 @@ export function createWebPlatform(): Platform {
files: {
readDir: async () => {
throw unsupported("files.readDir", "A browser tab can't browse your filesystem", "localFiles");
throw unsupported(
"files.readDir",
"A browser tab can't browse your filesystem",
"localFiles",
);
},
readText: async () => {
throw unsupported("files.readText", "A browser tab can't read local files", "localFiles");
@@ -243,7 +256,7 @@ export function createWebPlatform(): Platform {
): Promise<RpcStreamHandle<T>> {
// Same shape as the desktop — subscribe first, then dispatch — so that a
// command which grows the ability to stream here needs no caller changes.
const streamId = crypto.randomUUID();
const streamId = randomId();
const unlisten = db.listen(`stream_${streamId}`, (p) => onMessage(p as M));
try {
const result = (await runCommand(cmd, { ...payload, streamId }, db)) as T;
@@ -268,7 +281,11 @@ export function createWebPlatform(): Platform {
},
revealItemInDir: async () => {
throw unsupported("revealItemInDir", "A browser tab can't open your file manager", "localFiles");
throw unsupported(
"revealItemInDir",
"A browser tab can't open your file manager",
"localFiles",
);
},
osType: detectOsType,
+6 -6
View File
@@ -16,16 +16,16 @@ import type { Frame } from "@yaakapp-internal/web";
* (`yaak-web --serve`), and then a send is a request to a path on the
* page's own origin — no CORS, and nothing for a self-hoster to configure.
*
* `VITE_YAAK_WEB_URL` overrides it at build time, for a deployment that
* keeps the two apart. The dev server is one of those: it serves the app on its
* own origin and knows nothing about `/v1`, so a dev build falls back to a server
* running locally (`cargo run -p yaak-web`).
* `VITE_YAAK_WEB_URL` overrides it at build time, for a deployment that keeps the
* two apart. Nothing else needs to: the dev server passes `/v1` through to
* `yaak-web` (see the client's vite config), so a dev build and a production
* build are both talking to their own origin, and neither has an address to
* learn. A tab opened from another machine therefore works unchanged.
*/
export function serverBaseUrl(): string {
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env;
const configured = env?.VITE_YAAK_WEB_URL?.trim();
if (configured) return configured.replace(/\/+$/, "");
return env?.DEV ? "http://127.0.0.1:9227" : "";
return configured ? configured.replace(/\/+$/, "") : "";
}
export function serverSendUrl(): string {