Backend Bridge Pattern
Problem
The frontend needs a way to communicate with the backend that works across multiple contexts:
Production — The app runs inside Tauri and calls Rust commands via IPC
Mock dev mode — Frontend-only dev server with no Tauri runtime (
pnpm dev:mock)REST dev mode — Frontend in Chrome, talking to the real Rust backend over HTTP (
pnpm dev:rest)Testing/Storybook — Components run in a browser with no Tauri runtime
Directly importing @tauri-apps/api in components would make them untestable outside Tauri.
Solution
The @takazudo/backend-bridge package (packages/backend-bridge/) defines a BackendAPI TypeScript interface and provides swappable adapters:
BackendAPI (interface)
├── TauriAdapter — invoke() (production)
├── MockAdapter — in-memory (testing/Storybook)
└── RestAdapter — fetch() (Chrome debugging)TauriAdapter — Used in the native Tauri app. Calls Rust commands via
invoke()and listens for events vialisten().MockAdapter — Powers the standalone mock dev mode (
pnpm dev:mock), Storybook stories, and unit/integration tests. All data is in-memory.RestAdapter — Used in REST dev mode (
pnpm dev:rest). Calls the same Rust backend logic over HTTP (fetch()) and receives events via SSE (Server-Sent Events). This enables full Chrome DevTools debugging with real data.
BackendAPI Interface
The interface groups operations by domain (abridged; see packages/ for the full contract):
interface BackendAPI {
capabilities: Capabilities
appMode: () => Promise<AppRole>
files: { readText, writeText, deleteFile, mkdir, getUserSkillsDir, readDir, watchDir, unwatchDir, onSkillsChanged }
framePopout: { popOut, dock, onPopoutClosed, requestRestore, onRestoreRequest, sendRestorePayload, onRestorePayload, sendRestoreAck, onRestoreAck, sendReturnRequest, onReturnRequest }
workspaceFiles: { isSupported, read, exists, write, delete, list, listAll, metadata, subscribe }
workspace: { getDir, listFiles } // compatibility metadata; content uses workspaceFiles
dialog: { openDirectory, openFile, createDirectory, openFiles }
settings: { get, save }
fonts: { list }
device: { getName, setName, clearName }
auth: { getState, login, logout, onStateChanged }
subscription: { getInfo, startTrial, checkEntitlement, getPortalUrl, onInfoChanged }
cloudSync: { getStatus, triggerSync, connect, disconnect, setupEncryption, verifyPassword, armEncryption, getConflicts, resolveConflicts, onStatusChanged, onRemoteChange }
assets: { list, saveFile, importFile, readFile, deleteFile }
publish: { getProfile, updateProfile, deleteProfile, publishPage, replacePublishedPage, publishAdditionalPage, updatePage, unpublishPage, archivePage, restorePage, deletePage, emptyArchivedPages, getPageSnapshot, setPagePassword, removePagePassword, listPages, getPage, onPagesChanged }
generator: { scaffold, assembleChild, onAssembleProgress, findLeafForWorkspace, openApp }
window: { setOpacity, print, openExternalUrl, setTitle }
similarDocs: { query, getContent, writeContent, rebuildIndex }
aiSearchMessages: (prompt, titles, topK?) => Promise<{ filenames, summary }>
chat: (messages, options) => AsyncIterable<ChatEvent>
inlineCommand: { stream }
fileSearch: FileSearchAPI
getHomeDir: () => Promise<string>
revealDirectory: (path) => Promise<void>
apiTokens: { list, create, revoke }
}Each method returns a Promise. Event subscriptions such as onChanged return an unsubscribe function.
The full interface also retains compatibility domains for messages, pins, notes, and inbox. Production adapters implement their content operations over workspaceFiles; the native filesystem handlers and watchers for those domains survive only for the development REST fallback. New workspace features should use workspaceFiles directly.
Singleton Pattern
The bridge uses a module-level singleton:
// get-backend.ts
let backend: BackendAPI | null = null;
export function initBackend(adapter: BackendAPI): void {
backend = adapter;
}
export function getBackend(): BackendAPI {
if (!backend) {
throw new Error("Backend not initialized. Call initBackend() first.");
}
return backend;
}At app startup (renderer/ → renderer/):
import { initBackend } from "@takazudo/backend-bridge";
import { createTauriAdapter } from "@takazudo/backend-bridge/tauri-adapter";
// Inside bootstrapTauri():
initBackend(createTauriAdapter());In tests or Storybook:
import { initBackend } from "@takazudo/backend-bridge";
import { createMockAdapter } from "@takazudo/backend-bridge/mock-adapter";
const { api, controls } = createMockAdapter();
initBackend(api);
// Use controls to trigger events and inspect stateIn REST dev mode (renderer/):
import { initBackend } from "@takazudo/backend-bridge";
import { createRestAdapter } from "@takazudo/backend-bridge/rest-adapter";
initBackend(createRestAdapter("http://localhost:3001"));Tauri Adapter
The Tauri adapter (tauri-adapter.ts) maps each BackendAPI method to a Tauri invoke() call:
messages: {
list: () => invoke<MessageMeta[]>("messages_list", { includeBody: false }),
read: (filename) => invoke<string | null>("messages_read", { filename }),
// ...
}The auth domain uses the Better Auth system-browser handoff in better-auth-desktop.ts: a per-app deep link returns a single-use OTT, which is exchanged for the sliding session and a short-lived service JWT. The cloudSync domain uses the real cloud-sync bridge (cloud-sync-bridge.ts). Both are fully implemented.
Event listeners use a syncListen helper that wraps Tauri's async listen() to return a synchronous unsubscribe function:
onChanged: (callback) =>
syncListen<{ filename?: string }>("messages:changed", (payload) => {
callback(payload.filename);
}),How invoke() Maps to Rust Commands
Each invoke("command_name", { args }) call maps to a #[tauri::command] function in the Rust backend:
| TypeScript | Rust |
|---|---|
invoke("settings_get") | commands::settings::settings_get |
invoke("files_read_text", { path }) | commands::files::files_read_text |
How listen() Maps to Tauri Events
Event names emitted from Rust map to on* callbacks:
| Event Name | BackendAPI Callback | Emitted When |
|---|---|---|
messages:changed | messages.onChanged | Development REST fallback archive changes |
notes:changed | notes.onChanged / inbox.onChanged | Development REST fallback Note Tray changes |
draft:externalChange | inbox.onExternalChange | Development REST fallback active-note changes |
files:externalChange | files.onExternalChange | A local file open in External File Editor changes |
Mock Adapter
The mock adapter (mock-adapter.ts) provides an in-memory implementation and a MockControls object:
const { api, controls } = createMockAdapter();
// Manipulate state
controls.files.set("test.md", "# Hello");
// Trigger events
controls.triggerMessagesChanged("test.md");
controls.triggerFilesExternalChange("/tmp/test.md");The MockAdapter simulates auth and cloudSync for dev:mock testing.
REST Adapter
The REST adapter (rest-adapter.ts) talks to the same Rust backend over HTTP instead of Tauri IPC. When cargo tauri dev runs, the Rust backend also starts an axum HTTP server on port 3001 that exposes the same command handlers as HTTP endpoints.
import { initBackend } from "@takazudo/backend-bridge";
import { createRestAdapter } from "@takazudo/backend-bridge/rest-adapter";
initBackend(createRestAdapter("http://localhost:3001"));Transport Mapping
The REST adapter maps BackendAPI methods to HTTP and SSE:
| BackendAPI method | Transport | Example |
|---|---|---|
messages.list() | GET /api/messages | Returns JSON array |
messages.read(filename) | GET /api/messages/:filename | Returns file content |
messages.write(filename, body) | PUT /api/messages/:filename | Writes file |
messages.onChanged(cb) | SSE / | Pushes messages:changed events |
Commands use standard HTTP methods (GET, POST, PUT, DELETE). Events use a single SSE connection that multiplexes all event types — the same events that Tauri emits via listen() are delivered as SSE messages with a type field.
The auth and cloudSync domains are implemented in RestAdapter via the Better Auth browser handoff (better-auth-browser.ts) and cloud-sync bridge calls.
Publish boundary and external links
The publish domain is a typed boundary around the publish Worker, not a best-effort slug helper. PublishedPage.publishRevision is the owner-scoped compare-and-swap revision. Normal publishPage requests can return the versioned publish_decision_required response; the bridge exposes the explicit replacePublishedPage and publishAdditionalPage methods for the two intents. Replace carries an explicit page ID, source pair, expected revision, and idempotency key. Additional creation carries an explicit unique slug and the same immutable content/source context. The client validates decision, stale, idempotency-conflict, and validation_failed response shapes and rejects unknown or status-mismatched bodies as the fixed publish_request_failed error, so arbitrary Worker prose never becomes UI text. See Publish and API Tokens for the full route and CAS contract.
BackendAPI.window.openExternalUrl(url) is the only bridge path for published page links and other external browser handoffs. Every adapter first accepts only an absolute, credential-free http: or https: URL and preserves the approved string exactly. Relative, protocol-relative, file:, javascript:, data:, mailto:, and URLs containing a username or password are rejected before any host call.
Tauri:
TauriAdaptercalls@tauri-apps/plugin-openerso the operating system opens its default browser. It does not create a TauriWebviewWindowor navigate the app's WebView.REST/browser and mock:
RestAdapterandMockAdaptercallwindow.open(approvedUrl, "_blank", "noopener,noreferrer"). A blocked popup returns a fixed failure rather than silently succeeding.
Renderer actions catch opener failures and show a fixed, non-sensitive message such as Could not open the published page. Try again. They do not render native exception text, credentials, or the rejected URL as an error detail.
When to Use
REST mode is ideal when you need full Chrome DevTools while working with real data. See the REST Dev Mode section in the development workflow docs for setup instructions.