pyRPC
← Back to Blog

v0.3.3 — Cleaner types, no more /rpc/rpc, quieter watcher, CORS included

·8 min read

v0.3.3 fixes four issues that made the first “hello world” experience bumpier than it should be. No new features, no breaking changes — just the kind of polish that turns a promising tool into something you can hand to someone and say “try it.”

The four bugs

A user tried pyrpc for the first time. They installed the package, wrote amain.py with @rpc and @model, ranpyrpc dev, and ran into four separate issues:

  1. Autocomplete showed rpc as a method.Typing client. in VS Code suggested rpc alongside their procedures — even though accessing client.rpc throws a runtime error that says “Use client.method() instead.” The type system was lying.
  2. The client made requests to /rpc/rpc.The server output displays http://127.0.0.1:8000/rpc. The user naturally copied that as the baseUrl — but the client already appends /rpc internally, so requests went tohttp://127.0.0.1:8000/rpc/rpc and returned 404.
  3. The file watcher flooded the terminal on every save.Every IDE auto-save triggered a regeneration. If the file was caught mid-write, Python’s parser threw syntax errors — and the watcher printed every single one. A single Ctrl+S could produce ten lines of red ✗ Types: invalid syntax before the green✓ Types regenerated.
  4. Browser clients were blocked by CORS.The ASGI transport (used by pyrpc dev) set onlycontent-type on responses. No Access-Control-Allow-Origin, no OPTIONS handler. Cross-origin fetch from a browser frontend was impossible without manually wrapping the app.

Each fix follows the patterns you’d see in tRPC, Better Auth, FastAPI, webpack, and nodemon — no hacks, no reinventions.

1. rpc in autocomplete: the type was telling the wrong story

The client factory returned PyRPCClient & TTypes. ThePyRPCClient class has a public get rpc() that returns a proxy — it’s the mechanism that intercepts method calls and dispatches them as RPC requests. At runtime, createClientwraps the instance in a second proxy that blocks access to .rpcand throws a helpful error. But TypeScript doesn’t know about that.

// Before - type lies to autocomplete
export function createClient<TTypes>(...): PyRPCClient & TTypes {
  return new Proxy(client, {
    get(target, prop) {
      if (prop === 'rpc') throw new Error('Use client.method() instead')
      ...
    }
  })
}

// VS Code sees: { rpc: any } & TTypes → suggests .rpc
// Runtime: client.rpc → Error: "Use client.method() instead"

The fix: return TTypes directly. The class type is an implementation detail — the user’s mental model is “client is my set of RPC procedures,” not “client is a class instance that happens to have an .rpc getter.” This is what tRPC and openapi-fetch both do: their createClient return types map directly to the procedure signatures, never the internal class shape.

// After - type matches user's mental model
export function createClient<TTypes>(...): TTypes {
  // Same runtime proxy, same error on .rpc
  // But TypeScript only knows about TTypes
}

// VS Code sees: TTypes → never suggests .rpc
// client.get_user("Atnatewos") → typed correctly
// client.rpc → TypeScript compile error

If someone was using client.rpc.get_user() directly (which the runtime proxy was already blocking with an error), they’ll now get a compile-time error instead of a runtime error. That’s strictly better. The intended path — client.get_user() — works identically.

2. /rpc/rpc: a three-line URL normalization

The client’s request() method builds the fetch URL as`${this.baseUrl}/rpc`. The constructor only stripped a trailing slash. If the user passed baseUrl: "http://localhost:8000/rpc"(which is exactly what the server output shows), the request went tohttp://localhost:8000/rpc/rpc and the server returned 404.

The fix normalizes the URL in the constructor:

// Before
this.baseUrl = baseUrl.replace(/\/$/, '')

// After - strip trailing /rpc first, then re-append
const clean = baseUrl.replace(/\/+$/, '')
this.url = clean.replace(/\/rpc$/i, '') + '/rpc'

Now both of these work:

createClient({ baseUrl: 'http://localhost:8000' })
  // → http://localhost:8000/rpc  ✓

createClient({ baseUrl: 'http://localhost:8000/rpc' })
  // → strips /rpc, then re-appends → http://localhost:8000/rpc  ✓

Only Better Auth auto-appends a path suffix among major reference projects. tRPC, Axios, Apollo, and openapi-fetch all expect the full URL. But pyrpc’s design decision to simplify the common case (just pass the origin) is worth preserving — as long as the footgun is fixed. URL normalization is the standard approach: strip any existing trailing path segment that matches, then re-append. Better Auth does the same thing with its basePathoption.

3. File watcher: the debounce was missing

The watcher_loop() called regenerate() on every single .py file change. No debounce. IDEs write files in stages during auto-save, so a single save could trigger multiple partial reads — each producing a cascade of ✗ Types: invalid syntax errors before the file settled and regeneration succeeded.

// Before - fires on every file change
def watcher_loop():
    for changes in watch(*watched_dirs, ...):
        if any(f.endswith(".py") for _, f in changes):
            regenerate()

// Output on a single save:
//   ✗ Types: invalid syntax (main.py, line 1)
//   ✗ Types: expected ':' (main.py, line 4)
//   ✗ Types: expected an indented block ...
//   ✓ Types regenerated (1 procs)

The fix adds a 300ms debounce using Python’s standardthreading.Timer:

_regenerate_timer = None
_timer_lock = threading.Lock()
DEBOUNCE_SECONDS = 0.3

def _schedule_regenerate():
    nonlocal _regenerate_timer
    with _timer_lock:
        if _regenerate_timer is not None:
            _regenerate_timer.cancel()     // reset on every new change
        _regenerate_timer = threading.Timer(DEBOUNCE_SECONDS, regenerate)
        _regenerate_timer.daemon = True
        _regenerate_timer.start()

def watcher_loop():
    for changes in watch(*watched_dirs, ...):
        if any(f.endswith(".py") for _, f in changes):
            _schedule_regenerate()         // debounced, not direct

This is the same pattern webpack uses (watchOptions.aggregateTimeout, default 20ms) and nodemon uses (--delay, default 1s). The timer resets on every file change; regeneration only fires after 300ms of quiet. Partial writes during a save settle within milliseconds, well under the window. The result:

// After - one message, once, after file settles
//
//   ✓ Types regenerated (1 procs)

We considered using watchfiles’s built-in step parameter, but watchfiles’s step is a one-shot cooldown from thefirst event, not a resetting debounce. It doesn’t give you “regenerate N ms after the last change,” which is the behavior every dev tool expects. threading.Timer is the correct, standard approach.

4. CORS: cross-origin fetch finally works

The ASGI transport’s send_response() set exactly one header: content-type: application/json. NoAccess-Control-Allow-Origin. No handling ofOPTIONS preflight requests. If you ran your frontend onlocalhost:5173 (Vite) or localhost:3000 (Next.js), any fetch to localhost:8000/rpc was blocked by the browser’s CORS policy.

The fix adds standard CORS headers to every response and handlesOPTIONS /rpc preflight:

CORS_HEADERS = [
    (b"access-control-allow-origin", b"*"),
    (b"access-control-allow-methods", b"OPTIONS, GET, POST"),
    (b"access-control-allow-headers", b"Content-Type"),
    (b"access-control-max-age", b"86400"),
]

# In __call__:
if method == "OPTIONS" and path == "/rpc":
    await send({
        "type": "http.response.start",
        "status": 204,
        "headers": CORS_HEADERS,
    })
    await send({"type": "http.response.body", "body": b""})
    return

# In send_response:
await send({
    "type": "http.response.start",
    "status": status_code,
    "headers": [
        (b"content-type", b"application/json"),
    ] + CORS_HEADERS,
})

These are the same headers FastAPI’s CORSMiddleware sets. Better Auth was the reference here — it handles CORS internally for its API routes rather than punting to the server framework. Since pyrpc’s ASGI transport IS the server during pyrpc dev, it should do the same. The Access-Control-Max-Age: 86400 header reduces preflight frequency to once per day.

The Flask and FastAPI transports are not changed. Those follow tRPC’s approach: the host application is responsible for CORS. Users ofmount_flask or mount_fastapi configure their ownflask-cors or CORSMiddleware as they would for any other route.

Design decisions and what we didn’t change

The baseUrl + /rpc convention stays.We considered removing the auto-append entirely (matching tRPC’s “give us the full URL” pattern), but that would break every existing user who passes baseUrl: "http://localhost:8000". The normalization fix handles both forms transparently. If we ever introduce apath option (like Better Auth’s basePath), the migration path will be: baseUrl becomes origin-only,path defaults to /rpc. No breaking change needed.

Error deduplication is not implemented. The debounce alone eliminates the flood — only one regeneration fires per save, so even if the file has a real syntax error, you see exactly one message. Error dedup adds complexity (tracking previous error strings across saves) for marginal benefit. If the same error persists across multiple saves, you should see it each time.

The .rpc getter stays on the class. It’s used internally by the proxy to dispatch calls. The fix is type-level only — the runtime behavior is unchanged. Removing the getter would require rewriting the dispatch mechanism and break anyone extending PyRPCClientdirectly (unlikely, but possible).

CORS is not configurable in the ASGI transport.Access-Control-Allow-Origin: * is correct for a dev server. If users need stricter CORS in production, they should wrap the ASGI app with their own middleware or use the Flask/FastAPI transports where CORS is the host application’s responsibility.

What these changes feel like

The first-time experience now looks like this:

# Terminal (server):
$ pyrpc dev
  ✓ Types regenerated (1 procs)

  pyRPC dev server  http://127.0.0.1:8000/rpc
  Types: node_modules/@pyrpc/types/src/index.ts

type help for commands
pyrpc>

# VS Code (client):
import { createClient } from "@pyrpc/client";
import type { Types } from "@pyrpc/types";

const client = createClient<Types>({ baseUrl: "http://127.0.0.1:8000" });
// client. → autocomplete: get_user (not rpc)

const user = await client.get_user("Atnatewos");  // works

// On file save:
//   ✓ Types regenerated (1 procs)   ← one message, 300ms after save

Full changelog

  • Client type system: createClient return type changed from PyRPCClient & TTypes to TTypes. The rpc getter no longer pollutes autocomplete. Catches client.rpc.method() misuse at compile time instead of runtime.
  • Client URL normalization: Constructor normalizes baseUrl by stripping any existing trailing /rpc before re-appending it. Prevents double /rpc/rpc when users copy the URL from server output. Handles both http://localhost:8000 and http://localhost:8000/rpc correctly.
  • File watcher debounce: threading.Timer with 300ms resetting debounce replaces direct regenerate() calls in the watcher loop. Matches webpack’s aggregateTimeout and nodemon’s --delay. Fires once after the last file change settles. Startup and manual generate command still regenerate immediately.
  • ASGI CORS: Added Access-Control-Allow-Origin: *, Access-Control-Allow-Methods, Access-Control-Allow-Headers, and Access-Control-Max-Age to every response. Added OPTIONS /rpc handler returning 204. Same headers as FastAPI’s CORSMiddleware. Flask and FastAPI transports unchanged.

See the full changelog for details.