The pyrpc playground at /demo lets you write Python server code with @rpc and @model decorators, then call those procedures from TypeScript — all in the browser, with real-time autocomplete and type checking, no server required.
This post explains how the sandbox works under the hood, the key design decisions that shaped it, and how it compares to the actual pyrpc implementation (v0.1.0-alpha.1 across all packages).
Architecture Overview
The playground processes user code in three phases:
- Type Generation — Python server code is parsed client-side (regex-based) to extract
@modelclass fields and@rpcfunction signatures, then converted to TypeScript declarations. - Monaco Integration — The generated types are injected into Monaco Editor as virtual files at
/node_modules/@pyrpc/types/index.d.ts, enabling real-time autocomplete and type errors onclient.*calls. - Sandbox Execution — When the user clicks Run, client calls are extracted via regex, dispatched to a local API endpoint that parses the server code to construct mock return values, and the results are fed through a simulated
console.log.
Phase 1: Client-Side Type Generation
The type pipeline lives in docs/lib/parsePythonTypes.ts. Three functions form the core:
parseServerCode(code)— Uses regex to find@modelclasses and@rpcfunctions. Extracts field names and types for models, parameter names/types and return type for procedures.introspectionToTypes(schema)— Converts the parsed schema into TypeScript declaration strings. Python types likeintbecomenumber,list[X]becomesX[], model names reference the generated interfaces.validateServerCode(code)— Detects unknown decorators (e.g.@procedure), missing return type annotations, and empty model classes.
The output is a TypeScript source string containing export interface User { ... } and export interface Types { get_user(id: number): Promise<User>; ... }.
Phase 2: Monaco Editor Integration
The critical design decision was how to feed these types into Monaco so that TypeScript's compiler can see them. Monaco provides setExtraLibs for declaring ambient types, but we found that files added via setExtraLibs are not visible to TypeScript's module resolution — import type { Types } from "@pyrpc/types" would fail to resolve.
The solution: use monaco.editor.createModel() to create actual editor models at the exact file paths TypeScript expects:
/node_modules/@pyrpc/types/index.d.ts— Generated type declarations (updated live as server code changes)/node_modules/@pyrpc/client/index.d.ts— Static client SDK stubs (createClient,PyRPCClient, etc.)
The client editor model is set with path="/model.ts" so that TypeScript's NodeJs module resolution finds the /node_modules/... models. After updating the types model, a no-op edit (pushEditOperations) forces the TypeScript worker to re-evaluate the program, updating diagnostics and completion data immediately — at compile time, not after clicking Run.
The Omit<PyRPCClient, 'rpc'> type in the stubs hides the internal .rpc property from autocomplete, so client. shows only user-defined methods.
Phase 3: Mock Sandbox Execution
When the user clicks Run, the playground:
- Extracts client calls —
parseClientCallsuses a globalmatchAllregex to find everyclient.method(...)call in the client code. - Dispatches to mock API — A lightweight proxy (
createSandboxClient) sends each call as a POST to/api/sandbox/rpcwith the server code in a header. - Parses server code — The mock endpoint re-parses the
@rpcfunction signatures and@modelfields, then builds mock return values:
- For model return types: creates an object with default-typed fields (e.g.,
strbecomes"Sample fieldName"), overrides with matching parameter values, then overrides with return value literals parsed from the function body (e.g.return User(id=id, name="Core User")setsnameto"Core User"). - For primitive return types: returns sensible defaults (
int=42,str="mock_result",any= computed from input).
Finally, simulateConsoleLogs processes each console.log(...) call — supporting template literals (Hello ${name}), typeof() expressions, dot-path resolution (user.name), and proper handling of nested parentheses — and displays the output in a theme-aware terminal.
Live Validation
The server editor validates code on every keystroke via a useEffect that calls validateServerCode. Detected issues are surfaced as Monaco error markers (red underlines) and also block the Start Server button:
- Unknown decorators — Any
@namethat isn't@rpcor@modeltriggers an error. - Missing return type —
@rpc def foo()without a return type annotation is flagged. - Empty model — A
@modelclass with no typed fields is flagged.
This validation mirrors what pyrpc-core would enforce at runtime, but shifted to edit-time for immediate feedback.
Comparison with the Real pyrpc (v0.1.0-alpha.1)
The sandbox simulates pyrpc's behaviour, but there are fundamental differences between the demo and the real framework:
Type System
| Aspect | Sandbox | Real pyrpc |
|---|---|---|
| Type parsing | Client-side regex | Python AST via pyrpc-codegen |
| Supported types | int, str, bool, float, list[X], dict[K,V], Optional[X], Union[X,Y] | Full Python type system via Pydantic TypeAdapter |
| Type validation | None (skip) | Pydantic TypeAdapter.validate_python() on every call |
| Models | Parsed fields only | Pydantic dataclasses with full validation, defaults, nesting |
Execution Model
| Aspect | Sandbox | Real pyrpc |
|---|---|---|
| Runtime | None (mock results) | Python — ASGI/Flask/FastAPI server |
| Procedure execution | Regex-parsed signature + mock defaults | Actual Python function call with Pydantic-validated params |
| Return values | Constructed from return literals in body | Actual function return value, validated by TypeAdapter |
| Transport | Next.js API route (/api/sandbox/rpc) | PyRPCAsgiApp / mounted on FastAPI/Flask |
| Adapter pattern | Template shows mount_fastapi(app) but ignored | mount_fastapi(app) registers actual HTTP routes |
Protocol & Error Handling
| Aspect | Sandbox | Real pyrpc |
|---|---|---|
| Protocol | JSON-RPC 2.0-like (simplified) | JSON-RPC 2.0 with request/response envelopes |
| Error codes | Basic HTTP + -32600/-32601/-32603 | Full JSON-RPC 2.0 error codes + Pydantic validation errors |
| Introspection | Client-side regex (sync, instant) | Server-side GET /rpc endpoint returning full schema |
Client SDK
| Aspect | Sandbox | Real pyrpc |
|---|---|---|
| Implementation | Inline Proxy + fetch in page.tsx | @pyrpc/client npm package with full Proxy-based API |
| Parameter passing | Single arg = positional; object = named | Single non-array object = named params; array = positional |
Key Design Decisions
createModel over setExtraLibs
setExtraLibs files don't participate in module resolution. By creating real editor models at the resolved file paths, TypeScript's NodeJs resolution finds them correctly when the user types import type { Types } from "@pyrpc/types".
No Piston API
The original design used Piston (a remote code execution API) to run Python in a sandboxed container. Piston requires authentication and adds latency. The regex-based mock approach is instant, offline-capable, and sufficient for a type-first demo.
Return value literal parsing
Rather than hardcoding return values per template, the sandbox parses the actual return statement (return User(id=id, name="Core User")) to extract literal values. This makes it work with any user-defined server code without special-casing templates.
No external npm dependencies for the sandbox
The sandbox avoids depending on @pyrpc/client at build time by inlining a minimal proxy. This prevents resolution issues during Vercel deployment where workspace packages aren't available. The proxy mirrors the real client's parameter handling (positional vs named) so the demo accurately reflects the real developer experience.