Serve the web client from yaak-web (#582)

This commit is contained in:
Gregory Schier
2026-08-18 10:39:17 -07:00
committed by GitHub
parent 3f202ff664
commit 538f782068
50 changed files with 809 additions and 424 deletions
+1 -1
View File
@@ -11,8 +11,8 @@
"dependencies": {
"@yaakapp-internal/models": "^1.0.0",
"@yaakapp-internal/rpc-schema": "^1.0.0",
"@yaakapp-internal/send-proxy": "^1.0.0",
"@yaakapp-internal/web": "^1.0.0",
"@yaakapp-internal/wasm": "^1.0.0",
"@tauri-apps/api": "^2.11.0",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.7.1",
+23 -15
View File
@@ -24,9 +24,9 @@ installs the Tauri host exactly as before.
```
tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaakapp-internal/web (wasm)
│ ◀── model_writes ── crates/yaak-web → yaak-models → SQLite
│ ◀── model_writes ── crates/yaak-wasm → yaak-models → SQLite
│ └─ pages in IndexedDB
└── send.ts ──POST rendered request──▶ yaak-send-proxy (crates-server) ──▶ the internet
└── send.ts ──POST rendered request──▶ yaak-web (crates-server) ──▶ the internet
◀── NDJSON: events, response, body, cookies ──
```
@@ -35,14 +35,14 @@ tab (index.ts, commands.ts) ──MessagePort──▶ worker.ts ──▶ @yaak
| `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`. |
| `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-web` (`@yaakapp-internal/web`): `boot()`,
The Rust side is `crates/yaak-wasm` (`@yaakapp-internal/wasm`): `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
@@ -85,7 +85,7 @@ Behaviours worth knowing before changing anything:
| 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) |
| 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` |
@@ -187,8 +187,8 @@ other's writes for an echo of their own and drop them.
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:
network half of a send runs on a small stateless server,
`crates-server/yaak-web`. This layer stays the only place data lives:
1. `send.ts` creates the `http_response` row (state `initialized`), as the
desktop does, so anything that goes wrong lands in the response pane.
@@ -198,7 +198,7 @@ network half of a send runs on a small stateless proxy,
with `yaak_models::render::render_http_request`. Variables (`${[ name ]}`)
render here with no plugins involved.
3. 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
server. It streams back timeline events, the response head, body chunks and a
terminal frame carrying the jar as the send left it.
4. Each frame is written where the desktop writes it: the response row as it
progresses, `http_response_event` rows for the timeline (which is why
@@ -210,10 +210,18 @@ 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.
file body or multipart file fields are refused by the server (it has no access to
your files, and must not read its own). And on a public instance a request to
`localhost` or a LAN address can't work: the server runs elsewhere and refuses
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.
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`.
**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.
+3 -3
View File
@@ -71,7 +71,7 @@ const HANDLERS: Partial<Record<AppCmd, Handler>> = {
/* ------------------------------- sending ------------------------------- */
// The tab renders and stores; a stateless proxy puts the bytes on the wire.
// The tab renders and stores; a stateless server puts the bytes on the wire.
// See send.ts for the whole shape of it.
cmd_send_http_request: (payload, db) => {
const requestId = str(payload, "requestId");
@@ -249,7 +249,7 @@ const HTTP_AUTHENTICATION_SUMMARIES = [
* while the first is a slice away.
*/
const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityName | null]>> = {
// Saved requests send through the proxy (see send.ts). Ephemeral sends — the
// Saved requests send through the server (see send.ts). Ephemeral sends — the
// ones nothing stores, used for GraphQL introspection — take the same road but
// return the body inline; not wired yet.
cmd_send_ephemeral_request: ["Sending unsaved requests isn't available in the browser yet", null],
@@ -266,7 +266,7 @@ const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityNa
// Anything that needs files the page can't reach.
cmd_import_data: ["Importing from a file needs a filesystem, which a browser tab has no", "localFiles"],
cmd_import_url: ["Importing from a URL needs the send proxy, which isn't available yet", null],
cmd_import_url: ["Importing from a URL needs the Yaak server, which isn't available yet", null],
cmd_export_data: ["Exporting to a file isn't available in the browser yet", "localFiles"],
cmd_save_response: ["Saving a response to disk isn't available in the browser", "localFiles"],
cmd_save_base64_to_binary: ["Saving to disk isn't available in the browser", "localFiles"],
+1 -1
View File
@@ -163,7 +163,7 @@ export class WorkerConnection {
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
}
/** See `prepare_http_send` in crates/yaak-web: the database half of a send. */
/** See `prepare_http_send` in crates/yaak-wasm: the database half of a send. */
prepareHttpSend<T>(payload: unknown): Promise<T> {
return this.request<T>((id) => ({ type: "prepare_http_send", id, payload }));
}
+4 -4
View File
@@ -7,7 +7,7 @@
* the origin, so two tabs stay coherent for the same reason two desktop windows
* do: one process holds the data and pushes every write to all of them.
*
* Sending goes through a small stateless proxy, because a page cannot see a
* Sending goes through a small stateless server, because a page cannot see a
* response the way a desktop app can (see send.ts). What a page genuinely
* cannot do is not faked: there is no file dialog, no second window, no
* clipboard read without a prompt. Those report false through `capabilities`
@@ -33,7 +33,7 @@ import { requestPersistence } from "./storage";
/** What this host can do, reported honestly. */
function capabilitiesFor(): PlatformCapabilities {
return {
// Through the send proxy: the tab renders, the proxy executes, the tab
// Through the Yaak server: the tab renders, the server executes, the tab
// stores. Requests needing plugin auth or template functions are refused
// with the reason until plugins run here.
httpSending: true,
@@ -42,11 +42,11 @@ function capabilitiesFor(): PlatformCapabilities {
git: false,
sync: false,
// Certificates and proxies are decided by whoever puts the bytes on the
// wire, and the send proxy uses its own.
// wire, and the Yaak server uses its own.
tlsOptions: false,
cookieJar: true,
localFiles: false,
// The proxy streams the engine's events back and the sender stores them.
// The server streams the engine's events back and the sender stores them.
timeline: true,
// Whether the host can put a *second window* on this data on demand — what
// `cmd_new_child_window` does for Settings and workspace switching. A tab
-71
View File
@@ -1,71 +0,0 @@
/**
* The wire to the send proxy: where it is, and how to read what comes back.
*
* The shapes themselves are generated from `crates-server/yaak-send-proxy/src/wire.rs`
* into `@yaakapp-internal/send-proxy`, so the two sides cannot drift silently.
*/
import type { Frame } from "@yaakapp-internal/send-proxy";
/* ------------------------------- location -------------------------------- */
/**
* Where the tab sends. Build-time configuration for now: `VITE_YAAK_SEND_PROXY_URL`
* (Vite exposes `VITE_*` to the bundle), defaulting to a proxy on this machine at
* its default port. A per-user setting can replace this later without touching
* the callers, which only ever ask for the URL.
*/
export function proxyBaseUrl(): string {
const env = (import.meta as unknown as { env?: Record<string, string | undefined> }).env;
const configured = env?.VITE_YAAK_SEND_PROXY_URL?.trim();
return (configured || "http://127.0.0.1:9227").replace(/\/+$/, "");
}
export function proxySendUrl(): string {
return `${proxyBaseUrl()}/v1/http/send`;
}
let identity: Promise<string> | null = null;
/**
* Who does the sending, for the timeline: `yaak-send-proxy 0.1.0 at http://…`.
* Asked of `/v1/health` once per page load; if the proxy can't be reached the
* URL alone is the answer, and the send itself will say why shortly after.
*/
export function proxyIdentity(): Promise<string> {
identity ??= fetch(`${proxyBaseUrl()}/v1/health`)
.then((res) => res.json() as Promise<{ version?: string }>)
.then((health) => `yaak-send-proxy ${health.version ?? ""} at ${proxyBaseUrl()}`.replace(" ", " "))
.catch(() => {
identity = null; // try again next send
return `send proxy at ${proxyBaseUrl()}`;
});
return identity;
}
/**
* Yield frames from an NDJSON stream as they arrive. A partial trailing line is
* held until its newline comes; anything left when the stream ends is dropped,
* because a frame without its newline is a frame the proxy didn't finish writing.
*/
export async function* readFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<Frame> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim() !== "") yield JSON.parse(line) as Frame;
newline = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
+17 -17
View File
@@ -3,7 +3,7 @@
*
* A tab can't see a response the way the desktop can — CORS hides most headers,
* redirects are followed silently, there is no timeline — so the network half of
* a send happens on a small stateless proxy (`crates-server/yaak-send-proxy`).
* a send happens on a small stateless server (`crates-server/yaak-web`).
* Everything else happens here, against this tab's own database, in the same
* order the desktop does it:
*
@@ -11,13 +11,13 @@
* 2. resolve and render the request in the worker (`prepare_http_send`: the
* environment chain, inherited headers and auth, request settings, cookie
* jar — the desktop's `HttpSendInputs`, in Rust, on the same model layer);
* 3. POST the rendered request to the proxy and consume its stream: timeline
* 3. POST the rendered request to the server and consume its stream: timeline
* events, the response head, body chunks, and a terminal frame;
* 4. write what comes back where the desktop writes it — the response row as
* it progresses, `http_response_event` rows for the timeline, the body
* blob under the response id, the cookie jar with the proxy's changes.
* blob under the response id, the cookie jar with the server's changes.
*
* The proxy keeps nothing. Every byte it sees comes from this tab and every
* The server keeps nothing. Every byte it sees comes from this tab and every
* byte it returns is stored by this tab.
*/
@@ -31,9 +31,9 @@ import type {
HttpResponseEventData,
HttpSendSettings,
} from "@yaakapp-internal/models";
import type { Frame, SendRequest } from "@yaakapp-internal/send-proxy";
import type { Frame, SendRequest } from "@yaakapp-internal/web";
import type { WorkerConnection } from "./connection";
import { proxyIdentity, proxySendUrl, readFrames } from "./proxy";
import { serverIdentity, serverSendUrl, readFrames } from "./server";
/* -------------------------------- shapes --------------------------------- */
@@ -48,7 +48,7 @@ type ResponseRow = Pick<HttpResponse, "model" | "requestId" | "workspaceId"> &
type ResponsePatch = Partial<HttpResponse>;
/** What `prepare_http_send` (crates/yaak-web) hands back. */
/** What `prepare_http_send` (crates/yaak-wasm) hands back. */
interface PreparedHttpSend {
request: HttpRequest;
settings: HttpSendSettings;
@@ -68,7 +68,7 @@ export async function sendHttpRequest(
cookieJarId: string | null,
): Promise<ResponseRow> {
// The response row exists before anything can go wrong, as on the desktop, so
// a failure to render or to reach the proxy lands in the response pane as
// a failure to render or to reach the server lands in the response pane as
// that response's error rather than as a toast that names no request.
const workspaceId = await workspaceIdOfRequest(db, requestId);
const response = new ResponseWriter(db, { model: "http_response", requestId, workspaceId });
@@ -107,7 +107,7 @@ async function runSend(
// request through a proxy shows a different origin to the server than the
// user's machine, and this is where that should be visible.
const timeline = new TimelineWriter(db, response.id, response.workspaceId);
timeline.push([{ type: "info", message: `Executed by ${await proxyIdentity()}` }]);
timeline.push([{ type: "info", message: `Executed by ${await serverIdentity()}` }]);
timeline.push(prepared.settingEvents);
const body: SendRequest = {
@@ -116,19 +116,19 @@ async function runSend(
cookies: prepared.cookieJar?.cookies ?? null,
};
const startedAt = performance.now();
const res = await fetch(proxySendUrl(), {
const res = await fetch(serverSendUrl(), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal,
}).catch((err: unknown) => {
if (signal.aborted) throw err;
throw new Error(`Couldn't reach the send proxy at ${proxySendUrl()}: ${errorMessage(err)}`);
throw new Error(`Couldn't reach the Yaak server at ${serverSendUrl()}: ${errorMessage(err)}`);
});
if (!res.ok) {
// A refusal, not a failed send: bad destination, rate limit, a body the
// proxy can't build. It comes as JSON with the reason.
// server can't build. It comes as JSON with the reason.
const text = await res.text();
let reason = text;
try {
@@ -136,9 +136,9 @@ async function runSend(
} catch {
/* not JSON; the text is the reason */
}
throw new Error(reason || `The send proxy answered ${res.status}`);
throw new Error(reason || `The Yaak server answered ${res.status}`);
}
if (res.body == null) throw new Error("The send proxy sent no body");
if (res.body == null) throw new Error("The Yaak server sent no body");
const chunks: Uint8Array[] = [];
let received = 0;
@@ -175,12 +175,12 @@ async function runSend(
if (terminal != null) break;
}
// Everything the proxy said about the timeline is in the database before the
// Everything the server said about the timeline is in the database before the
// response is marked closed, so a reader that wakes on "closed" sees all of it.
await timeline.flush();
if (terminal == null) {
throw new Error("The send proxy closed the stream without finishing");
throw new Error("The Yaak server closed the stream without finishing");
}
// Cookies come back on both outcomes: a hop before the failing one may have
@@ -196,7 +196,7 @@ async function runSend(
// The body is written under the response id, which is how every reader —
// `cmd_http_response_body`, the image viewer, the download button — asks for
// it. One write, once the whole body is here: the worker's blob store has no
// append, and a body larger than memory is over the proxy's cap anyway.
// append, and a body larger than memory is over the server's cap anyway.
await db.blobPut(response.id, concat(chunks, received));
await response.finish({
contentLength: terminal.contentLength,
+83
View File
@@ -0,0 +1,83 @@
/**
* The wire to the Yaak server: where it is, and how to read what comes back.
*
* The shapes themselves are generated from `crates-server/yaak-web/src/wire.rs`
* into `@yaakapp-internal/web`, so the two sides cannot drift silently.
*/
import type { Frame } from "@yaakapp-internal/web";
/* ------------------------------- location -------------------------------- */
/**
* Where the tab sends.
*
* Empty means "this origin": the server can serve the app itself
* (`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`).
*/
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" : "";
}
export function serverSendUrl(): string {
return `${serverBaseUrl()}/v1/http/send`;
}
let identity: Promise<string> | null = null;
/** The server's location as a person reads it, since "" means "this origin". */
function serverLocation(): string {
return serverBaseUrl() || globalThis.location?.origin || "this origin";
}
/**
* Who does the sending, for the timeline: `yaak-web 0.1.0 at http://…`.
* Asked of `/v1/health` once per page load; if the server can't be reached the
* URL alone is the answer, and the send itself will say why shortly after.
*/
export function serverIdentity(): Promise<string> {
identity ??= fetch(`${serverBaseUrl()}/v1/health`)
.then((res) => res.json() as Promise<{ version?: string }>)
.then((health) => `yaak-web ${health.version ?? ""} at ${serverLocation()}`.replace(" ", " "))
.catch(() => {
identity = null; // try again next send
return `Yaak server at ${serverLocation()}`;
});
return identity;
}
/**
* Yield frames from an NDJSON stream as they arrive. A partial trailing line is
* held until its newline comes; anything left when the stream ends is dropped,
* because a frame without its newline is a frame the server didn't finish writing.
*/
export async function* readFrames(stream: ReadableStream<Uint8Array>): AsyncGenerator<Frame> {
const reader = stream.getReader();
const decoder = new TextDecoder();
let buffer = "";
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
let newline = buffer.indexOf("\n");
while (newline !== -1) {
const line = buffer.slice(0, newline);
buffer = buffer.slice(newline + 1);
if (line.trim() !== "") yield JSON.parse(line) as Frame;
newline = buffer.indexOf("\n");
}
}
} finally {
reader.releaseLock();
}
}
+2 -2
View File
@@ -26,7 +26,7 @@ import { DB_LOCK_NAME, type FromWorker, type ToWorker } from "./protocol";
* download and compile — and the tab can therefore tell "this worker is dead"
* from "this worker is busy" with a short timeout.
*/
type Engine = typeof import("@yaakapp-internal/web");
type Engine = typeof import("@yaakapp-internal/wasm");
let engine: Engine | null = null;
const ports = new Set<MessagePort>();
@@ -96,7 +96,7 @@ function bootOnce(): Promise<void> {
booted = (async () => {
await acquireDatabaseLock();
const loaded = await import("@yaakapp-internal/web");
const loaded = await import("@yaakapp-internal/wasm");
await loaded.boot();
engine = loaded;
})();