snapshot of https://github.com/mountain-loop/yaak.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Lint and Test / Lint/Test (push) Has been cancelled
Generate Sponsors README / deploy (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-23 17:39:41 +05:00
commit 906fb231cc
1544 changed files with 164131 additions and 0 deletions

View File

@@ -0,0 +1,14 @@
import { platform } from "./registry";
import type { CapabilityName } from "./types";
/**
* Whether the current host supports a feature.
*
* A hook so components can gate on it directly, and so this can become reactive
* later — a browser tab gains and loses capabilities when the local bridge comes
* and goes. Capabilities are fixed for the life of the desktop app, so today it
* is a plain read.
*/
export function useCapability(name: CapabilityName): boolean {
return platform.capabilities[name];
}

View File

@@ -0,0 +1,15 @@
import { setPlatform } from "./registry";
import { createTauriPlatform } from "./tauri";
// The desktop entry. Installed unconditionally and synchronously — several
// modules call commands while the module graph is still evaluating, so there is
// no later moment to do this in.
//
// This line is the swap point, and a browser build swaps it by resolving the
// package to `index.web.ts` instead of this file. Because nothing else in the
// app imports a host directly, that is the whole change.
setPlatform(createTauriPlatform());
export * from "./capabilities";
export { platform, setPlatform } from "./registry";
export * from "./types";

View File

@@ -0,0 +1,23 @@
import { setPlatform } from "./registry";
import { createWebPlatform } from "./web";
/**
* The package entry for browser builds, selected by aliasing
* `@yaakapp-internal/platform` to this file (see `YAAK_TARGET=web` in the
* client's vite.config.ts).
*
* A separate entry rather than a branch inside `index.ts`, because a branch
* would still leave `import "@tauri-apps/api"` in the module graph: the folded
* `if` disappears, but the imports it guarded do not, and those modules cannot
* be proven side-effect free. Splitting the entry means a web build never
* mentions Tauri at all — and it keeps `index.ts` exactly as the desktop has
* always had it.
*
* Like `index.ts`, this must install the host eagerly and synchronously:
* boot-time modules call commands while the module graph is still evaluating.
*/
setPlatform(createWebPlatform());
export * from "./capabilities";
export { platform, setPlatform } from "./registry";
export * from "./types";

View File

@@ -0,0 +1,66 @@
import type { Platform } from "./types";
let installed: Platform | null = null;
/**
* Install the host implementation. Called once at import time by this package's
* entry point, before any consumer's module body runs.
*
* It stays swappable at runtime because a browser build has to choose between
* talking to a local bridge and running in-page, and it can only find out which
* after it has tried to reach the bridge.
*/
export function setPlatform(next: Platform): void {
installed = next;
}
function host(): Platform {
if (installed == null) {
throw new Error("No platform installed. Import @yaakapp-internal/platform before using it.");
}
return installed;
}
/**
* The host, for everyone else to use.
*
* This is a fixed object that forwards to whatever is installed, so modules can
* import it at any time — including at module scope, which several boot-time
* modules do — without capturing a stale implementation.
*
* A host that has to connect before it can work (a WebSocket to the bridge)
* buffers inside its own `rpc`; nothing here waits on a connection, because the
* app's boot sequence calls commands while the module graph is still evaluating
* and cannot be made to wait.
*/
export const platform: Platform = {
get httpProxyUrl() {
return host().httpProxyUrl;
},
get capabilities() {
return host().capabilities;
},
get window() {
return host().window;
},
get clipboard() {
return host().clipboard;
},
get dialog() {
return host().dialog;
},
get files() {
return host().files;
},
get blobs() {
return host().blobs;
},
rpc: (cmd, payload) => host().rpc(cmd, payload),
rpcStream: (cmd, payload, onMessage) => host().rpcStream(cmd, payload, onMessage),
listen: (event, callback) => host().listen(event, callback),
emit: (event, payload) => host().emit(event, payload),
openUrl: (url) => host().openUrl(url),
revealItemInDir: (path) => host().revealItemInDir(path),
osType: () => host().osType(),
appIdentifier: () => host().appIdentifier(),
};

View File

@@ -0,0 +1,228 @@
import { getIdentifier } from "@tauri-apps/api/app";
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
import { emit as tauriEmit, listen as tauriListen } from "@tauri-apps/api/event";
import { basename, resolveResource } from "@tauri-apps/api/path";
import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow";
import { clear, readText, writeText } from "@tauri-apps/plugin-clipboard-manager";
import { open, save as saveDialog } from "@tauri-apps/plugin-dialog";
import { readDir, readFile, readTextFile } from "@tauri-apps/plugin-fs";
import { openUrl, revealItemInDir } from "@tauri-apps/plugin-opener";
import { type as osType } from "@tauri-apps/plugin-os";
import type {
DragDropEvent,
OpenDialogOptions,
Platform,
PlatformCapabilities,
PlatformWindow,
RpcPayload,
RpcStreamHandle,
Unsubscribe,
} from "../types";
/**
* The desktop host: the yaak-rpc envelope carried by Tauri's `invoke` and
* window events. Every command goes through the single `rpc` Tauri command
* into the `RpcRouter` on the Rust side.
*/
/**
* Tauri hands back an unsubscribe function asynchronously; the platform hands
* back one immediately, so callers can unsubscribe from a React cleanup without
* awaiting. Unsubscribing before the subscription lands is honoured once it does.
*/
function toSyncUnsubscribe(pending: Promise<Unsubscribe>): Unsubscribe {
let unsubscribe: Unsubscribe | null = null;
let cancelled = false;
pending
.then((fn) => {
if (cancelled) fn();
else unsubscribe = fn;
})
.catch(console.error);
return () => {
cancelled = true;
unsubscribe?.();
unsubscribe = null;
};
}
const ALL_CAPABILITIES: PlatformCapabilities = {
httpSending: true,
grpc: true,
websocket: true,
git: true,
sync: true,
tlsOptions: true,
cookieJar: true,
localFiles: true,
timeline: true,
multiWindow: true,
windowChrome: true,
interfaceZoom: true,
plugins: true,
encryption: true,
updater: true,
clipboardRead: true,
systemFonts: true,
license: true,
};
function createWindow(): PlatformWindow {
const webview = getCurrentWebviewWindow();
return {
label: webview.label,
show: () => webview.show(),
close: () => webview.close(),
minimize: () => webview.minimize(),
maximize: () => webview.maximize(),
unmaximize: () => webview.unmaximize(),
isMaximized: () => webview.isMaximized(),
isFullscreen: () => webview.isFullscreen(),
setZoom: (scale) => webview.setZoom(scale),
theme: () => webview.theme(),
onThemeChanged: (callback) =>
toSyncUnsubscribe(webview.onThemeChanged((e) => callback(e.payload))),
onFocusChanged: (callback) =>
toSyncUnsubscribe(webview.onFocusChanged((e) => callback(e.payload))),
onDragDrop: (callback) =>
toSyncUnsubscribe(webview.onDragDropEvent((e) => callback(e.payload as DragDropEvent))),
};
}
async function rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
try {
// Host plugin commands (`plugin:yaak-license|check`, ...) are registered by
// their Tauri plugins and ride outside the envelope. Everything else goes
// through the single `rpc` command and the RpcRouter behind it.
if (cmd.startsWith("plugin:")) {
return await invoke<T>(cmd, payload);
}
return await invoke<T>("rpc", { cmd, payload: payload ?? {} });
} catch (err) {
console.warn("Platform command error", cmd, err);
throw err;
}
}
/**
* Where this host keeps the bytes stored under an id, or null if it has none.
*
* The desktop stores them as files the engine wrote, so answering means asking
* the backend. Callers hold ids and nothing else; the path exists for exactly
* as long as it takes to open the file, which is the one thing a desktop host
* can do that a tab cannot.
*/
function storedBodyPath(id: string): Promise<string | null> {
return rpc<string | null>("cmd_http_response_body_path", { responseId: id });
}
/**
* Base64 for the trip over IPC, in chunks so a few megabytes don't blow the
* argument limit. The engine's write command speaks base64; only this host has
* to care.
*/
function toBase64(bytes: Uint8Array): string {
let binary = "";
for (let i = 0; i < bytes.length; i += 0x8000) {
binary += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
}
return btoa(binary);
}
export function createTauriPlatform(): Platform {
const window = createWindow();
return {
capabilities: ALL_CAPABILITIES,
httpProxyUrl: null,
window,
clipboard: {
writeText: (text) => writeText(text),
readText: () => readText(),
clear: () => clear(),
},
dialog: {
// Overloaded on the interface; one implementation covers both shapes.
open: ((options?: OpenDialogOptions) => open(options)) as Platform["dialog"]["open"],
save: (options) => saveDialog(options ?? {}),
},
files: {
readDir: (path) => readDir(path),
readText: (path) => readTextFile(path),
url: (path) => convertFileSrc(path),
basename: (path) => basename(path),
resolveResource: (path) => resolveResource(path),
async save(suggestedName, content, filters) {
const path = await saveDialog({ defaultPath: suggestedName, filters });
if (path == null) return null;
// Not the fs plugin: its ACL is read-only and scoped to the app's own
// directories, and a path the user just picked is neither. The engine
// writes it, which is where the desktop has always written it — and it
// speaks base64, so content that is already base64 goes straight over.
const data = content instanceof Uint8Array ? toBase64(content) : content.base64;
await rpc("cmd_save_base64_to_binary", { filepath: path, data });
return path;
},
},
blobs: {
async read(id) {
const path = await storedBodyPath(id);
return path == null ? null : readFile(path);
},
async url(id) {
const path = await storedBodyPath(id);
return path == null ? null : convertFileSrc(path);
},
},
rpc,
async rpcStream<T, M>(
cmd: string,
payload: RpcPayload,
onMessage: (message: M) => void,
): Promise<RpcStreamHandle<T>> {
// The caller mints the stream id, and the subscription is awaited before
// the command dispatches: registration is its own IPC round trip, and the
// command may emit its first message while it runs.
const streamId = crypto.randomUUID();
const unlisten = await tauriListen<M>(`stream_${streamId}`, (e) => onMessage(e.payload), {
target: { kind: "Window", label: window.label },
});
try {
const result = await rpc<T>(cmd, { ...payload, streamId });
return { result, unlisten };
} catch (err) {
unlisten();
throw err;
}
},
listen<T>(event: string, callback: (payload: T) => void): Unsubscribe {
return toSyncUnsubscribe(
tauriListen<T>(event, (e) => callback(e.payload), {
// Receives events broadcast to every window as well as ones addressed
// to this one, which is how the backend sends both kinds today.
target: { kind: "Window", label: window.label },
}),
);
},
emit: (event, payload) => tauriEmit(event, payload),
openUrl: (url) => openUrl(url),
revealItemInDir: (path) => revealItemInDir(path),
osType: () => osType(),
appIdentifier: () => getIdentifier(),
};
}

View File

@@ -0,0 +1,318 @@
/**
* The complete surface the app is allowed to use to reach its host.
*
* Everything the UI needs from outside the page goes through this interface, so
* that a host other than Tauri (a browser tab talking to the local bridge, a
* self-hosted or hosted server) can be added by writing one more implementation
* instead of touching call sites. Nothing outside `src/tauri` may import
* `@tauri-apps/*` — that is the whole point of the package.
*
* Commands and events are the yaak-rpc envelope: a command name plus a JSON
* payload in, a JSON payload out, and named events pushed the other way. The
* Tauri host puts that envelope inside `invoke`; a WebSocket host will put it on
* the wire unchanged.
*/
/** Cancels a subscription. Safe to call more than once. */
export type Unsubscribe = () => void;
/** Matches the values `@tauri-apps/plugin-os` reports so hosts agree on spelling. */
export type OsType =
| "android"
| "dragonfly"
| "freebsd"
| "ios"
| "linux"
| "macos"
| "netbsd"
| "openbsd"
| "solaris"
| "windows";
export type PlatformAppearance = "light" | "dark";
/** Command arguments. Serialized to JSON, so only JSON values belong here. */
export type RpcPayload = Record<string, unknown>;
/** A streaming command's result, plus the teardown for its subscription. */
export interface RpcStreamHandle<T> {
result: T;
unlisten: Unsubscribe;
}
/**
* What to save: the bytes, or base64 of them for a caller that already has it
* that way.
*
* Both forms exist because both hosts want a different one, and a value that
* arrives base64 should not be decoded and re-encoded to get back where it
* started. Whichever a caller has is the one to pass; each host converts only
* when it has to.
*/
export type SaveContent = Uint8Array | { base64: string };
export interface DialogFilter {
name: string;
extensions: string[];
}
export interface OpenDialogOptions {
title?: string;
defaultPath?: string;
directory?: boolean;
multiple?: boolean;
filters?: DialogFilter[];
}
export interface SaveDialogOptions {
title?: string;
defaultPath?: string;
filters?: DialogFilter[];
}
export interface DirEntry {
name: string;
isDirectory: boolean;
isFile: boolean;
isSymlink: boolean;
}
export interface DragDropPosition {
x: number;
y: number;
}
/**
* A native drag-and-drop over the window. Distinct from the DOM's drag events:
* the host reports window coordinates, not a DOM target, so listeners hit-test
* against their own bounding box.
*/
export type DragDropEvent =
| { type: "enter"; paths: string[]; position: DragDropPosition }
| { type: "over"; position: DragDropPosition }
| { type: "drop"; paths: string[]; position: DragDropPosition }
| { type: "leave" };
/**
* The window (desktop) or tab (browser) this UI is running in.
*
* Every method a host must provide is named here on purpose. The build spike
* replaced the Tauri window object wholesale and a single missing method
* (`onDragDropEvent`) threw mid-render with nothing to catch it at compile time;
* an explicit interface makes that class of failure a type error instead.
*/
export interface PlatformWindow {
/**
* Identity of this window among all the app's windows.
*
* Model writes carry the label of the window that made them so the others can
* tell an echo of their own write from someone else's. A browser host can
* satisfy this with a per-tab id and a BroadcastChannel.
*/
readonly label: string;
show(): Promise<void>;
close(): Promise<void>;
minimize(): Promise<void>;
maximize(): Promise<void>;
unmaximize(): Promise<void>;
isMaximized(): Promise<boolean>;
isFullscreen(): Promise<boolean>;
/** Scale the whole window's contents. 1 is unscaled. */
setZoom(scale: number): Promise<void>;
/** The host's current appearance, or null if it has no opinion and CSS should decide. */
theme(): Promise<PlatformAppearance | null>;
onThemeChanged(callback: (appearance: PlatformAppearance) => void): Unsubscribe;
onFocusChanged(callback: (focused: boolean) => void): Unsubscribe;
onDragDrop(callback: (event: DragDropEvent) => void): Unsubscribe;
}
export interface PlatformClipboard {
writeText(text: string): Promise<void>;
readText(): Promise<string>;
clear(): Promise<void>;
}
export interface PlatformDialog {
open(options: OpenDialogOptions & { multiple: true }): Promise<string[] | null>;
open(options?: OpenDialogOptions): Promise<string | null>;
save(options?: SaveDialogOptions): Promise<string | null>;
}
/**
* File-ish operations, keyed by paths the backend handed us.
*
* A path here is an opaque handle, not something to parse or construct: the UI
* only ever passes one back to `url` or `readDir`. A host without a filesystem
* can mint handles of its own (a blob id, a URL) and stay compatible.
*/
export interface PlatformFiles {
readDir(path: string): Promise<DirEntry[]>;
/**
* The text of a file the user picked, decoded as UTF-8.
*
* Only for paths the host itself handed us — a dialog result or a drag-drop
* payload — never one the page assembled. A host without a filesystem reads
* whatever its own handle points at.
*/
readText(path: string): Promise<string>;
/** A URL the page can load a file from, for `<img>`, `<video>`, and friends. */
url(path: string): string;
basename(path: string): Promise<string>;
/** Resolve a path bundled with the app itself, rather than one from the backend. */
resolveResource(path: string): Promise<string>;
/**
* Put bytes somewhere the user chooses. Returns where they went — a path, a
* filename, whatever this host can say — or null if the user backed out.
*
* The caller supplies the bytes, which is the whole point: the alternative
* shape, where a dialog mints a path and a separate backend command writes to
* it, can only work on a host that has both a filesystem and a backend. A tab
* has neither, so every one of those call sites was a dead control. Whoever
* produces the bytes already knows what they are; the host only has to know
* where they go.
*
* `suggestedName` is what the save dialog opens with, extension included.
*/
save(
suggestedName: string,
content: SaveContent,
filters?: DialogFilter[],
): Promise<string | null>;
}
/**
* Content the backend stored, addressed by the id it stored it under.
*
* Where those bytes actually live is the one thing every host answers
* differently — a file the engine wrote, a row in a database, a URL on the
* other end of a socket — so finding them is the host's job and nobody else's.
* The caller passes an id and never a location, which is also what keeps a page
* from naming something the backend never wrote.
*
* What the content *means* is the app's business, not this package's.
*/
export interface PlatformBlobs {
/** The bytes, or null if the host has nothing stored under that id. */
read(id: string): Promise<Uint8Array<ArrayBuffer> | null>;
/**
* A URL an element can load the content from, for `<img>`, `<video>` and
* friends, or null if the host has nothing stored under that id.
*
* Asynchronous because the host may have to ask its backend where the bytes
* are, and it only does that the moment before it reads them.
*/
url(id: string): Promise<string | null>;
}
export interface Platform {
readonly capabilities: PlatformCapabilities;
/** Web request relay, or null when this host executes requests itself. */
readonly httpProxyUrl: string | null;
readonly window: PlatformWindow;
readonly clipboard: PlatformClipboard;
readonly dialog: PlatformDialog;
readonly files: PlatformFiles;
readonly blobs: PlatformBlobs;
/** Call a backend command and await its result. */
rpc<T>(cmd: string, payload?: RpcPayload): Promise<T>;
/**
* Call a command that streams messages back while it runs.
*
* Resolves once the command itself completes, with its result and an
* `unlisten` that tears down the local subscription. The host guarantees the
* subscription is live before the command is dispatched, so a stream that
* emits immediately cannot lose its first message.
*/
rpcStream<T, M>(
cmd: string,
payload: RpcPayload,
onMessage: (message: M) => void,
): Promise<RpcStreamHandle<T>>;
/** Subscribe to a backend event addressed to this window. */
listen<T>(event: string, callback: (payload: T) => void): Unsubscribe;
/** Send an event to the backend. Used for replies and for long-lived streams. */
emit(event: string, payload?: unknown): Promise<void>;
openUrl(url: string): Promise<void>;
revealItemInDir(path: string): Promise<void>;
/**
* Which OS the UI is running on. Synchronous because layout decisions
* (traffic-light padding, modifier key labels) are made during first render.
*/
osType(): OsType;
/** The host application's identifier, eg. `app.yaak.desktop`. */
appIdentifier(): Promise<string>;
}
/**
* What this host can actually do.
*
* Read these instead of testing for a platform: "does this host have a cookie
* jar" is the real question, and it stays answerable when there are three hosts.
* The Tauri host hardcodes every capability on; Rust hosts will report theirs
* from the cargo features they were built with.
*/
export interface PlatformCapabilities {
/** Send HTTP requests and see the whole response: every header, the redirect chain, timing. */
httpSending: boolean;
/** Send gRPC requests. Needs HTTP/2 trailers, so it needs a real backend. */
grpc: boolean;
/** Send WebSocket requests with custom headers and auth. */
websocket: boolean;
/** Git-backed workspaces. */
git: boolean;
/** Two-way sync between a workspace and a directory of files. */
sync: boolean;
/** Client certificates, custom CAs, and disabling certificate validation. */
tlsOptions: boolean;
/** A cookie jar the user can read and edit. */
cookieJar: boolean;
/** Paths the backend can read: file bodies, proto files, sync directories. */
localFiles: boolean;
/** Per-request timing and connection detail. */
timeline: boolean;
/** More than one window or tab on the same data. */
multiWindow: boolean;
/**
* The page is the window's titlebar: it draws the drag region and window
* controls, and leaves room for macOS traffic lights. False when something
* else owns the frame around the page (a browser tab), so none of that
* chrome should be reserved or drawn.
*/
windowChrome: boolean;
/**
* The app zooms its own interface, and so owns Cmd/Ctrl `+`, `-` and `0`.
* False in a browser, where those keys are already the browser's.
*/
interfaceZoom: boolean;
/** The plugin runtime. */
plugins: boolean;
/** Workspace encryption backed by a key the host keeps. */
encryption: boolean;
/** In-app update checks and installs. */
updater: boolean;
/** Reading the clipboard without the user pasting first. */
clipboardRead: boolean;
/** Enumerating the fonts installed on the machine. */
systemFonts: boolean;
/** Commercial licence activation. */
license: boolean;
}
export type CapabilityName = keyof PlatformCapabilities;

View File

@@ -0,0 +1,228 @@
# 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. `vp run web:dev` does that and starts the send server
alongside the frontend:
```shell
vp run web:dev
```
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-wasm → yaak-models → SQLite
│ └─ pages in IndexedDB
└── send.ts ──POST rendered request──▶ yaak-web (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 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
`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,
has `SharedWorker` and 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 `label`** as
`UpdateSource::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 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` |
Some of these answer honestly rather than fully, and the difference matters:
- `cmd_render_template` returns 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_summaries` returns the auth methods Yaak ships as
plugins, so the picker is truthful about the product — but
`cmd_get_http_authentication_config` returns an empty form, because the plugin
that defines the form isn't running.
- `cmd_template_function_summaries` returns 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_metadata` reports 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 on
`localhost`.
- **`pkg/yaak_web_bg.wasm` is 3.8 MB and committed** (no `wasm-opt`, matching
`yaak-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_template` returns 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-schema` does 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, and `commands.ts` stays typed against `RpcSchema`.
## 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 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.
2. 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's `HttpSendInputs`, in Rust, on the same model layer,
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
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
`timeline` is true), the body under the response id via `blob_put`, and the
cookie jar through `models_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 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.
**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 `PORT` 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.

View File

@@ -0,0 +1,373 @@
/**
* The command table: what this host answers, and what it declines and why.
*
* The model commands are forwarded to the worker, where the desktop's own
* model layer answers them — same queries, same migrations, same cascade
* rules — so nothing about *what a model is* is decided in this file. What is
* decided here is the rest of the desktop's command surface: a handful of
* fixed answers that are true of a browser tab, and the refusals. The refusals
* are the important half: a command that silently returns nothing leaves the
* UI showing something that isn't true, whereas a refusal with a reason becomes
* a toast the user can act on. So each unsupported command is listed by name
* with the reason, and anything not listed at all is refused generically
* rather than guessed at.
*
* The command names are `keyof RpcSchema`, the same generated wire schema the
* desktop's router is built from, so a command renamed or added in Rust shows
* up here as a type error rather than as a runtime surprise.
*/
import type { RpcSchema } from "@yaakapp-internal/rpc-schema";
import type { CapabilityName, RpcPayload } from "../types";
import type { WorkerConnection } from "./connection";
import { unsupported } from "./errors";
import { sendHttpRequest } from "./send";
export type AppCmd = keyof RpcSchema;
type Handler = (payload: RpcPayload, db: WorkerConnection) => Promise<unknown>;
/** Placeholder shown wherever the desktop would show a real filesystem path. */
const NO_PATH = "";
function str(payload: RpcPayload, key: string): string | null {
const value = payload[key];
return typeof value === "string" && value !== "" ? value : null;
}
/** Like `str`, but for fields where an empty string is a legitimate value. */
function text(payload: RpcPayload, key: string): string {
const value = payload[key];
return typeof value === "string" ? value : "";
}
/**
* Commands this host answers itself.
*
* Anything here either reads and writes the browser's own database, or is a
* fixed answer that is true of this host — not a stub standing in for something
* that should work.
*/
const HANDLERS: Partial<Record<AppCmd, Handler>> = {
/* ------------------------------- models -------------------------------- */
// Answered by the model layer itself, in the worker. The payload goes over
// untouched and the answer comes back untouched: this file has no opinion
// about models, and it would be wrong for it to grow one.
models_workspace_models: (payload, db) => db.rpc("models_workspace_models", payload),
models_upsert: (payload, db) => db.rpc("models_upsert", payload),
models_delete: (payload, db) => db.rpc("models_delete", payload),
models_duplicate: (payload, db) => db.rpc("models_duplicate", payload),
models_get_settings: (payload, db) => db.rpc("models_get_settings", payload),
models_get_graphql_introspection: (payload, db) =>
db.rpc("models_get_graphql_introspection", payload),
models_upsert_graphql_introspection: (payload, db) =>
db.rpc("models_upsert_graphql_introspection", payload),
models_grpc_events: (payload, db) => db.rpc("models_grpc_events", payload),
models_websocket_events: (payload, db) => db.rpc("models_websocket_events", payload),
cmd_get_workspace_meta: (payload, db) => db.rpc("cmd_get_workspace_meta", payload),
cmd_delete_all_http_responses: (payload, db) => db.rpc("cmd_delete_all_http_responses", payload),
cmd_delete_send_history: (payload, db) => db.rpc("cmd_delete_send_history", payload),
/* ------------------------------- sending ------------------------------- */
// 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");
if (requestId == null) throw new Error("cmd_send_http_request needs a requestId");
return sendHttpRequest(
db,
requestId,
str(payload, "environmentId"),
str(payload, "cookieJarId"),
);
},
/* -------------------------------- app ---------------------------------- */
async cmd_metadata() {
return {
isDev: true,
version: "0.0.0-web",
cliVersion: null,
name: "Yaak",
// The desktop hands out real directories here and the UI offers to open
// them. There is no filesystem behind this host, and the capability flags
// are what the UI should be gating those affordances on.
appDataDir: NO_PATH,
appLogDir: NO_PATH,
vendoredPluginDir: NO_PATH,
defaultProjectDir: NO_PATH,
featureUpdater: false,
featureLicense: false,
};
},
// The theme package ships its own defaults, so an empty list is a complete
// answer rather than a degraded one — themes beyond those come from plugins.
async cmd_get_themes() {
return [];
},
async cmd_default_headers() {
// Mirrors `default_headers()` in crates/yaak-models/src/queries/workspaces.rs
return [
{ enabled: true, name: "User-Agent", value: "yaak", id: null },
{ enabled: true, name: "Accept", value: "*/*", id: null },
];
},
async cmd_plugin_init_errors() {
return [];
},
async cmd_check_for_updates() {
return false;
},
async cmd_dismiss_notification() {
return null;
},
// Plugin-contributed menus. Empty is honest: no plugin runtime, no actions.
async cmd_http_request_actions() {
return [];
},
async cmd_websocket_request_actions() {
return [];
},
async cmd_grpc_request_actions() {
return [];
},
async cmd_workspace_actions() {
return [];
},
async cmd_folder_actions() {
return [];
},
/**
* Both of these are polled once a second until they answer with something, so
* an empty list is not a quiet no — it is a poll that never stops.
*
* The auth list names what Yaak actually offers, so the picker tells the
* truth about the product even though the form behind each entry stays empty
* until plugins run here. Template functions get the opposite treatment: one
* provider contributing no functions. That settles the poll while putting
* nothing in the autocomplete, which is the honest answer — a function the
* user could insert but nothing could evaluate would be worse than none.
*/
async cmd_get_http_authentication_summaries() {
return HTTP_AUTHENTICATION_SUMMARIES;
},
async cmd_template_function_summaries() {
return [{ pluginRefId: "web", functions: [] }];
},
async cmd_get_http_authentication_config() {
return { args: [], pluginRefId: "web" };
},
async cmd_format_json(payload) {
const source = text(payload, "text");
try {
return JSON.stringify(JSON.parse(source), null, 2);
} catch {
// Formatting invalid JSON is a no-op, not an error: the editor calls this
// while the user is still typing.
return source;
}
},
/**
* Rendering resolves variables and calls template functions, and the
* functions live in plugins. Handing the template back unrendered is what the
* preview then shows — the raw `${[...]}`, which is at least the thing the
* user typed rather than a wrong value.
*/
async cmd_render_template(payload) {
return text(payload, "template");
},
/* ------------------------------- bodies -------------------------------- */
async cmd_http_response_body(payload, db) {
const responseId = str(payload, "responseId");
if (responseId == null) return { content: "" };
if (str(payload, "filter") != null) {
return {
content: "",
error: "Response filters come from a plugin, which this host doesn't run yet",
};
}
const bytes = await db.blobGet(responseId);
return { content: bytes == null ? "" : new TextDecoder().decode(bytes) };
},
// Bodies live in this database, not on a disk, so there is no path to give.
async cmd_http_response_body_path() {
return null;
},
async cmd_http_request_body(payload, db) {
const responseId = str(payload, "responseId");
if (responseId == null) return null;
// Keyed the way the desktop keys it: the request bytes belong to the
// response that recorded them.
const bytes = await db.blobGet(`${responseId}.request`);
return bytes == null ? null : Array.from(bytes);
},
// The rows the sender wrote for that response, same table as the desktop.
cmd_get_http_response_events: (payload, db) => db.rpc("cmd_get_http_response_events", payload),
/* ------------------------------- export -------------------------------- */
// Built by the model layer, exactly as the desktop and the CLI build it. Where
// the document goes is not decided here: the caller hands it to `files.save`,
// which is the one thing each host answers differently.
cmd_export_data: (payload, db) => db.rpc("cmd_export_data", payload),
// The same example the desktop creates, built by the model layer in the worker.
cmd_create_example_workspace: (payload, db) => db.rpc("cmd_create_example_workspace", payload),
async cmd_get_sse_events() {
return [];
},
};
/**
* The auth methods Yaak ships as plugins today (plugins/auth-*). Listed so the
* picker is truthful about the product; choosing one currently yields an empty
* config form, because the plugin that defines the form isn't running.
*/
const HTTP_AUTHENTICATION_SUMMARIES = [
{ name: "apikey", label: "API Key", shortLabel: "API Key" },
{ name: "aws", label: "AWS SigV4", shortLabel: "AWS" },
{ name: "basic", label: "Basic Auth", shortLabel: "Basic" },
{ name: "bearer", label: "Bearer Token", shortLabel: "Bearer" },
{ name: "digest", label: "Digest Auth", shortLabel: "Digest" },
{ name: "jwt", label: "JWT Bearer", shortLabel: "JWT" },
{ name: "ntlm", label: "NTLM", shortLabel: "NTLM" },
{ name: "oauth1", label: "OAuth 1.0", shortLabel: "OAuth 1" },
{ name: "oauth2", label: "OAuth 2.0", shortLabel: "OAuth 2" },
];
/**
* Commands this host declines, each with the reason a user would need.
*
* Naming them individually rather than letting them fall through to a generic
* refusal is deliberate: "sending is not available yet" and "Yaak in a browser
* has no filesystem" are different situations, and the second is permanent
* while the first is a slice away.
*/
const DECLINED: Partial<Record<AppCmd, [reason: string, capability: CapabilityName | null]>> = {
// 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],
cmd_curl_to_request: ["Importing from cURL needs a plugin, which this host doesn't run", null],
// Protocols that need a real socket.
cmd_grpc_reflect: ["gRPC isn't available in the browser", "grpc"],
cmd_grpc_go: ["gRPC isn't available in the browser", "grpc"],
cmd_delete_all_grpc_connections: ["gRPC isn't available in the browser", "grpc"],
cmd_ws_connect: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_send: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_close: ["WebSocket requests aren't available in the browser yet", "websocket"],
cmd_ws_delete_connections: [
"WebSocket requests aren't available in the browser yet",
"websocket",
],
// 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 Yaak server, which isn't available yet", null],
cmd_commit_import: ["Importing needs a plugin, which this host doesn't run", null],
cmd_list_import_sources: ["Importing isn't available in the browser yet", null],
cmd_import_sources_for_origin: ["Importing isn't available in the browser yet", null],
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"],
cmd_format_graphql: ["Formatting GraphQL needs a plugin, which this host doesn't run", null],
// Windows. A tab is the window, and there is only ever one of it.
cmd_new_child_window: ["Yaak in a browser uses one tab", "multiWindow"],
cmd_new_main_window: ["Yaak in a browser uses one tab", "multiWindow"],
cmd_restart: ["Reload the page to restart Yaak", null],
// Workspace encryption is backed by a key the host keeps for you; a page has
// nowhere to keep one that a page couldn't also read.
cmd_enable_encryption: ["Workspace encryption isn't available in the browser", "encryption"],
cmd_disable_encryption: ["Workspace encryption isn't available in the browser", "encryption"],
cmd_reveal_workspace_key: ["Workspace encryption isn't available in the browser", "encryption"],
cmd_set_workspace_key: ["Workspace encryption isn't available in the browser", "encryption"],
cmd_secure_template: ["Workspace encryption isn't available in the browser", "encryption"],
cmd_decrypt_template: ["Workspace encryption isn't available in the browser", "encryption"],
// The plugin runtime is a Node process. Nothing here runs one.
cmd_reload_plugins: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugin_info: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_search: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_install: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_install_from_directory: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_uninstall: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_updates: ["Plugins aren't available in the browser yet", "plugins"],
cmd_plugins_update_all: ["Plugins aren't available in the browser yet", "plugins"],
cmd_template_function_config: [
"Template functions come from plugins, which this host doesn't run",
"plugins",
],
cmd_template_tokens_to_string: [
"Template functions come from plugins, which this host doesn't run",
"plugins",
],
cmd_call_http_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_websocket_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_grpc_request_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_workspace_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_folder_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_call_http_authentication_action: ["Plugins aren't available in the browser yet", "plugins"],
cmd_send_feedback: ["Feedback goes through the desktop app for now", null],
};
/**
* The support table, for documentation and for the console.
*
* Derived from the two maps above rather than written alongside them, so it
* cannot drift from what the host actually does.
*/
export function commandSupport(): {
implemented: string[];
declined: { cmd: string; reason: string; capability: CapabilityName | null }[];
} {
return {
implemented: Object.keys(HANDLERS).sort(),
declined: Object.entries(DECLINED)
.map(([cmd, [reason, capability]]) => ({ cmd, reason, capability }))
.sort((a, b) => a.cmd.localeCompare(b.cmd)),
};
}
export async function runCommand(
cmd: string,
payload: RpcPayload,
db: WorkerConnection,
): Promise<unknown> {
const handler = HANDLERS[cmd as AppCmd];
if (handler != null) return handler(payload, db);
const declined = DECLINED[cmd as AppCmd];
if (declined != null) throw unsupported(cmd, declined[0], declined[1]);
// Git and sync land here, along with anything added to the schema since. The
// message names the command because an unlisted one is a gap in this file,
// and whoever hits it should be able to see which.
throw unsupported(cmd, `\`${cmd}\` isn't available when Yaak runs in a browser`);
}

View File

@@ -0,0 +1,236 @@
/**
* A tab's end of the wire to the worker that owns the database.
*
* Constructible synchronously and usable immediately, which is the hard
* requirement: boot-time modules call commands while the module graph is still
* evaluating, so there is no later moment to connect in. Messages posted before
* the worker has opened the database sit in the port until it has, and the
* app's own top-level await then doubles as the boot gate — nothing renders
* until the first command has answered, and it can only answer once the
* database is open.
*/
import type { Unsubscribe } from "../types";
import { randomId } from "./ids";
import { type FromWorker, type ToWorker, WORKER_NAME } from "./protocol";
/**
* How long a freshly connected worker gets to say hello.
*
* A live worker answers in the same turn it is connected — the worker script
* is tiny and imports the model layer lazily, so this measures liveness, not
* load time. It only has to be longer than a cold fetch of that small script;
* a worker silent past this is not coming, and a message beats a blank page.
*/
const HELLO_TIMEOUT_MS = 3000;
const WORKER_FAILED = "Yaak's database worker could not be started. Reload the page to try again";
const UNSUPPORTED =
"This browser can't run Yaak: it needs shared workers and Web Locks to keep your data safe across tabs. Every current browser has both.";
type Pending = { resolve: (value: unknown) => void; reject: (reason: Error) => void };
export class WorkerConnection {
private port: MessagePort | null;
private readonly pending = new Map<number, Pending>();
private readonly listeners = new Map<string, Set<(payload: unknown) => void>>();
private nextId = 1;
private bootError: string | null = null;
/**
* This tab's identity, standing in for the desktop's window label. Stamped
* on every write this tab makes, so the store can tell an echo of its own
* write from another tab's. Minted per page load, not kept in
* `sessionStorage`, on purpose: duplicating a tab copies session storage,
* and two tabs claiming one identity would each drop the other's writes as
* echoes.
*/
readonly label = `tab_${randomId(4)}`;
/** True once the worker has said anything at all. */
private heard = false;
constructor() {
// Both are required and neither is faked. Without a shared worker every
// tab would need its own SQLite over the same pages; without Web Locks
// nothing can promise there is only one even so. Every current browser,
// desktop and mobile, has both (Chrome for Android since 148); the ones
// that don't get told, not corrupted.
if (typeof SharedWorker === "undefined" || typeof navigator.locks === "undefined") {
this.port = null;
this.bootError = UNSUPPORTED;
showBootError(UNSUPPORTED);
return;
}
this.port = this.connect();
// Let the worker forget this port. Not load-bearing — a port that never
// says goodbye is a leaked entry in a Set — but tidy.
window.addEventListener("pagehide", () => this.post({ type: "goodbye" }));
}
/**
* Connect to the origin's one database worker.
*
* The browser hands every tab the same SharedWorker for this name and URL,
* which is what makes "one database owner" true without anyone coordinating.
*
* `new URL("./worker.ts", import.meta.url)` is written out inline on purpose:
* that exact syntax is what the bundler pattern-matches to know it must
* bundle a worker entry. Hoisted into a variable it becomes an asset URL and
* ships as raw TypeScript.
*/
private connect(): MessagePort {
const worker = new SharedWorker(new URL("./worker.ts", import.meta.url), {
type: "module",
name: WORKER_NAME,
});
// A worker whose script fails to load fires `error` on the SharedWorker
// object and nothing else — the port just goes quiet.
worker.onerror = () => {
if (!this.heard) this.failEverything(`${WORKER_FAILED} (its script failed to load).`);
};
worker.port.onmessage = (e: MessageEvent<FromWorker>) => this.receive(e.data);
worker.port.start();
// The worker says hello synchronously on connect. Silence past the timeout
// means this port is attached to nothing that will ever answer, and the
// user should see that rather than a blank page. It is not retried: the
// one way this used to happen (a module worker missing connects during a
// top-level-await import) is fixed at the source by importing the wasm
// lazily, and a reload is the right remedy for anything else.
setTimeout(() => {
if (!this.heard) this.failEverything(`${WORKER_FAILED} (it never answered).`);
}, HELLO_TIMEOUT_MS);
return worker.port;
}
private failEverything(message: string): void {
this.bootError = message;
showBootError(message);
for (const [id, p] of this.pending) {
this.pending.delete(id);
p.reject(new Error(message));
}
}
private post(message: ToWorker, transfer: Transferable[] = []): void {
this.port?.postMessage(message, transfer);
}
private receive(message: FromWorker): void {
this.heard = true;
switch (message.type) {
case "hello":
case "ready":
return;
case "boot_error":
// Nothing will ever answer, and the app cannot render without an
// answer, so say what happened where the user can see it. This is the
// page's whole content at this point.
this.failEverything(message.message);
return;
case "result": {
const p = this.pending.get(message.id);
this.pending.delete(message.id);
p?.resolve(message.result);
return;
}
case "error": {
const p = this.pending.get(message.id);
this.pending.delete(message.id);
p?.reject(new Error(message.message));
return;
}
case "event":
this.deliver(message.event, message.payload);
return;
}
}
private request<T>(build: (id: number) => ToWorker, transfer: Transferable[] = []): Promise<T> {
if (this.bootError != null) return Promise.reject(new Error(this.bootError));
const id = this.nextId++;
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
this.post(build(id), transfer);
});
}
rpc<T>(cmd: string, payload: unknown): Promise<T> {
return this.request<T>((id) => ({ type: "rpc", id, cmd, payload, label: this.label }));
}
/** 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 }));
}
async blobGet(blobId: string): Promise<Uint8Array<ArrayBuffer> | null> {
const buf = await this.request<ArrayBuffer | null>((id) => ({ type: "blob_get", id, blobId }));
return buf == null ? null : new Uint8Array(buf);
}
blobPut(blobId: string, bytes: Uint8Array): Promise<void> {
// Copied so the caller's buffer isn't detached out from under it, then
// transferred so the copy isn't copied again crossing to the worker.
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return this.request<void>(
(id) => ({ type: "blob_put", id, blobId, bytes: copy.buffer }),
[copy.buffer],
);
}
blobDelete(blobId: string): Promise<void> {
return this.request<void>((id) => ({ type: "blob_delete", id, blobId }));
}
/* ------------------------------- events -------------------------------- */
listen(event: string, callback: (payload: unknown) => void): Unsubscribe {
let set = this.listeners.get(event);
if (set == null) {
set = new Set();
this.listeners.set(event, set);
}
set.add(callback);
return () => {
set.delete(callback);
if (set.size === 0) this.listeners.delete(event);
};
}
/**
* Deliver an event to this tab's listeners.
*
* Used for what the worker pushes, and for the app's own local emits (a
* plugin round trip, a stream teardown). Local emits stay local: every
* emitter in the app is replying to something *this* tab is doing.
*/
deliver(event: string, payload: unknown): void {
const set = this.listeners.get(event);
if (set == null) return;
// Copied because a listener may unsubscribe itself while being called
for (const callback of Array.from(set)) {
try {
callback(payload);
} catch (err) {
console.error(`Listener for \`${event}\` threw`, err);
}
}
}
}
function showBootError(message: string): void {
const root = document.getElementById("root");
if (root == null || root.childElementCount > 0) return;
const el = document.createElement("div");
el.style.cssText =
"font: 15px/1.5 system-ui, sans-serif; max-width: 32rem; margin: 20vh auto; padding: 0 1rem; color: inherit";
el.textContent = message;
root.appendChild(el);
}

View File

@@ -0,0 +1,32 @@
import type { CapabilityName } from "../types";
/**
* What this host says when it is asked for something it doesn't have.
*
* A rejected command reaches the user as a toast built from `message`, so the
* message is the user-facing text and has to read like one. The structured
* fields alongside it are for code: `capability` names the switch a caller
* should have checked first, and `cmd` identifies the command without anyone
* having to parse prose back out of the message.
*/
export class UnsupportedCommandError extends Error {
readonly name = "UnsupportedCommandError";
/** Stable discriminator, so a caller can branch without matching on text. */
readonly code = "unsupported_command";
readonly cmd: string;
readonly capability: CapabilityName | null;
constructor(cmd: string, message: string, capability: CapabilityName | null = null) {
super(message);
this.cmd = cmd;
this.capability = capability;
}
}
export function unsupported(
cmd: string,
reason: string,
capability: CapabilityName | null = null,
): UnsupportedCommandError {
return new UnsupportedCommandError(cmd, reason, capability);
}

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("");
}

View File

@@ -0,0 +1,383 @@
/**
* The browser host: Yaak in a tab, with no install and nothing running locally.
*
* The desktop host forwards to a Rust process. This one forwards to a worker
* running the same model layer compiled to wasm, over a `MessagePort` instead
* of Tauri's IPC. The worker owns the database and is shared by every tab on
* 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 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`
* and refuse with a reason if called anyway, so a missing feature shows up as a
* disabled control or a toast that explains itself, never as a silent no-op.
*/
import type {
DialogFilter,
DragDropEvent,
OsType,
Platform,
PlatformCapabilities,
PlatformWindow,
RpcPayload,
RpcStreamHandle,
SaveContent,
Unsubscribe,
} from "../types";
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";
/** What this host can do, reported honestly. */
function capabilitiesFor(): PlatformCapabilities {
return {
// 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,
grpc: false,
websocket: false,
git: false,
sync: false,
// Certificates and proxies are decided by whoever puts the bytes on the
// wire, and the Yaak server uses its own.
tlsOptions: false,
cookieJar: true,
localFiles: false,
// 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
// can't, so those open in place instead. This 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 of this flag.
multiWindow: false,
// The browser draws the frame around the page. There are no traffic lights
// to leave room for and no window controls to draw.
windowChrome: false,
// The browser already zooms the page, on the same keys, and remembers it
// per site. The app stays out of the way.
interfaceZoom: false,
plugins: false,
encryption: false,
updater: false,
// Reading needs a permission prompt at first paint, which is a bad ask for
// an app people paste bearer tokens into. Pasting still works everywhere.
clipboardRead: false,
systemFonts: false,
license: false,
};
}
/** Match `@tauri-apps/plugin-os` spellings so layout code needs no new branch. */
function detectOsType(): OsType {
const ua = navigator.userAgent;
if (/Mac|iPhone|iPad|iPod/.test(ua)) return "macos";
if (/Win/.test(ua)) return "windows";
if (/Android/.test(ua)) return "android";
return "linux";
}
/**
* The Tauri host-plugin commands, which ride outside the RPC envelope.
*
* `set_title` has a real browser equivalent. `set_theme` paints the native
* window frame behind the webview, which a tab has neither of. The license and
* font plugins answer with "nothing", which is true and keeps the settings
* screens rendering instead of erroring.
*/
async function hostPluginCommand<T>(cmd: string, payload?: RpcPayload): Promise<T> {
switch (cmd) {
case "plugin:yaak-mac-window|set_title": {
const title = payload?.title;
document.title = typeof title === "string" ? title : "Yaak";
return undefined as T;
}
case "plugin:yaak-mac-window|set_theme":
return undefined as T;
case "plugin:yaak-fonts|list":
// Enumerating installed fonts is a fingerprinting surface browsers don't
// offer. The pickers fall back to their bundled families.
return { editorFonts: [], uiFonts: [] } as T;
default:
throw unsupported(cmd, `\`${cmd}\` isn't available when Yaak runs in a browser`);
}
}
/**
* The File System Access API, where the browser has it. Chromium does; Firefox
* and Safari do not, and fall through to a download below.
*/
interface SaveFilePicker {
(options: {
suggestedName?: string;
types?: { description: string; accept: Record<string, string[]> }[];
}): Promise<{
name: string;
createWritable(): Promise<{ write(data: Uint8Array): Promise<void>; close(): Promise<void> }>;
}>;
}
/**
* Saving, the only way a page can: a real save dialog where the browser offers
* one, and a download everywhere else.
*
* The return value is a name rather than a path because a name is all a tab
* ever learns. Callers show it back to the user and nothing more, which is why
* the interface promises "where they went" rather than a path.
*/
function toBytes(content: SaveContent): Uint8Array {
if (content instanceof Uint8Array) return content;
const binary = atob(content.base64);
const out = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) out[i] = binary.charCodeAt(i);
return out;
}
async function saveBytes(
suggestedName: string,
content: SaveContent,
filters?: DialogFilter[],
): Promise<string | null> {
const picker = (window as unknown as { showSaveFilePicker?: SaveFilePicker }).showSaveFilePicker;
const bytes = toBytes(content);
if (picker != null) {
let handle;
try {
handle = await picker({
suggestedName,
types: filters?.map((f) => ({
description: f.name,
// The API keys accepted extensions by MIME type. Nothing here knows
// the real one, and it only labels the dialog's filter, so the
// catch-all is honest enough.
accept: { "application/octet-stream": f.extensions.map((e) => `.${e}`) },
})),
});
} catch (err) {
// Only choosing a file is allowed to fall back. Backing out is not a
// failure and must not become a download nobody asked for; anything else
// here is a browser that won't show the dialog, which a download covers.
if ((err as { name?: string } | null)?.name === "AbortError") return null;
console.warn("Save dialog unavailable, falling back to a download", err);
}
// Past the dialog, the user has named a destination. A write that fails
// there is a failed save, and saying so beats quietly putting the file
// somewhere else and reporting success.
if (handle != null) {
const writable = await handle.createWritable();
await writable.write(bytes);
await writable.close();
return handle.name;
}
}
// No dialog: the file lands wherever the browser puts downloads, under the
// name we suggested. The user is not asked, so there is nothing to cancel.
const url = URL.createObjectURL(new Blob([bytes as BlobPart]));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = suggestedName;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
// Revoked on a later turn: revoking while the browser is still reading the
// blob cancels the download in some of them.
setTimeout(() => URL.revokeObjectURL(url), 30_000);
return suggestedName;
}
function createWindow(db: WorkerConnection): PlatformWindow {
const noop = async () => {};
return {
// Stands in for the desktop's window label: the identity model writes carry
// so a tab can tell its own echo from another tab's write.
label: db.label,
// A tab manages its own frame. These exist because the interface names
// them; the UI only reaches for them behind `multiWindow`.
show: noop,
close: noop,
minimize: noop,
maximize: noop,
unmaximize: noop,
isMaximized: async () => false,
isFullscreen: async () => document.fullscreenElement != null,
setZoom: noop,
// Null means "no opinion, let CSS decide". The desktop returns a real value
// because applying a theme forces the window appearance and poisons the
// media query; nothing does that here, so `prefers-color-scheme` is the
// honest answer and the theme package already falls back to it.
theme: async () => null,
onThemeChanged(callback) {
const media = window.matchMedia("(prefers-color-scheme: dark)");
const listener = () => callback(media.matches ? "dark" : "light");
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
},
onFocusChanged(callback) {
const onFocus = () => callback(true);
const onBlur = () => callback(false);
window.addEventListener("focus", onFocus);
window.addEventListener("blur", onBlur);
return () => {
window.removeEventListener("focus", onFocus);
window.removeEventListener("blur", onBlur);
};
},
// Native drag-and-drop reports OS paths, which a page never sees. The DOM's
// own drag events are a different thing, and the components that need those
// use them directly.
onDragDrop(_callback: (event: DragDropEvent) => void): Unsubscribe {
return () => {};
},
};
}
export function createWebPlatform(): Platform {
const db = new WorkerConnection();
const capabilities = capabilitiesFor();
// Without this, IndexedDB is best-effort storage and a browser reclaiming
// space may drop someone's workspaces. Asking is all we can do, and there is
// nothing useful to do about a refusal.
void requestPersistence();
// Enough to answer "what does this host actually do?" from the console
// without reading the source.
(window as unknown as Record<string, unknown>).__YAAK_WEB__ = {
label: db.label,
capabilities,
commands: commandSupport,
};
return {
capabilities,
httpProxyUrl: serverBaseUrl() || window.location.origin,
window: createWindow(db),
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",
);
},
clear: async () => {
throw unsupported(
"clipboard.clear",
"Yaak in a browser can't modify the clipboard",
"clipboardRead",
);
},
},
// Returning null rather than throwing: null is what a cancelled dialog
// returns, which every caller already handles.
dialog: {
open: (async () => null) as Platform["dialog"]["open"],
save: async () => null,
},
files: {
readDir: async () => {
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");
},
// No filesystem here, so a path is just a string this host echoes back.
url: (path) => path,
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
resolveResource: async (path) => path,
save: saveBytes,
},
/**
* Bodies live in the worker's blob database, addressed by the id they were
* stored under. A page hands over an id and never a location, which is what
* keeps it from naming bytes the app never wrote.
*/
blobs: {
read: (id) => db.blobGet(id),
async url(id) {
const bytes = await db.blobGet(id);
// An object URL, the tab's equivalent of Tauri's `convertFileSrc`. The
// caller keys a query on it and drops it on the next response, so it is
// left to be reclaimed when the document goes rather than revoked here
// while an <img> may still be loading it.
return bytes == null ? null : URL.createObjectURL(new Blob([bytes]));
},
},
rpc<T>(cmd: string, payload?: RpcPayload): Promise<T> {
// `plugin:` commands are Tauri host plugins, not engine commands, and
// never reached the router even on the desktop.
if (cmd.startsWith("plugin:")) return hostPluginCommand<T>(cmd, payload);
return runCommand(cmd, payload ?? {}, db) as Promise<T>;
},
async rpcStream<T, M>(
cmd: string,
payload: RpcPayload,
onMessage: (message: M) => void,
): 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 = randomId();
const unlisten = db.listen(`stream_${streamId}`, (p) => onMessage(p as M));
try {
const result = (await runCommand(cmd, { ...payload, streamId }, db)) as T;
return { result, unlisten };
} catch (err) {
unlisten();
throw err;
}
},
listen<T>(event: string, callback: (payload: T) => void): Unsubscribe {
return db.listen(event, (payload) => callback(payload as T));
},
// Local only. Every emitter in the app is replying to something this tab is
// doing — a plugin round trip, a stream teardown — and telling other tabs
// about it would answer a question they never asked.
emit: async (event, payload) => db.deliver(event, payload),
openUrl: async (url) => {
window.open(url, "_blank", "noopener,noreferrer");
},
revealItemInDir: async () => {
throw unsupported(
"revealItemInDir",
"A browser tab can't open your file manager",
"localFiles",
);
},
osType: detectOsType,
appIdentifier: async () => "app.yaak.web",
};
}

View File

@@ -0,0 +1,53 @@
/**
* The messages that cross between a tab and the worker that owns the database.
*
* Declared once and imported from both sides, so a change to the shape is a
* type error in whichever side forgot. Kept deliberately small: commands in,
* results or errors out, and events pushed the other way — the same envelope
* the desktop's IPC uses, because that is what the frontend is written against.
*/
/** Tab → worker */
export type ToWorker =
| { type: "rpc"; id: number; cmd: string; payload: unknown; label: string }
/**
* The prepare half of a send: resolve, inherit and render a request against
* the database. Its own message rather than an `rpc` command because it is
* async in the engine (rendering is), where every `rpc` command is not.
*/
| { type: "prepare_http_send"; id: number; payload: unknown }
| { type: "blob_get"; id: number; blobId: string }
| { type: "blob_put"; id: number; blobId: string; bytes: ArrayBuffer }
| { type: "blob_delete"; id: number; blobId: string }
/** The tab is going away; the worker can forget its port. */
| { type: "goodbye" };
/** Worker → tab */
export type FromWorker =
/**
* Sent synchronously the moment a port connects, before anything else. Its
* only job is to prove the worker is alive: a tab that connects during a
* shared worker's teardown gets a port that is accepted and then never
* serviced, and this is how it tells that apart from a slow boot.
*/
| { type: "hello" }
/** The database is open. Sent to each port once boot has finished. */
| { type: "ready" }
/** The database could not be opened; every command will fail with this. */
| { type: "boot_error"; message: string }
| { type: "result"; id: number; result: unknown }
| { type: "error"; id: number; message: string }
/** A backend event for the app — today only `model_writes`. Sent to every port. */
| { type: "event"; event: string; payload: unknown };
/** What the worker registers itself under. Tabs on one origin share it. */
export const WORKER_NAME = "yaak-db";
/**
* The Web Lock the worker takes before opening the database.
*
* The browser already guarantees one SharedWorker per origin for this name.
* The lock covers the one overlap it doesn't rule out: a tab reloading itself,
* whose old worker may still be letting go while the new one comes up.
*/
export const DB_LOCK_NAME = "yaak-db";

View File

@@ -0,0 +1,352 @@
/**
* Sending an HTTP request from a tab.
*
* 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 server (`crates-server/yaak-web`).
* Everything else happens here, against this tab's own database, in the same
* order the desktop does it:
*
* 1. create the `http_response` row (state: initialized);
* 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 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 server's changes.
*
* The server keeps nothing. Every byte it sees comes from this tab and every
* byte it returns is stored by this tab.
*/
// Types only: the models package imports this one at runtime, and a type import
// is erased, so there is no cycle.
import type {
Cookie,
CookieJar,
HttpRequest,
HttpResponse,
HttpResponseEventData,
HttpSendSettings,
} from "@yaakapp-internal/models";
import type { Frame, SendRequest } from "@yaakapp-internal/web";
import type { WorkerConnection } from "./connection";
import { serverIdentity, serverSendUrl, readFrames } from "./server";
/* -------------------------------- shapes --------------------------------- */
/**
* The response row as this file knows it: what identifies it, plus whatever
* has been written so far. Every other field is optional and takes the model
* layer's default when absent, the same way the desktop's row does — defaults
* live in Rust, once.
*/
type ResponseRow = Pick<HttpResponse, "model" | "requestId" | "workspaceId"> &
Partial<Omit<HttpResponse, "model" | "requestId" | "workspaceId">>;
type ResponsePatch = Partial<HttpResponse>;
/** What `prepare_http_send` (crates/yaak-wasm) hands back. */
interface PreparedHttpSend {
request: HttpRequest;
settings: HttpSendSettings;
settingEvents: HttpResponseEventData[];
cookieJar: CookieJar | null;
}
/** The desktop writes progress at most this often while a body streams in. */
const PROGRESS_INTERVAL_MS = 100;
/* --------------------------------- send ---------------------------------- */
export async function sendHttpRequest(
db: WorkerConnection,
requestId: string,
environmentId: string | null,
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 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 });
await response.create();
const cancel = new AbortController();
const unlistenCancel = db.listen(`cancel_http_response_${response.id}`, () => cancel.abort());
try {
await runSend(db, response, requestId, environmentId, cookieJarId, cancel.signal);
} catch (err) {
const message = cancel.signal.aborted ? "Request canceled" : errorMessage(err);
await response.finish({ error: message });
} finally {
unlistenCancel();
}
return response.current();
}
async function runSend(
db: WorkerConnection,
response: ResponseWriter,
requestId: string,
environmentId: string | null,
cookieJarId: string | null,
signal: AbortSignal,
): Promise<void> {
const prepared = await db.prepareHttpSend<PreparedHttpSend>({
requestId,
environmentId,
cookieJarId,
});
await response.patch({ url: prepared.request.url });
// The first line of the timeline says what did the sending and where. A
// 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 serverIdentity()}` }]);
timeline.push(prepared.settingEvents);
const body: SendRequest = {
request: prepared.request,
settings: prepared.settings,
cookies: prepared.cookieJar?.cookies ?? null,
};
const startedAt = performance.now();
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 Yaak server at ${serverSendUrl()}: ${errorMessage(err)}`);
});
if (!res.ok) {
// A refusal, not a failed send: bad destination, rate limit, a body the
// server can't build. It comes as JSON with the reason.
const text = await res.text();
let reason = text;
try {
reason = (JSON.parse(text) as { error?: string }).error ?? text;
} catch {
/* not JSON; the text is the reason */
}
throw new Error(reason || `The Yaak server answered ${res.status}`);
}
if (res.body == null) throw new Error("The Yaak server sent no body");
const chunks: Uint8Array[] = [];
let received = 0;
let lastProgress = startedAt;
let terminal: Frame | null = null;
for await (const frame of readFrames(res.body)) {
switch (frame.type) {
case "event":
timeline.push([frame.event]);
break;
case "response":
await response.patch(headOf(frame));
break;
case "body": {
const bytes = base64ToBytes(frame.data);
chunks.push(bytes);
received += bytes.byteLength;
const now = performance.now();
if (now - lastProgress >= PROGRESS_INTERVAL_MS) {
lastProgress = now;
await response.patch({
contentLength: received,
elapsed: Math.round(now - startedAt),
});
}
break;
}
case "done":
case "error":
terminal = frame;
break;
}
if (terminal != null) break;
}
// 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 Yaak server closed the stream without finishing");
}
// Cookies come back on both outcomes: a hop before the failing one may have
// set some, and the desktop keeps those too.
if (prepared.cookieJar != null && terminal.cookies != null) {
await persistCookies(db, prepared.cookieJar, terminal.cookies);
}
if (terminal.type === "error") {
throw new Error(terminal.message);
}
// 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 server's cap anyway.
await db.blobPut(response.id, concat(chunks, received));
await response.finish({
contentLength: terminal.contentLength,
contentLengthCompressed: terminal.contentLengthCompressed,
elapsed: terminal.elapsed,
});
}
function headOf(frame: Extract<Frame, { type: "response" }>): ResponsePatch {
return {
state: "connected",
status: frame.status,
statusReason: frame.statusReason,
url: frame.url,
remoteAddr: frame.remoteAddr,
version: frame.version,
headers: frame.headers,
requestHeaders: frame.requestHeaders,
contentLength: frame.contentLength,
elapsedHeaders: frame.elapsedHeaders,
elapsedDns: frame.elapsedDns,
};
}
/* ------------------------------- helpers --------------------------------- */
/**
* The response row, written the way the desktop writes it: created empty,
* patched as the send progresses, closed at the end. Each write goes through
* `models_upsert`, so every tab on this database sees the response land.
*/
class ResponseWriter {
private state: ResponseRow;
constructor(
private readonly db: WorkerConnection,
initial: ResponseRow,
) {
this.state = initial;
}
get id(): string {
return this.state.id ?? "";
}
get workspaceId(): string {
return this.state.workspaceId;
}
current(): ResponseRow {
return this.state;
}
/** Create the row. Everything but its identity is the model layer's default. */
async create(): Promise<void> {
const id = await this.db.rpc<string>("models_upsert", { model: this.state });
this.state = { ...this.state, id };
}
async patch(patch: ResponsePatch): Promise<void> {
// Structured clone carries `undefined` across to the worker as a present
// key, and the model layer reads that as "wrong type" and refuses the whole
// model. Nothing here should produce one, but a missing wire field must
// not take the response row down with it.
const defined = Object.fromEntries(Object.entries(patch).filter(([, v]) => v !== undefined));
this.state = { ...this.state, ...defined };
await this.db.rpc("models_upsert", { model: this.state });
}
async finish(patch: ResponsePatch): Promise<void> {
await this.patch({ ...patch, state: "closed" });
}
}
/**
* Timeline events, written in the order they arrived. Writes are chained rather
* than awaited inline so a burst of `header_down` events doesn't serialise the
* body read behind a database round trip each, and `flush()` is the point at
* which the whole timeline is known to be in the database.
*/
class TimelineWriter {
private queue: HttpResponseEventData[] = [];
private inFlight: Promise<void> = Promise.resolve();
constructor(
private readonly db: WorkerConnection,
private readonly responseId: string,
private readonly workspaceId: string,
) {}
push(events: HttpResponseEventData[]): void {
if (events.length === 0) return;
this.queue.push(...events);
this.inFlight = this.inFlight.then(() => this.drain());
}
private async drain(): Promise<void> {
if (this.queue.length === 0) return;
const events = this.queue;
this.queue = [];
await this.db.rpc("web_insert_http_response_events", {
responseId: this.responseId,
workspaceId: this.workspaceId,
events,
});
}
flush(): Promise<void> {
return this.inFlight;
}
}
/**
* Carry the send's cookie changes into the jar. The worker applies them as a
* difference against the jar as it is now (see `apply_cookie_changes` in
* yaak-models), so an edit made while the send was in flight survives rather
* than being written over by the send's stale snapshot.
*/
async function persistCookies(db: WorkerConnection, jar: CookieJar, cookies: Cookie[]): Promise<void> {
await db.rpc("web_persist_send_cookies", { cookieJarId: jar.id, before: jar.cookies, after: cookies });
}
/**
* The request's workspace, needed to create the response row before the worker
* has resolved the request (which is where a render refusal would land).
*/
async function workspaceIdOfRequest(db: WorkerConnection, requestId: string): Promise<string> {
const req = await db.rpc<{ workspaceId: string }>("web_get_http_request", { requestId });
return req.workspaceId;
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
function base64ToBytes(data: string): Uint8Array {
const bin = atob(data);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
function concat(chunks: Uint8Array[], total: number): Uint8Array {
if (chunks.length === 1) return chunks[0]!;
const out = new Uint8Array(total);
let offset = 0;
for (const c of chunks) {
out.set(c, offset);
offset += c.byteLength;
}
return out;
}

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. 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();
return configured ? configured.replace(/\/+$/, "") : "";
}
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();
}
}

View File

@@ -0,0 +1,18 @@
/**
* Ask the browser not to evict this origin's data under storage pressure.
*
* Without it IndexedDB — where the worker's SQLite pages live — is "best
* effort", and a browser clearing space can drop a user's workspaces. Granting
* is the browser's call; it typically says yes once a site looks installed or
* engaged, and often says no on localhost. This is a request, not a guarantee,
* and there is nothing useful to do when it declines.
*/
export async function requestPersistence(): Promise<boolean> {
try {
if (navigator.storage?.persist == null) return false;
if (await navigator.storage.persisted()) return true;
return await navigator.storage.persist();
} catch {
return false;
}
}

View File

@@ -0,0 +1,196 @@
/// <reference lib="webworker" />
/**
* The process that owns the database.
*
* On the desktop that is the Rust binary: it holds SQLite, every window talks
* to it, and it pushes model writes to all of them. In a browser this worker
* plays that part. It loads the model layer compiled to wasm, opens the one
* database, answers each tab's commands over its port, and fans every
* `model_writes` out to every port — so two tabs are coherent for the same
* reason two desktop windows are, not because of a side channel.
*
* It 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. It still takes a Web Lock before opening the database,
* for the one overlap the browser doesn't rule out — a tab reloading itself,
* whose old worker may still be letting go while the new one comes up.
*/
import { DB_LOCK_NAME, type FromWorker, type ToWorker } from "./protocol";
/**
* The wasm is imported lazily, inside `boot()`, rather than at the top of the
* module. That keeps this script's own evaluation instant, so a tab's connect
* gets its `hello` immediately regardless of how long the model layer takes to
* 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/wasm");
let engine: Engine | null = null;
const ports = new Set<MessagePort>();
/** Resolves once `boot()` has, or rejects with why it couldn't. */
let booted: Promise<void> | null = null;
let bootError: string | null = null;
function send(port: MessagePort, message: FromWorker, transfer: Transferable[] = []): void {
port.postMessage(message, transfer);
}
function broadcast(message: FromWorker): void {
for (const port of ports) send(port, message);
}
function errorMessage(err: unknown): string {
if (err instanceof Error) return err.message;
return String(err);
}
/**
* How long to wait for the database lock before concluding someone else has it
* for good. The wait exists for one case: a tab reloading itself. Its old
* worker still holds the lock while it is torn down, and the new worker only
* needs it to let go — which takes milliseconds, not seconds. Anything longer
* is a live worker in another tab, and the honest answer is to say so.
*/
const LOCK_TIMEOUT_MS = 3000;
const ALREADY_OPEN =
"Yaak's database is held by another worker that isn't letting go. Close Yaak's other tabs and reload.";
/**
* Take the lock, or explain why not.
*
* Held for the life of the worker: the callback's promise never settles, so
* the browser keeps the lock until this worker is gone. Requested with a
* timeout rather than `ifAvailable`, so a dying predecessor's brief hold is
* waited out but a live one is reported.
*/
function acquireDatabaseLock(): Promise<void> {
// The tab checked for Web Locks before it ever connected; a worker without
// them would be a browser lying about its own features.
return new Promise<void>((resolve, reject) => {
navigator.locks
.request(DB_LOCK_NAME, { signal: AbortSignal.timeout(LOCK_TIMEOUT_MS) }, () => {
resolve();
return new Promise<void>(() => {});
})
.catch((err: unknown) => {
const name = (err as { name?: string } | null)?.name;
reject(name === "AbortError" || name === "TimeoutError" ? new Error(ALREADY_OPEN) : err);
});
});
}
/**
* Open the database, once, for everyone.
*
* Lock first, then load the model layer, then open — in that order, so a
* worker that will never own the database also never downloads and compiles
* the wasm for it.
*/
function bootOnce(): Promise<void> {
if (booted != null) return booted;
booted = (async () => {
await acquireDatabaseLock();
const loaded = await import("@yaakapp-internal/wasm");
await loaded.boot();
engine = loaded;
})();
booted.catch((err) => {
bootError = errorMessage(err);
});
return booted;
}
async function handle(port: MessagePort, message: ToWorker): Promise<void> {
if (message.type === "goodbye") {
ports.delete(port);
return;
}
// Every command waits for boot rather than the tab having to. Tabs post
// the moment they load; the port queues; this drains once the DB is open.
try {
await booted;
} catch {
send(port, { type: "error", id: message.id, message: bootError ?? "Database failed to open" });
return;
}
const { rpc, blob_get, blob_put, blob_delete, prepare_http_send } = engine!;
try {
switch (message.type) {
case "rpc": {
const outcome = rpc(message.cmd, message.payload, message.label) as {
result: unknown;
events: unknown[];
};
// Result first, to the caller; then the writes, to everyone including
// the caller. The store applies its own echo the same as any other
// window's, so it must arrive — and the caller's `await` resolving
// before its echo lands is fine, because it resolves on the same tick
// and the store reads on the next.
send(port, { type: "result", id: message.id, result: outcome.result });
if (outcome.events.length > 0) {
broadcast({ type: "event", event: "model_writes", payload: outcome.events });
}
return;
}
case "prepare_http_send": {
const prepared = await prepare_http_send(message.payload);
send(port, { type: "result", id: message.id, result: prepared });
return;
}
case "blob_get": {
const bytes = blob_get(message.blobId);
if (bytes == null) {
send(port, { type: "result", id: message.id, result: null });
} else {
// Copy into a fresh buffer we can transfer: the wasm's memory
// cannot leave the worker.
const out = new Uint8Array(bytes.byteLength);
out.set(bytes);
send(port, { type: "result", id: message.id, result: out.buffer }, [out.buffer]);
}
return;
}
case "blob_put": {
blob_put(message.blobId, new Uint8Array(message.bytes));
send(port, { type: "result", id: message.id, result: null });
return;
}
case "blob_delete": {
blob_delete(message.blobId);
send(port, { type: "result", id: message.id, result: null });
return;
}
}
} catch (err) {
send(port, { type: "error", id: message.id, message: errorMessage(err) });
}
}
function attach(port: MessagePort): void {
ports.add(port);
port.onmessage = (e: MessageEvent<ToWorker>) => void handle(port, e.data);
port.start?.();
// Proof of life, before boot: the tab is timing this.
send(port, { type: "hello" });
bootOnce().then(
() => send(port, { type: "ready" }),
(err) => send(port, { type: "boot_error", message: errorMessage(err) }),
);
}
// Each tab arrives as a connect event with its own port.
(self as unknown as SharedWorkerGlobalScope).onconnect = (e: MessageEvent) => {
attach(e.ports[0]!);
};