tRPC Migration Design

Date: 2026-04-17 Status: Approved (brainstorm) Scope: Replace the JSON REST API under /api/* with tRPC procedures. Keep the OpenAI-compatible /v1/* chat server, OAuth, MCP, health probes, admin, binary/stream downloads, and /api/nodes/metadata as REST.

Goals

Non-goals

Scope

In scope (migrate to tRPC)

JSON request/response endpoints under:

Stays REST (unchanged)

Architecture

Server

Router code lives in packages/websocket/src/trpc/:

trpc/
  index.ts              # initTRPC with superjson transformer + errorFormatter; exports publicProcedure/protectedProcedure
  context.ts            # createContext({ req, apiOptions, registry, pythonBridge }) → Context
  error-formatter.ts    # TRPCError ↔ ApiErrorCode mapping
  middleware.ts         # protectedProcedure enforcing ctx.userId !== null
  router.ts             # appRouter composing sub-routers; export type AppRouter
  routers/
    workflows.ts
    assets.ts
    jobs.ts
    messages.ts
    threads.ts
    nodes.ts
    settings.ts
    users.ts
    workspace.ts
    files.ts
    costs.ts
    skills.ts
    collections.ts
    models.ts
    mcp-config.ts
    storage.ts

Each router file absorbs both the current routes/<domain>.ts plugin and the logic from the matching *-api.ts helper. The 2.9k-line http-api.ts is decomposed across these files; functions that still serve REST-staying routes (binary/stream/metadata) remain in a slimmed-down http-api.ts.

Mounted onto the existing Fastify instance at prefix /trpc via @trpc/server/adapters/fastify:

import { fastifyTRPCPlugin } from "@trpc/server/adapters/fastify";
await app.register(fastifyTRPCPlugin, {
  prefix: "/trpc",
  trpcOptions: { router: appRouter, createContext }
});

The existing onRequest auth hook runs before tRPC and sets req.userId. createContext simply reads it — no duplicate auth logic.

Schemas

Zod schemas live in @nodetool/protocol/src/api-schemas/ (zod is already a dep of the protocol package), one file per domain. Each file exports the input and output schemas for that domain’s procedures. Server procedures import them for validation; client code imports them for form validation where useful.

packages/protocol/src/api-schemas/
  workflows.ts
  assets.ts
  jobs.ts
  ...
  index.ts  # re-exports

Complex entity types (Workflow, Asset, Job) already have TypeScript types in @nodetool/protocol or @nodetool/models. Zod schemas may reference these via z.custom<T>() where full runtime parsing is overkill, or be defined from scratch where input shape differs from the entity.

Transport and serialization

Error handling

Custom errorFormatter on the server extends shape.data with:

Procedure bodies call throwApiError(ApiErrorCode.X, "message", trpcCode?) — a small helper that constructs a TRPCError with the apiCode attached to cause. The tRPC errorFormatter reads cause.apiCode and surfaces it on shape.data.apiCode. This is a helper-at-call-site pattern (not a middleware) — explicit at the point the error is raised, no magic error-to-code translation layer.

Client helper isTRPCErrorWithCode(err, ApiErrorCode.X) for call sites that branch on specific errors.

Context

interface Context {
  userId: string | null;
  registry: NodeRegistry;
  apiOptions: HttpApiOptions;
  pythonBridge: PythonStdioBridge;
  getPythonBridgeReady: () => boolean;
}

Client integration

Web (web/src/trpc/):

Mobile (mobile/src/trpc/): same pattern as web. Deletes mirror web’s.

CLI (packages/cli):

Electron main (electron/src/api.ts): vanilla createTRPCClient same as CLI. ~5 call sites migrate.

AppRouter type export

packages/websocket/package.json exposes a subpath for the router, following the repo’s existing export-condition pattern (nodetool-dev for source, types for declarations, import/default for built dist):

"exports": {
  ".": { ... current ... },
  "./trpc": {
    "nodetool-dev": "./src/trpc/router.ts",
    "types": "./src/trpc/router.ts",
    "import": "./dist/trpc/router.js",
    "default": "./dist/trpc/router.js"
  }
}

Clients use import type { AppRouter } from "@nodetool/websocket/trpc" — type-only import, no runtime coupling (TypeScript erases type-only imports at build). The router module is structured so nothing at its top level executes on import; all procedures and sub-routers are declared as values inside the appRouter builder, which is tree-shakable.

Data flow

client hook (useQuery)
  → httpBatchLink (batches sibling queries into one POST /trpc/…)
  → superjson encode
  → Fastify onRequest hook → sets req.userId from token
  → @trpc/server/adapters/fastify
  → createContext({ req, apiOptions, registry, pythonBridge })
  → zod .input() parse (400 on failure, with zodError in data)
  → procedure body
  → result
  → superjson encode
  → client: typed result, Date/Map/Set preserved

Migration ordering (within the big-bang branch)

  1. Foundation — add @trpc/* deps, create trpc/ skeleton, createContext, errorFormatter, Fastify mount, protocol api-schemas/ skeleton, superjson.
  2. Pilot domain (costs) end-to-end — smallest server-side surface (cost-api.ts is 128 lines). Server router, protocol schemas, one web hook, one web call site. Verify transport, auth, error formatter, superjson, TanStack Query integration all work. This is the pattern the rest follows.
  3. Remaining 15 domains — server-side routers + schemas.
  4. Rewrite web/src/serverState/ onto tRPC hooks. Delete web/src/api.ts. Remove openapi-fetch from web/.
  5. Mobile migration — mirror web.
  6. CLI + Electron main migration.
  7. Deletion sweep — every REST route and *-api.ts helper that only served a migrated endpoint is removed. http-api.ts keeps only what still serves REST-staying routes.
  8. Test pass — every router has vitest coverage via appRouter.createCaller(ctx); a small e2e suite exercises the Fastify transport + auth + error formatter via app.inject().

Testing strategy

Deletion list (end-of-branch)

Server:

Client:

Open questions to resolve during implementation planning

Risks