pyRPC
← Back to Blog

Server mode: type distribution across repositories

·9 min read

Server mode is for setups where the Python backend and TypeScript frontend live in separate repositories. In this mode, pyrpc never touches the client’s filesystem. Instead, it exposes the current schema over HTTP and lets the client fetch it on demand via npx pyrpc sync.

This post covers how server mode works on the server side: what changes, what doesn’t, and how to think about the deployment architecture.

The config

{
  "version": 1,
  "framework": "fastapi",
  "entrypoint": "app.main",
  "distribution": "server"
}

Notice what’s not here: client_root. In server mode, pyrpc has no client path to write to. It doesn’t need one. The config is smaller because the server’s responsibility is narrower — serve the schema, don’t distribute it.

What stays the same

In server mode, most of pyrpc dev behaves identically:

  • The file watcher still monitors Python files for changes
  • The router still reloads modules on change
  • The GET /rpc introspection endpoint still serves the schema
  • The dev server still starts (unless --types-only)
  • The developer console still works with procs, inspect, etc.

What changes

The difference is in the regenerate() callback:

def regenerate():
    if not _regenerate_lock.acquire(blocking=False):
        return
    try:
        ok = default_router.reload_module(module)
        if not ok:
            console.print("No procedures found ...")
            return
        schemas = get_registry_schema(default_router)
        if resolved_distribution == "server":
            console.print(f"Server mode — "
                f"schema updated ({len(schemas)} procs)")
        else:
            _, save_typescript_client = _lazy_import_codegen()
            save_typescript_client(schemas, types_output)
            console.print(f"Types regenerated ({len(schemas)} procs)")
    except Exception as e:
        console.print(f"Types: {e}")
    finally:
        _regenerate_lock.release()

In server mode, pyrpc still reloads the router and extracts the schema — but it never calls save_typescript_client(). The lazy_import_codegenisn’t even invoked. The in-memory registry is the source of truth; theGET /rpc endpoint reads from it directly.

The console output reflects this:

> pyrpc dev --distribution server  pyRPC dev server  http://127.0.0.1:8000/rpc  Distribution: server (clients fetch via npx pyrpc sync)type help for commandspyrpc>

The schema endpoint

The schema is served at GET /rpc, the same endpoint used for introspection since the earliest versions of pyrpc:

async def handle_introspection(self, send):
    schemas = get_registry_schema(self.router)
    data = {
        name: schema.model_dump() \
            if hasattr(schema, "model_dump") \
            else schema
        for name, schema in schemas.items()
    }
    await self.send_response(send, 200, data)

The response is a JSON object mapping procedure names to their schemas, including parameters, types, docs, and return types. The existing pyrpc codegen http://localhost:8000command already fetches from this endpoint. The upcoming client-side npx pyrpc syncwill do the same, but from the TypeScript side.

The developer console in server mode

The types command in the developer console shows a different message in server mode:

> typesServer mode — clients fetch types via HTTP.  GET /rpc returns the current schema  Run npx pyrpc sync on the client to regenerate types

And the generate command says “Regenerating schema” instead of “Regenerating TypeScript types,” since no files are written.

Why not just always write types?

It would be simpler to always write types, even in server mode — just omitclient_root and write to a default location. Three reasons not to:

  1. The server should not assume client filesystem access. In production, the server and client are different machines. Writing types to a path implies the path is meaningful, but in a deployment it’s not.
  2. Separation of concerns. The server’s job is to serve RPC requests and expose its schema. The client’s job is to consume types. Writing types to the server’s filesystem is a monorepo convenience, not an architectural requirement.
  3. The client controls when to update. In server mode, the client decides when to fetch new types — on deploy, on demand, on a schedule. The server doesn’t push types; the client pulls them.

The client side

For the client to consume types in server mode, it needs two things:

  1. A pyrpc-client.json file in the project root, created during npm install @pyrpc/client. This file stores the distribution mode and, for server mode, the server URL.
  2. npx pyrpc sync, which readspyrpc-client.json, fetches GET {server_url}/rpc, and regenerates @pyrpc/types.

The client-side implementation lives in the @pyrpc/client npm package, which is a separate release track.

Deployment architectures

Server mode supports two deployment patterns:

  • Separate repositories: The backend repo runs pyrpc devin server mode. The frontend repo installs @pyrpc/client and usesnpx pyrpc sync to pull types. No shared filesystem.
  • Published npm package: In CI, after deploying the server, runnpx pyrpc sync --server-url https://api.example.com and publish the resulting @pyrpc/types to npm. Consumers install the package without needing server access.

These are covered in more detail in the three deployment architectures post.

The complete flow

Server:
  pyrpc dev --distribution server
    │
    ├─ Read pyrpc.json
    ├─ Skip client_root entirely
    ├─ Start file watcher
    ├─ Start dev server with GET /rpc
    └─ Open developer console

  On file change:
    watcher → reload module → update in-memory schema

Client:
  npm install @pyrpc/client
    │
    └─ postinstall: prompt distribution, create pyrpc-client.json

  npx pyrpc sync
    │
    └─ Read pyrpc-client.json → fetch GET /rpc → regenerate types

Server mode decouples the server from the client filesystem. It’s more work to set up than workspace mode (you need pyrpc-client.json andnpx pyrpc sync), but it’s the right choice when your backend and frontend are maintained independently.