Add the Yaak Bridge so a browser tab can run the real engine

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Gregory Schier
2026-08-15 11:03:21 -07:00
co-authored by Claude Fable 5
parent 93001e3da7
commit e294e6bcef
17 changed files with 3862 additions and 11 deletions
+211
View File
@@ -0,0 +1,211 @@
import type { Unsubscribe } from "../types";
/**
* The wire to the Yaak Bridge: one `POST /rpc` per command, one WebSocket for
* events in both directions.
*
* The hard requirement this file exists to satisfy: the connection is opened
* asynchronously, but the host that uses it must be constructible
* *synchronously*. Boot-time modules call commands while the module graph is
* still evaluating (`lib/appInfo.ts` top-level-awaits one), so there is no
* later moment to install a host, and a registry that waited for a socket would
* deadlock. So every call made before the connection opens is queued here and
* flushed when it does. The app's own top-level await then doubles as the
* connection gate: nothing renders until the first command has answered, which
* means it has answered over a live connection.
*/
interface EventFrame {
event: string;
payload: unknown;
}
export interface BridgeInfo {
name: string;
version: string;
capabilities: Record<string, boolean>;
commands: string[];
}
/** How long to wait before retrying a dropped connection, and the ceiling. */
const RECONNECT_BASE_MS = 250;
const RECONNECT_MAX_MS = 5000;
export class BridgeConnection {
readonly baseUrl: string;
readonly label: string;
/**
* Null when the user hasn't supplied one yet. The connection then never
* opens, so every call queues forever — which is exactly what the connect
* screen wants, and means "waiting for a token" and "waiting for the socket"
* are the same code path rather than two.
*/
private readonly token: string | null;
private socket: WebSocket | null = null;
private connected = false;
private reconnectDelay = RECONNECT_BASE_MS;
/** Frames the page tried to send before the socket opened. */
private outboundQueue: EventFrame[] = [];
/** Resolvers for anything awaiting the first successful connection. */
private readyWaiters: Array<() => void> = [];
private listeners = new Map<string, Set<(payload: unknown) => void>>();
info: BridgeInfo | null = null;
constructor(baseUrl: string, token: string | null, label: string) {
this.baseUrl = baseUrl.replace(/\/$/, "");
this.token = token;
this.label = label;
if (token != null) this.openSocket();
}
get hasToken(): boolean {
return this.token != null;
}
/** Resolves once the events socket is open. */
ready(): Promise<void> {
if (this.connected) return Promise.resolve();
return new Promise((resolve) => this.readyWaiters.push(resolve));
}
/** A URL on the bridge with the token attached, for the browser to fetch directly. */
url(path: string): string {
const url = new URL(this.baseUrl + path);
url.searchParams.set("token", this.token ?? "");
return url.toString();
}
async fetch(path: string, init?: RequestInit): Promise<Response> {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${this.token ?? ""}`);
return fetch(this.baseUrl + path, { ...init, headers });
}
async loadInfo(): Promise<BridgeInfo> {
const res = await this.fetch("/bridge/info");
if (!res.ok) {
throw new Error(`Bridge rejected the connection (${res.status}). Is the token correct?`);
}
this.info = (await res.json()) as BridgeInfo;
return this.info;
}
/**
* Send a command and await its result.
*
* Waits for the connection first, so a command issued during module
* evaluation queues instead of failing. Errors are carried inside the
* envelope and rethrown here, so callers see the backend's own message —
* matching what Tauri's `invoke` does with a rejected command.
*/
async rpc<T>(cmd: string, payload: Record<string, unknown> = {}): Promise<T> {
await this.ready();
const res = await this.fetch("/rpc", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: crypto.randomUUID(), cmd, payload }),
});
if (!res.ok) {
throw new Error(`Bridge request failed (${res.status})`);
}
const body = (await res.json()) as
| { type: "Success"; id: string; payload: T }
| { type: "Error"; id: string; error: string };
if (body.type === "Error") {
throw new Error(body.error);
}
return body.payload;
}
listen(event: string, callback: (payload: unknown) => void): Unsubscribe {
let handlers = this.listeners.get(event);
if (handlers == null) {
handlers = new Set();
this.listeners.set(event, handlers);
}
handlers.add(callback);
// Synchronous, because callers unsubscribe from React cleanups.
return () => {
const current = this.listeners.get(event);
if (current == null) return;
current.delete(callback);
if (current.size === 0) this.listeners.delete(event);
};
}
emit(event: string, payload: unknown): void {
const frame: EventFrame = { event, payload };
if (this.socket != null && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(frame));
} else {
this.outboundQueue.push(frame);
}
}
/** Tell the bridge who and where we are — what a desktop window's URL says. */
attach(): void {
this.emit("bridge_attach", { label: this.label, url: window.location.href });
}
private openSocket(): void {
const wsUrl = new URL(this.baseUrl.replace(/^http/, "ws") + "/events");
wsUrl.searchParams.set("token", this.token ?? "");
const socket = new WebSocket(wsUrl.toString());
this.socket = socket;
socket.onopen = () => {
this.connected = true;
this.reconnectDelay = RECONNECT_BASE_MS;
this.attach();
for (const frame of this.outboundQueue.splice(0)) {
socket.send(JSON.stringify(frame));
}
for (const resolve of this.readyWaiters.splice(0)) {
resolve();
}
};
socket.onmessage = (message) => {
let frame: EventFrame;
try {
frame = JSON.parse(String(message.data)) as EventFrame;
} catch {
console.warn("Bridge sent a malformed event frame");
return;
}
// Deliver the payload directly, not wrapped in Tauri's `{ payload }`.
for (const handler of this.listeners.get(frame.event) ?? []) {
try {
handler(frame.payload);
} catch (err) {
console.error("Bridge event handler threw", frame.event, err);
}
}
};
socket.onclose = () => {
this.connected = false;
this.socket = null;
// The server closes the socket when a tab falls too far behind to be
// consistent, so a reconnect has to re-read the workspace rather than
// resume. `bridge_reconnected` is what tells the app to do that.
window.setTimeout(() => this.openSocket(), this.reconnectDelay);
this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS);
};
socket.onerror = () => {
// `onclose` always follows, and it owns the retry.
socket.close();
};
}
}
+290
View File
@@ -0,0 +1,290 @@
import type {
DragDropEvent,
OsType,
Platform,
PlatformCapabilities,
PlatformWindow,
RpcPayload,
RpcStreamHandle,
Unsubscribe,
} from "../types";
import { BridgeConnection } from "./connection";
/**
* The browser host: the Yaak UI in a tab, with the real engine running in the
* Yaak Bridge next to it.
*
* Everything the desktop gets from Tauri comes over one HTTP connection
* instead. The parts a page genuinely cannot do — a native file dialog, a
* second window, reading the clipboard unprompted — are not faked. They report
* false through `capabilities` and throw if called anyway, so a missing feature
* surfaces as a disabled control rather than a silent no-op.
*/
/**
* Until the bridge answers, assume nothing works.
*
* These are replaced wholesale by the server's own report as soon as
* `/bridge/info` returns, which happens before the app's first render — the
* boot sequence top-level-awaits a command, and that command cannot resolve
* before the connection is up. Starting pessimistic means that if that ordering
* ever changes, the UI hides a feature it should have shown instead of offering
* one that will fail.
*/
const NO_CAPABILITIES: PlatformCapabilities = {
grpc: false,
websocket: false,
git: false,
sync: false,
tlsOptions: false,
cookieJar: false,
localFiles: false,
timeline: false,
multiWindow: false,
plugins: false,
encryption: false,
updater: false,
clipboardRead: false,
systemFonts: false,
license: false,
};
function unsupported(what: string): Error {
return new Error(`${what} is not supported in the browser`);
}
/** Match `@tauri-apps/plugin-os` spellings so layout code needs no new branch. */
function detectOsType(): OsType {
const platform = navigator.userAgent;
if (/Mac|iPhone|iPad|iPod/.test(platform)) return "macos";
if (/Win/.test(platform)) return "windows";
if (/Android/.test(platform)) return "android";
return "linux";
}
/**
* A response body path is an opaque handle the backend minted, and the bridge
* writes them as `<data dir>/responses/<response id>`. Taking the last segment
* turns it back into the id the `/responses/{id}/body` route wants, which keeps
* the server from ever being asked for a path chosen by the page.
*/
function responseIdFromBodyPath(path: string): string {
const segments = path.split(/[/\\]/);
return segments[segments.length - 1] ?? path;
}
/**
* Answer 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 no equivalent of and does
* not need. Everything else is a desktop-only feature; rejecting is correct,
* and the callers already gate on the matching capability.
*/
async function handleHostPluginCommand<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;
default:
throw unsupported(`\`${cmd}\``);
}
}
function createWindow(connection: BridgeConnection): PlatformWindow {
const noop = async () => {};
return {
label: connection.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 them
// use them directly.
onDragDrop(_callback: (event: DragDropEvent) => void): Unsubscribe {
return () => {};
},
};
}
/**
* Keep the bridge told where the tab is.
*
* The desktop reads the workspace, environment, cookie jar and request straight
* off the window's URL whenever a plugin asks. The bridge can't, so the tab
* pushes it on every navigation. The router uses the History API, which fires
* no event of its own on push, hence the wrapping.
*/
function trackNavigation(connection: BridgeConnection): void {
const report = () => connection.attach();
for (const method of ["pushState", "replaceState"] as const) {
const original = history[method];
history[method] = function (this: History, ...args: Parameters<History["pushState"]>) {
const result = original.apply(this, args);
report();
return result;
};
}
window.addEventListener("popstate", report);
window.addEventListener("hashchange", report);
}
export function createBridgePlatform(baseUrl: string, token: string | null): Platform {
const label = `tab_${crypto.randomUUID().slice(0, 8)}`;
const connection = new BridgeConnection(baseUrl, token, label);
// Mutated in place once the bridge reports, because `platform.capabilities`
// hands out this object and callers hold the reference.
const capabilities: PlatformCapabilities = { ...NO_CAPABILITIES };
if (connection.hasToken) {
void connection
.loadInfo()
.then((info) => Object.assign(capabilities, info.capabilities))
.catch((err) => console.error("Failed to read bridge capabilities", err));
}
trackNavigation(connection);
// Two host requests the plugin runtime makes that only a page can carry out.
connection.listen("bridge_copy_text", (payload) => {
const text = (payload as { text?: string } | null)?.text;
if (typeof text === "string") void navigator.clipboard.writeText(text);
});
connection.listen("bridge_open_url", (payload) => {
const url = (payload as { url?: string } | null)?.url;
if (typeof url === "string") window.open(url, "_blank", "noopener,noreferrer");
});
const platformWindow = createWindow(connection);
return {
capabilities,
window: platformWindow,
clipboard: {
writeText: (text) => navigator.clipboard.writeText(text),
// Reading needs a permission prompt the moment the page paints, which is
// a bad ask for an app people paste bearer tokens into. `clipboardRead`
// is false and the one caller is gated on it.
readText: async () => {
throw unsupported("Reading the clipboard");
},
clear: async () => {
throw unsupported("Clearing the clipboard");
},
},
dialog: {
open: (async () => null) as Platform["dialog"]["open"],
save: async () => null,
},
files: {
async readFile(path) {
const res = await connection.fetch(`/responses/${responseIdFromBodyPath(path)}/body`);
if (!res.ok) {
throw new Error(`Failed to read response body (${res.status})`);
}
return new Uint8Array(await res.arrayBuffer());
},
readDir: async () => {
throw unsupported("Browsing the filesystem");
},
// The `<img src>`/`<video src>` equivalent of Tauri's `convertFileSrc`.
// The token rides in the query because the browser makes these requests
// itself and the page cannot add a header to them.
url: (path) => connection.url(`/responses/${responseIdFromBodyPath(path)}/body`),
basename: async (path) => path.split(/[/\\]/).pop() ?? path,
resolveResource: async (path) => path,
},
rpc: <T,>(cmd: string, payload?: RpcPayload): Promise<T> => {
// `plugin:`-prefixed commands are Tauri host plugins, not engine
// commands, so they never reach the RpcRouter. Two of them are window
// chrome the tab can do itself; the rest belong to features this host
// reports false for, and saying so beats a confusing "unknown command".
if (cmd.startsWith("plugin:")) {
return handleHostPluginCommand<T>(cmd, payload);
}
return connection.rpc<T>(cmd, payload);
},
async rpcStream<T, M>(
cmd: string,
payload: RpcPayload,
onMessage: (message: M) => void,
): Promise<RpcStreamHandle<T>> {
// Caller-minted id, subscribed before dispatch, exactly as on the
// desktop: the command can emit its first message before it returns.
const streamId = crypto.randomUUID();
const unlisten = connection.listen(`stream_${streamId}`, (p) => onMessage(p as M));
try {
const result = await connection.rpc<T>(cmd, { ...payload, streamId });
return { result, unlisten };
} catch (err) {
unlisten();
throw err;
}
},
listen: <T,>(event: string, callback: (payload: T) => void): Unsubscribe =>
connection.listen(event, (payload) => callback(payload as T)),
emit: async (event, payload) => connection.emit(event, payload),
openUrl: async (url) => {
window.open(url, "_blank", "noopener,noreferrer");
},
revealItemInDir: async () => {
throw unsupported("Revealing a file");
},
osType: detectOsType,
appIdentifier: async () => "app.yaak.bridge",
};
}
+108
View File
@@ -0,0 +1,108 @@
/**
* Deciding which host to install, and getting a bridge token when there isn't
* one yet.
*
* Dev-grade on purpose. The token is a shared secret the bridge prints at
* startup, passed in the URL and kept for the session. OTP pairing and request
* encryption replace this whole file; the seam is that nothing outside it knows
* how the token was obtained.
*/
export interface BridgeConfig {
url: string;
token: string;
}
const TOKEN_STORAGE_KEY = "yaak.bridge.token";
const TOKEN_QUERY_PARAM = "bridgeToken";
declare global {
interface Window {
__TAURI_INTERNALS__?: unknown;
}
// Declared here rather than by depending on Vite's types: this package is
// consumed by a bundler that provides them, and only this one variable.
interface ImportMeta {
readonly env?: Record<string, string | undefined>;
}
}
function bridgeUrl(): string {
// Set when the frontend runs on a Vite dev server and the bridge is on its
// own port. When the bridge serves the built app, they share an origin.
const configured = import.meta.env?.VITE_YAAK_BRIDGE_URL;
return (configured ?? window.location.origin).replace(/\/$/, "");
}
/**
* The bridge token, or null if the user hasn't supplied one.
*
* A token in the URL is consumed and stashed: leaving it in the address bar
* means it lands in the router's own history entries and in anything the user
* copies out of the bar.
*/
function readToken(): string | null {
const url = new URL(window.location.href);
const fromQuery = url.searchParams.get(TOKEN_QUERY_PARAM);
if (fromQuery != null && fromQuery !== "") {
sessionStorage.setItem(TOKEN_STORAGE_KEY, fromQuery);
url.searchParams.delete(TOKEN_QUERY_PARAM);
history.replaceState(null, "", url.toString());
return fromQuery;
}
return sessionStorage.getItem(TOKEN_STORAGE_KEY);
}
/** Whether this build should talk to a bridge at all. */
export function shouldUseBridge(): boolean {
if (typeof window === "undefined") return false;
// Running inside the desktop app: Tauri always wins.
if (window.__TAURI_INTERNALS__ != null) return false;
return true;
}
export function bridgeConfig(): BridgeConfig | null {
const token = readToken();
if (token == null) return null;
return { url: bridgeUrl(), token };
}
/**
* Put the connect form on screen.
*
* Synchronous, and it does not stop anything by itself — the caller pairs it
* with a host that never connects, so the app's own boot-time await is what
* holds. Submitting reloads with the token in the query, which `readToken`
* then consumes.
*/
export function promptForToken(): void {
document.body.innerHTML = `
<div style="font-family: system-ui, sans-serif; max-width: 26rem; margin: 15vh auto; padding: 0 1.5rem; color: #d5d3e0">
<h1 style="font-size: 1.25rem; margin: 0 0 0.5rem">Connect to the Yaak Bridge</h1>
<p style="margin: 0 0 1.25rem; line-height: 1.5; color: #9a97ad">
Paste the token the bridge printed when it started.
</p>
<form id="yaak-bridge-connect" style="display: flex; gap: 0.5rem">
<input name="token" autofocus autocomplete="off" spellcheck="false" placeholder="Bridge token"
style="flex: 1; padding: 0.5rem 0.65rem; border-radius: 0.375rem; border: 1px solid #3b3950; background: #232135; color: inherit; font-family: ui-monospace, monospace" />
<button type="submit"
style="padding: 0.5rem 1rem; border-radius: 0.375rem; border: 0; background: #6d5ef0; color: white; font-weight: 500; cursor: pointer">
Connect
</button>
</form>
</div>
`;
document.documentElement.style.background = "#1b1a29";
document.getElementById("yaak-bridge-connect")?.addEventListener("submit", (e) => {
e.preventDefault();
const token = new FormData(e.target as HTMLFormElement).get("token");
if (typeof token !== "string" || token === "") return;
const url = new URL(window.location.href);
url.searchParams.set(TOKEN_QUERY_PARAM, token);
window.location.href = url.toString();
});
}
+23 -7
View File
@@ -1,14 +1,30 @@
import { createBridgePlatform } from "./bridge";
import { bridgeConfig, promptForToken, shouldUseBridge } from "./connect";
import { setPlatform } from "./registry";
import { createTauriPlatform } from "./tauri";
// Desktop is the only host today, so it is 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 it has to run synchronously: several modules
// call commands while the module graph is still evaluating, so there is no
// later moment to install a host in.
//
// This line is the swap point. A browser build selects its own host here, and
// because nothing else in the app imports a host directly, that is the whole
// change.
setPlatform(createTauriPlatform());
// Both hosts are constructible without waiting for anything. The bridge host
// opens its connection in the background and queues calls made before it lands,
// which is why picking a host here does not mean blocking on one.
if (shouldUseBridge()) {
const config = bridgeConfig();
if (config == null) {
// No token yet: put the connect form on screen and install a host that
// never connects. Boot then stalls at its own top-level await rather than
// failing somewhere that doesn't explain itself — and it stalls in the one
// place already designed to wait, which keeps this file synchronous.
promptForToken();
setPlatform(createBridgePlatform(window.location.origin, null));
} else {
setPlatform(createBridgePlatform(config.url, config.token));
}
} else {
setPlatform(createTauriPlatform());
}
export * from "./capabilities";
export { platform, setPlatform } from "./registry";