zudo-text

検索したい単語を入力

いつでも検索バーを開ける

Backend Bridge Pattern

Problem

The frontend needs a way to communicate with the backend that works across multiple contexts:

  1. Production — The app runs inside Tauri and calls Rust commands via IPC

  2. Mock dev mode — Frontend-only dev server with no Tauri runtime (pnpm dev:mock)

  3. REST dev mode — Frontend in Chrome, talking to the real Rust backend over HTTP (pnpm dev:rest)

  4. 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 via listen().

  • 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/backend-bridge/src/types.ts 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/main.tsxrenderer/bootstrap/tauri.tsx):

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 state

In REST dev mode (renderer/bootstrap/web.tsx):

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:

TypeScriptRust
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 NameBackendAPI CallbackEmitted When
messages:changedmessages.onChangedDevelopment REST fallback archive changes
notes:changednotes.onChanged / inbox.onChangedDevelopment REST fallback Note Tray changes
draft:externalChangeinbox.onExternalChangeDevelopment REST fallback active-note changes
files:externalChangefiles.onExternalChangeA 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 methodTransportExample
messages.list()GET /api/messagesReturns JSON array
messages.read(filename)GET /api/messages/:filenameReturns file content
messages.write(filename, body)PUT /api/messages/:filenameWrites file
messages.onChanged(cb)SSE /api/eventsPushes 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.

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: TauriAdapter calls @tauri-apps/plugin-opener so the operating system opens its default browser. It does not create a Tauri WebviewWindow or navigate the app's WebView.

  • REST/browser and mock: RestAdapter and MockAdapter call window.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.

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.