v0.8.0 ships a persistent Node.js daemon for JSON Schema to TypeScript conversion that drops regeneration time from ~3.3s to ~4.6ms — a 715x speedup. Here is how it works, why it matters, and what we learned along the way.
The old way: npx on every keystroke
Every time you saved a file in pyrpc dev, pyRPC needed to convert your Python-type-annotated schemas into TypeScript interfaces. It did this by writing the schema to a temp file, callingnpx --package=json-schema-to-typescript json2ts, reading the output, and deleting the file. Each call cost ~3.3 seconds:
~3.3s per regeneration ├── 1.6s watchfiles debounce (detect the file change) ├── 0.3s pyrpc debounce (wait for more changes) ├── 0.5s Python importlib.reload + schema extraction ├── 1.0s npx resolve + node spawn + json2ts parse + emit └── 0.1s Jinja2 render + file write
The npx subprocess was the dominant cost. Each spawn had to resolve thejson-schema-to-typescript package, start a fresh Node.js process, parse the schema, and emit TypeScript — then throw everything away. No caching, no reuse, no incremental builds.
The daemon: one process, reused
The fix replaces the per-call subprocess with a single long-lived Node.js process that keeps json-schema-to-typescriptloaded in V8’s code cache. Python communicates with it over stdin/stdout using a JSON-line protocol:
Before (each keystroke):
Python -> tempfile -> npx resolve -> npm install? -> node spawn -> json2ts -> TS -> delete
After (first call):
Python -> npm install --prefix to ~/.jsonschema-ts/ -> spawn node daemon -> wait
After (subsequent calls):
Python -> stdin: {schema, options} -> daemon -> compile() -> stdout: TS
~4.6msThe daemon is a standalone Node.js script that loadsjson-schema-to-typescript viacreateRequire() from a local cache directory. It reads JSON lines from stdin, calls compile()in-memory (no temp files), and writes JSON responses to stdout.
Why not npx --package=... node ...?
The first design tried npx --package=json-schema-to-typescript node daemon.js. This does not work on any platform: npx adds packages to PATH, not NODE_PATH, so require() cannot find them. This is a known, unresolved npx limitation (open since 2018). The createRequire() API avoids NODE_PATH entirely and is the standard Node.js approach for loading modules from arbitrary paths.
Benchmarks
Test schema: {type: "object", properties: {name: string, age: number, active: boolean}}
Environment: Windows, Python 3.12, Node.js 24, npm 11.
| Scenario | Time | vs subprocess |
|---|---|---|
| Subprocess (old, avg n=5) | 3.299s | baseline |
| Daemon cold start (npm install) | 5.910s | 0.6x (one-time) |
| Daemon warm (first call after cache) | 0.323s | 10x faster |
| Daemon hot (avg n=10) | 0.0046s | 715x faster |
| Daemon hot min | 0.0029s | 2.9ms |
| Daemon hot max | 0.0074s | 7.4ms |
The one-time cold start cost of 5.9s covers thenpm install of json-schema-to-typescriptto ~/.jsonschema-ts/ plus the initial Node.js spawn. Subsequent calls hit an already-hot process with all modules cached in V8’s code cache.
What about the watchfiles debounce?
Even with the daemon making conversion near-instant, the old file watcher used a default 1.6s debounce interval before pyRPC even received the change notification. v0.8.0 reduces this to 200ms, cutting ~1.4s off the perceived latency.
The total time from save to updated TypeScript is now:
~0.5s total (down from ~3.3s) ├── 0.2s watchfiles debounce ├── 0.3s pyrpc debounce + importlib.reload ├── 0.005s daemon type conversion └── 0.1s Jinja2 render + file write
Edge cases handled
- Daemon crash — if the Node.js process dies (OOM, uncaught exception), the daemon manager detects the exit, spawns a replacement, and retries the conversion. If the fresh daemon also fails, pyRPC falls back to the original subprocess path.
- No npm available — if
npmis not found or the install fails, the daemon is skipped and pyRPC uses the subprocess path transparently. - Concurrent regeneration — Python uses a threading lock around stdio communication. The daemon queues messages in-process, so even rapid saves are handled sequentially.
- Clean shutdown — Python’s
atexitsendsSIGTERMto the daemon process. If the daemon does not exit within 5 seconds, it is killed. The daemon also exits on stdin close.
How to use it
The daemon is enabled by default in jsonschema-ts v0.3.0 and pyrpc-codegen v0.8.0. Upgrade:
pip install --upgrade pyrpc-core
No configuration needed. The daemon starts on the first type generation and stays alive for the duration of the Python process.
If you need to disable the daemon (e.g., in a CI environment without Node.js), pass use_daemon=False to theJsonschemaTsOptions:
from jsonschema_ts import Options, convert_all opts = Options(use_daemon=False) result = convert_all(defs, opts=opts)
What’s next
The daemon architecture opens the door for further optimizations: schema diffing (skip models that have not changed), incremental TypeScript emit, and hot module reloading in the browser via WebSocket push. For now, the 715x speedup means type generation is no longer the bottleneck in the dev loop — reloading your Python module is.
Read the full changelogfor the complete list of changes in v0.8.0.

pyRPC