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 /rpcintrospection 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:
- 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.
- 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.
- 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:
- A
pyrpc-client.jsonfile in the project root, created duringnpm install @pyrpc/client. This file stores the distribution mode and, for server mode, the server URL. npx pyrpc sync, which readspyrpc-client.json, fetchesGET {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/clientand usesnpx pyrpc syncto pull types. No shared filesystem. - Published npm package: In CI, after deploying the server, run
npx pyrpc sync --server-url https://api.example.comand publish the resulting@pyrpc/typesto 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 typesServer 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.

pyRPC