diff --git a/apps/yaak-client/vite.config.ts b/apps/yaak-client/vite.config.ts index 27694a03..91472e12 100644 --- a/apps/yaak-client/vite.config.ts +++ b/apps/yaak-client/vite.config.ts @@ -29,6 +29,23 @@ const iconsDir = normalizePath( */ const yaakTarget = process.env.YAAK_TARGET === "web" ? "web" : "desktop"; +/** + * Where `yaak-web` is listening, taken from the same variable that put it there. + * + * A wildcard bind is an instruction about what the server accepts, not an address + * to dial, so it becomes loopback here — the dev server and the send server share + * a machine. + */ +function sendServerUrl(): string { + const bind = process.env.YAAK_WEB_BIND?.trim(); + if (!bind) return "http://127.0.0.1:9227"; + const port = bind.slice(bind.lastIndexOf(":") + 1); + const host = bind.slice(0, bind.lastIndexOf(":")); + const dialable = + !host || host === "0.0.0.0" || host === "[::]" || host === "::" ? "127.0.0.1" : host; + return `http://${dialable}:${port}`; +} + // https://vitejs.dev/config/ export default defineConfig(async () => { return { @@ -94,8 +111,28 @@ export default defineConfig(async () => { }, clearScreen: false, server: { + // `HOST` names the interface, as it does most places: unset leaves Vite on + // loopback, `0.0.0.0` exposes it for reaching the dev server from another + // device. + host: process.env.HOST, + // Vite refuses a `Host` it does not recognise, which stops a page on a name the + // attacker controls from rebinding that name here and driving `/v1` as its own + // origin. Addresses are allowed already; only names need listing, so reaching + // this as `dev-box.example` means naming it. Deliberately not widened to "any + // host when exposed": the proxy below leads to an unauthenticated sender. + allowedHosts: process.env.ALLOWED_HOSTS?.split(",") + .map((h) => h.trim()) + .filter(Boolean), port: parseInt(process.env.YAAK_CLIENT_DEV_PORT ?? process.env.YAAK_DEV_PORT ?? "1420", 10), strictPort: true, + // A web dev server is one origin, the way the built app is: `/v1` is passed + // through to `yaak-web` rather than the tab being told to call it directly. + // That is one address to open instead of two, no CORS in the loop, and a + // dev build that sends exactly the way a production build does. + proxy: + yaakTarget === "web" + ? { "/v1": { target: sendServerUrl(), changeOrigin: true } } + : undefined, }, envPrefix: ["VITE_", "TAURI_"], }; diff --git a/crates-server/yaak-web/README.md b/crates-server/yaak-web/README.md index 2006cbf0..0b920113 100644 --- a/crates-server/yaak-web/README.md +++ b/crates-server/yaak-web/README.md @@ -46,7 +46,7 @@ docker run -p 8080:8080 \ anything strangers can reach — see [What it refuses](#what-it-refuses-and-why). Turn it on for an instance on your own network, where calling the API on the next machine is the whole point. Note that "private" is relative to the - *container*: `127.0.0.1` is the container itself, and reaching the Docker + _container_: `127.0.0.1` is the container itself, and reaching the Docker host means `host.docker.internal` (or `--network host`). - **`YAAK_WEB_RATE_LIMIT_PER_MINUTE`** defaults to 120 sends per client IP, which suits a public instance and not a team of your own; `0` disables it. @@ -72,27 +72,33 @@ cargo run -p yaak-web YAAK_TARGET=web npm run dev --workspace @yaakapp/yaak-client ``` -A dev build looks for the server at `http://127.0.0.1:9227` (the Vite server is a -different origin and serves no `/v1`); a production build sends to its own -origin unless `VITE_YAAK_WEB_URL` was set when it was built. +The dev server passes `/v1` through to this binary, so a dev build sends to its +own origin exactly like a production build does — one address to open, and no +CORS in the loop. `YAAK_WEB_BIND` moves this server and the dev server follows +it. A production build also sends to its own origin, unless `VITE_YAAK_WEB_URL` +was set when it was built. + +Reaching the dev server from another machine is `HOST=0.0.0.0`. Vite allows +addresses but not names, so opening it as a hostname also needs +`ALLOWED_HOSTS=that-name`. ## Configuration Every flag has a `YAAK_WEB_*` environment variable, so a container needs no arguments; `--help` lists them all. -| Flag | Default | What | -| --- | --- | --- | -| `--serve` | off | Also serve a built web client from this directory, on the same origin. | -| `--bind` | `127.0.0.1:9227` | Listen address. The image sets `0.0.0.0:8080`. | -| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. | -| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. | -| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. | -| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. | -| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. | -| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. | -| `--max-concurrent` | 256 | Sends in flight at once. | -| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. | +| Flag | Default | What | +| -------------------------- | ---------------- | --------------------------------------------------------------------------------------------- | +| `--serve` | off | Also serve a built web client from this directory, on the same origin. | +| `--bind` | `127.0.0.1:9227` | Listen address. The image sets `0.0.0.0:8080`. | +| `--allow-private-networks` | off | Allow sends to loopback, private and link-local addresses. | +| `--allowed-origins` | `*` | CORS origins, comma-separated. Unused when the app is served from here: same origin, no CORS. | +| `--max-request-bytes` | 16 MiB | Largest rendered request accepted from the tab. | +| `--max-response-bytes` | 64 MiB | Largest upstream body relayed before the send is cut off. | +| `--max-timeout-secs` | 60 | Ceiling on a send's timeout; a request asking for more (or none) gets this. | +| `--rate-limit-per-minute` | 120 | Sends per client IP per minute; 0 disables. | +| `--max-concurrent` | 256 | Sends in flight at once. | +| `--trust-forwarded-for` | off | Take the client IP from `X-Forwarded-For`. Only behind a load balancer that sets it. | ## Logging @@ -181,7 +187,7 @@ in `lib.rs`, or in middleware on the exported router. Put TLS in front of a publ { "request": { "url": "https://…", "method": "GET", "headers": […], "body": {…}, "bodyType": null, "urlParameters": […] }, "settings": { "validateCertificates": true, "followRedirects": true, "timeoutMs": 0, "sendCookies": true, "storeCookies": true }, - "cookies": [ … ] + "cookies": [ … ] } ``` @@ -193,13 +199,13 @@ jar's contents (or `null` for no jar). The reply is `application/x-ndjson`, one JSON frame per line, in the order things happened: -| `type` | When | Carries | -| --- | --- | --- | -| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape | +| `type` | When | Carries | +| ---------- | ----------------------------------------- | ---------------------------------------------------------------------------------- | +| `event` | as the engine produces them | one timeline event, in the desktop's `http_response_event.event` shape | | `response` | once, when the final hop's headers arrive | status, all headers, request headers as sent, remote address, HTTP version, timing | -| `body` | as the body is read | a decompressed chunk, base64 | -| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it | -| `error` | last, on failure | the reason, and any cookies collected before the failure | +| `body` | as the body is read | a decompressed chunk, base64 | +| `done` | last, on success | elapsed, byte counts, and the cookie jar as the send left it | +| `error` | last, on failure | the reason, and any cookies collected before the failure | Refusals that happen before anything is sent (a blocked destination, a bad body, rate limit, capacity) are plain HTTP errors (`403`, `400`, `429`, `503`) with diff --git a/packages/platform/src/web/README.md b/packages/platform/src/web/README.md index 6f6cf3d5..0a99f07d 100644 --- a/packages/platform/src/web/README.md +++ b/packages/platform/src/web/README.md @@ -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. diff --git a/packages/platform/src/web/connection.ts b/packages/platform/src/web/connection.ts index 13769a51..e66b7ff4 100644 --- a/packages/platform/src/web/connection.ts +++ b/packages/platform/src/web/connection.ts @@ -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; diff --git a/packages/platform/src/web/ids.ts b/packages/platform/src/web/ids.ts new file mode 100644 index 00000000..613d918f --- /dev/null +++ b/packages/platform/src/web/ids.ts @@ -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(""); +} diff --git a/packages/platform/src/web/index.ts b/packages/platform/src/web/index.ts index eb661ca2..9f9c9d37 100644 --- a/packages/platform/src/web/index.ts +++ b/packages/platform/src/web/index.ts @@ -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> { // 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, diff --git a/packages/platform/src/web/server.ts b/packages/platform/src/web/server.ts index 1ce57624..3c60e9ee 100644 --- a/packages/platform/src/web/server.ts +++ b/packages/platform/src/web/server.ts @@ -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 }).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 {