Skip to main content
The RPC (Remote Procedure Call) layer is the bridge between Yasumu’s Next.js frontend and the Tanxium JavaScript runtime. It provides a type-safe, intuitive API for invoking backend functions from the UI.

Why RPC?

Yasumu could have used REST APIs, GraphQL, or direct Tauri commands for frontend-backend communication. Instead, it implements a custom RPC layer for several reasons:
  1. Type safety: Full TypeScript support from frontend to backend
  2. Developer experience: Auto-completion and compile-time errors
  3. Flexibility: Easy to add new commands without boilerplate
  4. Decoupling: Clear separation between UI and business logic
  5. Testability: Business logic can be tested independently

Architecture overview

RPC package structure

The @yasumu/rpc package defines the RPC interface:
packages/rpc/
├── src/
│   ├── index.ts                      # Main exports
│   ├── platform-bridge.ts            # Bridge interface
│   ├── create-rpc.ts                 # RPC proxy factory
│   ├── rpc-commands.ts               # Command type definitions
│   ├── yasumu-rpc.ts                 # Core RPC types
│   └── rpc-subscription-events.ts    # Subscription events
└── package.json

Platform bridge

The platform bridge is an interface that abstracts the communication mechanism:
// packages/rpc/src/platform-bridge.ts
export interface PlatformBridge {
  invoke<T extends YasumuRpcCommands>(
    context: YasumuRpcContext,
    command: RpcCommandData<T>
  ): Promise<InferReturnType<YasumuRpcCommandMap[T]>>;
}

Implementation (Frontend)

In the Tauri app, the platform bridge makes HTTP requests:
// apps/yasumu/lib/platform-bridge.ts
import { invoke } from '@tauri-apps/api/core';

export const platformBridge: PlatformBridge = {
  invoke: async (context, command) => {
    // Get the RPC server port from Tauri
    const port = await invoke<number>('get_rpc_port');
    
    // Make HTTP request to Tanxium
    const response = await fetch(`http://localhost:${port}/rpc`, {
      method: 'POST',
      headers: { 
        'Content-Type': 'application/json',
        'X-Workspace-Id': context.workspaceId || ''
      },
      body: JSON.stringify({
        command: command.command,
        parameters: command.parameters,
        type: command.type
      })
    });
    
    if (!response.ok) {
      throw new Error(`RPC error: ${response.statusText}`);
    }
    
    const data = await response.json();
    return data.result;
  }
};

RPC proxy

The RPC proxy provides a fluent API for making calls:
// packages/rpc/src/create-rpc.ts
export function createYasumuRPC(
  platformBridge: PlatformBridge,
  context: YasumuRpcContext
): YasumuRPC {
  const commandSegments: string[] = [];
  
  const handler: ProxyHandler<YasumuRPC> = {
    get(target, prop) {
      // Special methods: $mutate and $query
      if (prop === '$mutate' || prop === '$query') {
        return (...args: { parameters: unknown[] }[]) => {
          const command = commandSegments.join('.');
          const type = prop === '$mutate' ? 'mutation' : 'query';
          
          return platformBridge.invoke(context, {
            command,
            parameters: args.flatMap(arg => arg.parameters),
            type,
            isType: (t) => t === command
          });
        };
      }
      
      // Build command path
      commandSegments.push(prop as string);
      return new Proxy({} as YasumuRPC, handler);
    }
  };
  
  return new Proxy({} as YasumuRPC, handler);
}

Usage

The proxy enables intuitive call syntax:
// This call:
const result = await rpc.workspace.list.$query();

// Becomes:
platformBridge.invoke(context, {
  command: 'workspace.list',
  parameters: [],
  type: 'query'
});
The proxy builds the command path dynamically by tracking property accesses.

Command definitions

Commands are defined with full TypeScript types:
// packages/rpc/src/rpc-commands.ts
export interface YasumuRpcCommandMap {
  'workspace.list': {
    parameters: [];
    returns: Promise<Workspace[]>;
  };
  'workspace.create': {
    parameters: [{ name: string; path: string }];
    returns: Promise<Workspace>;
  };
  'rest.execute': {
    parameters: [{ entityId: string }];
    returns: Promise<RestResponse>;
  };
  // ... more commands
}

export type YasumuRpcCommands = keyof YasumuRpcCommandMap;
This provides:
  • Auto-completion in IDEs
  • Type checking at compile time
  • Inline documentation via JSDoc

Queries vs mutations

The RPC layer distinguishes between queries and mutations:

Queries ($query)

  • Read-only operations
  • Can be cached
  • Idempotent (safe to retry)
// Example queries
const workspaces = await rpc.workspace.list.$query();
const entity = await rpc.entity.get.$query({ parameters: [entityId] });

Mutations ($mutate)

  • Write operations
  • Not cached
  • Can have side effects
// Example mutations
const created = await rpc.workspace.create.$mutate({
  parameters: [{ name: 'My Project', path: '/path/to/project' }]
});

const result = await rpc.rest.execute.$mutate({
  parameters: [{ entityId }]
});

Server-side handling

In Tanxium, the RPC server routes commands to handlers:
// packages/tanxium/src/rpc/rpc-server.ts
import { Hono } from 'hono';
import { commandHandlers } from './handlers';

export const rpcServer = new Hono()
  .post('/rpc', async (c) => {
    const { command, parameters, type } = await c.req.json();
    
    // Find handler
    const handler = commandHandlers[command];
    if (!handler) {
      return c.json({ error: `Unknown command: ${command}` }, 404);
    }
    
    // Validate command type
    if (type === 'query' && !handler.isQuery) {
      return c.json({ error: 'Command is not a query' }, 400);
    }
    
    try {
      // Execute handler
      const result = await handler.execute(...parameters);
      return c.json({ result });
    } catch (error) {
      return c.json({ error: error.message }, 500);
    }
  });

Command handlers

// packages/tanxium/src/rpc/handlers/workspace.ts
import { db } from '../../database';
import { workspaces } from '../../database/schema';

export const workspaceHandlers = {
  'workspace.list': {
    isQuery: true,
    execute: async () => {
      return db.select().from(workspaces).all();
    }
  },
  
  'workspace.create': {
    isQuery: false,
    execute: async ({ name, path }) => {
      const [workspace] = await db.insert(workspaces)
        .values({ name, path })
        .returning();
      return workspace;
    }
  }
};

Context passing

The RPC layer supports passing context (like the active workspace):
export interface YasumuRpcContext {
  workspaceId: string | null;
}

// Frontend
const yasumu = new Yasumu({ platformBridge });
await yasumu.initialize();

// RPC calls automatically include context
const entities = await yasumu.rpc.entity.list.$query();
// ^ Uses active workspace from yasumu.getRpcContext()

Server-side context access

// Tanxium receives context in headers
const workspaceId = c.req.header('X-Workspace-Id');

// Use in handler
export const entityHandlers = {
  'entity.list': {
    isQuery: true,
    execute: async (context) => {
      return db.select()
        .from(entities)
        .where(eq(entities.workspaceId, context.workspaceId))
        .all();
    }
  }
};

Subscription events

For real-time updates, the RPC layer supports subscriptions:
// packages/rpc/src/rpc-subscription-events.ts
export interface RpcSubscriptionEvents {
  'rest-entity-updated': {
    workspaceId: string;
    entityId: string;
  };
  'entity-history-updated': {
    workspaceId: string;
    entityId: string;
  };
}

Frontend subscription

// Listen for events
yasumu.onSubscription(async (payload) => {
  if (payload.event === 'rest-entity-updated') {
    // Refetch entity data
    queryClient.invalidateQueries(['entity', payload.data.entityId]);
  }
});

Backend emission

// Emit event after mutation
await db.update(entities)
  .set({ content: newContent })
  .where(eq(entities.id, entityId));

// Notify subscribers
emitEvent('rest-entity-updated', { workspaceId, entityId });

Error handling

The RPC layer provides structured error handling:
try {
  const result = await rpc.entity.create.$mutate({
    parameters: [{ name: '', type: 'rest' }]
  });
} catch (error) {
  if (error instanceof RpcError) {
    console.error(`RPC Error [${error.code}]: ${error.message}`);
  } else {
    console.error('Unexpected error:', error);
  }
}

Error types

export class RpcError extends Error {
  constructor(
    public code: string,
    message: string,
    public details?: unknown
  ) {
    super(message);
  }
}

// Common error codes:
// - VALIDATION_ERROR: Invalid parameters
// - NOT_FOUND: Resource not found
// - PERMISSION_DENIED: Insufficient permissions
// - INTERNAL_ERROR: Server error

Testing

The RPC layer is easy to test by mocking the platform bridge:
// test/mocks/platform-bridge.ts
export const mockPlatformBridge: PlatformBridge = {
  invoke: jest.fn(async (context, command) => {
    // Return mock data based on command
    if (command.command === 'workspace.list') {
      return [{ id: '1', name: 'Test Workspace' }];
    }
    throw new Error(`Unmocked command: ${command.command}`);
  })
};

// In test:
const yasumu = new Yasumu({ 
  platformBridge: mockPlatformBridge 
});

const workspaces = await yasumu.rpc.workspace.list.$query();
expect(workspaces).toHaveLength(1);

Performance considerations

Batching

Multiple RPC calls can be batched:
const [workspaces, entities, history] = await Promise.all([
  rpc.workspace.list.$query(),
  rpc.entity.list.$query(),
  rpc.history.recent.$query()
]);

Caching

TanStack Query caches RPC responses:
const { data } = useQuery({
  queryKey: ['workspace', workspaceId],
  queryFn: () => rpc.workspace.get.$query({ parameters: [workspaceId] }),
  staleTime: 5 * 60 * 1000 // 5 minutes
});

Next steps

Build docs developers (and LLMs) love