zudo-tauri-wisdom

Type to search...

to open search from anywhere

Backend Bridge / Adapter Pattern

CreatedApr 3, 2026Takeshi Takatsudo

A swappable backend abstraction layer enabling three development modes with a single frontend codebase

Backend Bridge / Adapter Pattern

The backend bridge is an abstraction layer that decouples the frontend from a specific communication mechanism. By swapping adapters, the same React frontend can run against Tauri IPC (production), an in-memory mock (development/testing), or an HTTP REST API (hybrid development).

Why an Abstraction Layer?

Tauri apps typically call invoke() directly from components. This works, but creates tight coupling:

  • Cannot test without Tauri — Unit tests and Storybook stories cannot call invoke() because there is no Rust backend running
  • Cannot develop frontend-only — Changing a React component requires starting the full Rust build pipeline
  • Cannot use alternative transports — No way to debug the frontend against a running backend over HTTP

The backend bridge solves all three problems by defining a single BackendAPI interface that adapters implement.

The BackendAPI Interface

The interface defines every operation the frontend needs, organized by domain:

export interface BackendAPI {
  messages: {
    list: () => Promise<MessageMeta[]>;
    read: (filename: string) => Promise<string | null>;
    write: (filename: string, content: string) => Promise<boolean>;
    create: (name: string, content: string) => Promise<string>;
    onChanged: (callback: (filename?: string) => void) => () => void;
    // ...
  };
  pins: {
    list: (pinIndex: number) => Promise<PinEntry[]>;
    read: (pinIndex: number, entryPath: string) => Promise<string | null>;
    // ...
  };
  terminal: {
    spawn: (cwd?: string) => Promise<string>;
    write: (id: string, data: string) => Promise<void>;
    onData: (callback: (id: string, data: string) => void) => () => void;
    // ...
  };
  draft: { /* ... */ };
  drafts: { /* ... */ };
  settings: {
    get: () => Promise<AppSettings | null>;
    save: (settings: AppSettings) => Promise<boolean>;
  };
  // ...more domains
}

Every method returns a Promise (commands) or returns a cleanup function (event listeners). This uniform contract makes adapters interchangeable.

Initialization and Access

The bridge uses a simple singleton pattern:

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;
}

The app’s entry point calls initBackend() once with the appropriate adapter. All components call getBackend() to access the API.

The Three Adapters

TauriAdapter — Native IPC

Used in production (pnpm tauri:dev). Maps every BackendAPI method to a Tauri invoke() call:

export function createTauriAdapter(): BackendAPI {
  return {
    messages: {
      list: () => invoke<MessageMeta[]>("messages_list", { includeBody: false }),
      read: (filename) => invoke<string | null>("messages_read", { filename }),
      write: (filename, content) =>
        invoke<boolean>("messages_write", { filename, content }),
      onChanged: (callback) =>
        syncListen<{ filename?: string }>("messages:changed", (payload) => {
          callback(payload.filename);
        }),
      // ...
    },
    // ...
  };
}

Event listeners use a syncListen helper that wraps Tauri’s async listen() to return a synchronous cleanup function, matching the BackendAPI contract:

function syncListen<T>(
  event: string,
  handler: (payload: T) => void,
): () => void {
  let unlistenFn: (() => void) | null = null;
  let cancelled = false;

  listen<T>(event, (e) => handler(e.payload))
    .then((fn) => {
      if (cancelled) fn();
      else unlistenFn = fn;
    });

  return () => {
    cancelled = true;
    unlistenFn?.();
  };
}

MockAdapter — In-Memory

Used for frontend-only development (pnpm dev:mock) and Storybook/testing. Implements the full API using in-memory Map objects:

export function createMockAdapter(options?: MockAdapterOptions): {
  api: BackendAPI;
  controls: MockControls;
} {
  const files = new Map<string, string>();
  const pinFiles = new Map<string, string>();
  // ...seed data...

  const api: BackendAPI = {
    messages: {
      list: async () => { /* sort and return from files Map */ },
      read: async (filename) => files.get(filename) ?? null,
      write: async (filename, content) => { files.set(filename, content); return true; },
      // ...
    },
    // ...
  };

  const controls: MockControls = {
    triggerMessagesChanged: (...args) => messagesChanged.emit(...args),
    files,
    // ...
  };

  return { api, controls };
}

💡 Tip

The mock adapter returns a controls object alongside the API. Tests use controls to trigger events (e.g., controls.triggerMessagesChanged()) and inspect internal state (e.g., controls.files). This makes it easy to simulate backend-initiated changes like file watcher notifications.

The mock adapter seeds realistic data (sample messages, a pin tree, font list) so the UI looks populated during development.

RestAdapter — HTTP/SSE

Used for hybrid development (pnpm dev:rest). Maps commands to HTTP requests and events to Server-Sent Events:

export function createRestAdapter(baseUrl = "http://localhost:3001"): BackendAPI {
  async function getJson<T>(path: string): Promise<T> {
    const res = await fetch(`${baseUrl}${path}`);
    if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`);
    return res.json();
  }

  function sseListener(eventType: string, callback: (data: unknown) => void): () => void {
    const es = getSharedEventSource();
    const handler = (e: MessageEvent) => callback(JSON.parse(e.data));
    es.addEventListener(eventType, handler);
    return () => {
      es.removeEventListener(eventType, handler);
      releaseSharedEventSource();
    };
  }

  return {
    messages: {
      list: () => getJson<MessageMeta[]>("/api/messages"),
      read: (filename) => getText(`/api/messages/${encodeURIComponent(filename)}`),
      onChanged: (callback) =>
        sseListener("messages:changed", (data) =>
          callback((data as { filename?: string }).filename)
        ),
      // ...
    },
    // ...
  };
}

The REST adapter uses a shared EventSource with reference counting — multiple listeners share one SSE connection, and it closes when the last listener unsubscribes.

📝 Note

Some features are inherently native and cannot work over HTTP. The REST adapter stubs these with warnings:

  • terminal.* — PTY sessions cannot be forwarded over HTTP
  • dialog.openDirectory / dialog.openFile — Native OS dialogs require Tauri
  • workspace.setDir — Requires native directory picker

Development Modes Enabled

ModeCommandAdapterBackendUse Case
Full Tauripnpm tauri:devTauriAdapterRust (IPC)Production parity
Mockpnpm dev:mockMockAdapterNoneFrontend-only, Storybook, tests
RESTpnpm dev:restRestAdapterRust (HTTP)Debug with real data, no recompile for frontend changes

Key Design Decisions

  1. Promise-based commands, callback-based events — Commands are one-shot request/response pairs. Events are long-lived subscriptions. The interface reflects this difference.

  2. Event listeners return cleanup functions — Following React’s useEffect cleanup pattern, every on* method returns an unsubscribe function. This prevents memory leaks when components unmount.

  3. Adapter is set once at startup — No runtime switching. The Vite config determines which adapter the build uses, keeping the runtime code simple.

  4. Mock adapter has full fidelity — It implements collision handling, frontmatter parsing, and sort ordering to match the real backend. This catches integration bugs early.