← Back to Blog

Multi-client support: one Python server, many frontends

·9 min read

Up to v0.10.x, pyrpc.json pointed at exactly one output path. One Python server, one frontend, one __pyrpc.d.ts. That was fine for the tutorial shape of things, but it broke down the moment a real project grew a second consumer, a marketing site, an admin panel, a dashboard built by a different team.

v0.11.0 makes the client a plural concept. pyrpc.json now stores one or more client project roots, and every command in the CLI , dev, watch, codegen, and the watcher’s regen callback, regenerates types for all of them.

One config field, two shapes

The config can hold a single client or a list. The two shapes are interchangeable, and the CLI normalizes them through one function so the rest of the code never has to care which one you wrote:

def _get_clients(cfg: dict) -> list[str]:
 """Normalizes the configuration to a list of client paths."""
 if "clients" in cfg:
 return cfg["clients"]
 elif "client" in cfg and cfg["client"]:
 return [cfg["client"]]
 return []

A single-frontend project keeps the simple shape:

{
 "module": "server",
 "framework": "FastAPI",
 "client": "./frontend"
}

A multi-frontend project uses the list form:

{
 "module": "server",
 "framework": "Mixed",
 "clients": ["./frontend", "./admin"]
}

Note the framework field becomes "Mixed" when the wizard configures several clients at once, the value is informational, a record of what was detected at setup time, not a decision the CLI enforces later.

The regen loop became a loop

Previously the CLI had one hard-coded output path threaded through every call site. v0.11.0 replaces that with _regenerate_clients: a single loop that owns the per-client work of running codegen and configuring each client’s tsconfig:

def _regenerate_clients(module: str, client_dirs: list[str], *, reload: bool = False) -> int:
 """Generate types for every configured client and configure each tsconfig."""
 from pyrpc_core.tsconfig import configure_tsconfig
 n = 0
 for client_dir in client_dirs:
 output_path = os.path.abspath(os.path.join(client_dir, "__pyrpc.d.ts"))
 n = _run_codegen(module, output_path, reload=reload)
 try:
 configure_tsconfig(client_dir)
 except Exception as e:
 console.print(f"[yellow]⚠ Could not configure tsconfig in {client_dir}: {e}[/yellow]")
 return n

Three details here matter. First, the output path is derived from the client root, never stored, there is exactly one place that knows the convention (<client>/__pyrpc.d.ts). Second, codegen and tsconfig configuration travel together, because a client without its @pyrpc/types alias is a client that still resolves the published package instead of your generated file. Third, a tsconfig failure is a warning, not a crash: a broken tsconfig.json on one client should not stop types being generated for the others.

Every caller went through the same door

Three different entry points needed to do the same thing, generate types for all clients on startup or change, and they all now call _regenerate_clients:

  • dev, after importing the module on startup, and again whenever the debounced regen callback fires on a .py change.
  • watch, once at startup, then through the same regen callback for the lifetime of the process.
  • the regen callback, _do_regen, shared by both of the above.

The startup messages differ slightly by count, types generated (2 procs) → ./frontend for one client versus types generated (2 procs) for 2 clientsfor several, but the work is identical, so behavior can’t drift between commands.

Live re-wiring when config changes

Because clients are now a list, the pyrpc.json watcher compares lists rather than a single path. When you edit the config to add a third frontend while devis running, the watcher detects new_client_dirs != client_dirs, re-wires the regen callback with the new list, and regenerates immediately:

new_module = new_cfg.get("module", module)
new_client_dirs = _get_clients(new_cfg)

module_changed = new_module != module
output_changed = new_client_dirs != client_dirs

if not module_changed and not output_changed:
 continue

console.print(" [blue]pyrpc.json changed, reloading...[/blue]")

if output_changed:
 client_dirs = new_client_dirs
 console.print(f" [dim]clients → {client_dirs}[/dim]")

No restart required to pick up a new client. The same mechanism already handled module changes by restarting uvicorn when pyRPC owns the server; the list form simply extends that to arbitrarily many frontends.

What this unlocks

Multi-client support is the difference between a framework that fits the example repo and one that fits a real codebase:

  • Separate teams, one API, a backend team ships the same typed contract to a web app and an admin tool without maintaining two configs or two servers.
  • Monorepo symmetry, every frontend in packages/* or apps/* gets its own committed __pyrpc.d.ts, tracked in git like any other source file.
  • Consistent CI, codegen --client and watch --client normalize to the same path convention, so a CI job can regenerate a single client without touching the others.

The model is deliberately simple: a client root is just a directory, and a generated file is just __pyrpc.d.ts inside it. Nothing in the system needs to know what framework lives there, that was only ever needed to pick an output path, and the path is no longer a choice.

Read the full changelogfor the complete list of changes.