Architecture
How the magic happens under the hood.
Architecture
pyRPC is designed to be as "invisible" as possible. To achieve this, it relies on a simple multi-tier architecture: Registry, Introspection, Config, Distribution, and Contract.
1. The Registry (Source of Truth)
When you use the @rpc decorator, you are adding your function to a central Registry.
@rpc
def add(a: int, b: int) -> int: ...The registry captures the function's signature using Python's inspect module and Pydantic's TypeAdapter. It knows exactly what inputs it expects and what output it promises.
2. The Introspection Engine
The registry is connected to an Introspection Engine. This engine can turn the Python signatures into a structured JSON schema at any time.
This engine is exposed via the GET /rpc endpoint. It allows tools (and other pyRPC clients) to "see" your backend as if it were a typed library.
3. Project Configuration (pyrpc.json)
pyRPC stores project settings in a dedicated pyrpc.json file:
{
"version": 1,
"framework": "fastapi",
"entrypoint": "main",
"client_root": "../frontend",
"distribution": "workspace"
}- framework: The web framework adapter to use (fastapi, flask, asgi)
- entrypoint: The Python module to scan for
@rpcprocedures - client_root: Relative path to the TypeScript client project
- distribution: How types are delivered to the client -
workspace(monorepo) orserver(remote)
All paths are resolved relative to pyrpc.json's directory at config load time. The types output path is derived automatically from client_root.
The config file is created by pyrpc dev and can be updated with --reconfigure or individual flags.
4. Distribution
How TypeScript types are delivered to the client depends on the distribution mode. This is configured via the distribution field in pyrpc.json.
Workspace Mode (Monorepo)
When backend and frontend live in the same repository, the server writes generated types directly into the client's node_modules:
┌──────────────┐ writes types ┌─────────────────┐
│ Python Dev │ ──────────────────→ │ node_modules/ │
│ pyrpc dev │ │ @pyrpc/types │
└──────────────┘ └─────────────────┘- Types are regenerated automatically on every file change (300ms debounce).
- No network round-trip - the frontend sees updates instantly.
- The TypeScript client imports types from
@pyrpc/typesas if they were a regular npm package.
Server Mode (Remote / Separate Repos)
When backend and frontend are in different repositories, the server exposes the schema at GET /rpc and the client fetches it on demand:
┌──────────────┐ GET /rpc (schema) ┌─────────────────┐
│ Python Dev │ ←────────────────── │ npx pyrpc sync │
│ pyrpc dev │ (HTTP fetch) │ @pyrpc/client │
└──────────────┘ └─────────────────┘- The server never touches the client's filesystem.
- Frontend developers run
npx pyrpc syncto pull the latest schema and regenerate types. - The
@pyrpc/clientpostinstall script prompts for the server URL and distribution mode on first install.
How It Works Under the Hood
Both modes use the same introspection schema (from GET /rpc). The difference is who initiates the sync:
- Workspace:
pyrpc devcallssave_typescript_client()on regeneration, writing directly to the resolvedclient_root. - Server:
pyrpc devonly updates the in-memory schema. The client'snpx pyrpc syncfetches it over HTTP and runssave_typescript_client()locally.
This means you can switch between modes without changing your Python code - only the distribution field and the client's setup process differ.
5. Contract Synchronization
The final tier is the Contract. This is the bridge to TypeScript.
Instead of generating a bulky SDK with custom classes and logic, pyrpc codegen (or pyrpc dev) fetches the introspection schema and translates it into TypeScript interface and type definitions.
Why this works:
- Decoupled: Your server doesn't need to know about TypeScript.
- Fast: The client runtime is tiny because the "logic" is just standard fetch calls.
- Accurate: The types are generated directly from your running Python code.
The Journey of a Request
- Client: Calls
client.add(1, 2). - Runtime: Packages the call into a JSON-RPC 2.0 object.
- Transport: Sends a
POST /rpcto the server. - Adapter: (FastAPI/Flask) receives the request and passes it to pyRPC.
- Interpreter: Validates the parameters via Pydantic, calls the Python function, and catches any errors.
- Response: The result is packaged and sent back to the client.
By understanding this flow, you can see why pyRPC is so reliable - there is no manual translation layer where types can drift.

pyRPC