pyRPC
Concepts

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 @rpc procedures
  • client_root: Relative path to the TypeScript client project
  • distribution: How types are delivered to the client - workspace (monorepo) or server (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/types as 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 sync to pull the latest schema and regenerate types.
  • The @pyrpc/client postinstall 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 dev calls save_typescript_client() on regeneration, writing directly to the resolved client_root.
  • Server: pyrpc dev only updates the in-memory schema. The client's npx pyrpc sync fetches it over HTTP and runs save_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

  1. Client: Calls client.add(1, 2).
  2. Runtime: Packages the call into a JSON-RPC 2.0 object.
  3. Transport: Sends a POST /rpc to the server.
  4. Adapter: (FastAPI/Flask) receives the request and passes it to pyRPC.
  5. Interpreter: Validates the parameters via Pydantic, calls the Python function, and catches any errors.
  6. 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.