No description
  • Rust 59.1%
  • C 15.9%
  • TypeScript 7.5%
  • Python 6.8%
  • C# 3.2%
  • Other 7.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Fabian Stamm ade278b976
All checks were successful
ci/woodpecker/push/e2e Pipeline was successful
ci/woodpecker/push/test Pipeline was successful
ci/woodpecker/tag/release Pipeline was successful
Bump version to 0.4.4
2026-10-17 00:10:50 +02:00
.woodpecker Add Python and Go targets, fmt/check commands, multi-error reporting and more LSP features 2026-10-16 22:59:58 +02:00
e2e Let servers call clients (Python, Go, TypeScript, C#) 2026-10-17 00:03:06 +02:00
examples Support the bytes type in all targets and fix the C# server parameters 2026-10-16 23:22:40 +02:00
libjrpc Allow naming lints in the generated Rust code 2026-10-17 00:10:00 +02:00
lsp Support the bytes type in all targets and fix the C# server parameters 2026-10-16 23:22:40 +02:00
src Add Python and Go targets, fmt/check commands, multi-error reporting and more LSP features 2026-10-16 22:59:58 +02:00
tree-sitter-jrpc Add Python and Go targets, fmt/check commands, multi-error reporting and more LSP features 2026-10-16 22:59:58 +02:00
zed Bump version to 0.4.0 and pin the Zed grammar 2026-10-16 23:00:07 +02:00
.gitignore Fix the e2e pipeline: track the Rust test files, drop the invalid dart analyze flag 2026-10-16 23:38:48 +02:00
Cargo.lock Bump version to 0.4.4 2026-10-17 00:10:50 +02:00
Cargo.toml Bump version to 0.4.4 2026-10-17 00:10:50 +02:00
README.md Let servers call clients (Python, Go, TypeScript, C#) 2026-10-17 00:03:06 +02:00

JSON-RPC 2.0 Code-Gen

Type/Service definition language and code generator for JSON-RPC 2.0, written in Rust. From a single .jrpc definition it generates the types, clients and servers for several languages.

Target Description Client Server
ts-node TypeScript for NodeJS ✓ ✓
ts-esm TypeScript in ESM format (Deno/Web) ✓ ✓
rust Rust (tokio, serde) ✓ ✓
csharp C# (alias: c#) ✓ ✓
dart Dart ✓
python Python 3.10+, asyncio (alias: py) ✓ ✓
go Go 1.21+ (alias: golang) ✓ ✓

Installation

cargo install --path .

The workspace contains the CLI (jrpc-cli, the root crate), the library (libjrpc), the language server (lsp), the tree-sitter grammar (tree-sitter-jrpc) and the Zed extension (zed).

Usage

Create a definition file like test.jrpc:

define rust_crate example;

enum TestEnum {
  VAL1,
  VAL2,
  VAL10 = 10,
  VAL11,
  VAL12
}

type Test {
  testen: TestEnum;
  someString: string;
  someNumber?: float;
  array: string[];
  map: {int, TestEnum};
}

service TestService {
  @Description("This function adds two numbers")
  @Param("v1", "The first number")
  @Param("v2", "The second number")
  @Returns("The added values of the two numbers")
  AddNumbers(v1: float, v2: float): float;

  GetTest(name?: string): Test;
  GetOptional()?: Test;

  notification SendTest(test: Test);
}

Then run the generator:

jrpc-cli compile test.jrpc -o ts-node:output/ts -o rust:output/rust

This generates the client and server code in the specified folders.

Commands

Command Description
compile <input> -o <t:p> Compile a definition. -o can be used multiple times.
check <input>... Validate definitions without generating code.
fmt <file>... Format definition files in place. --check only reports, - formats stdin.
targets List all targets.
lsp Run the language server on stdin/stdout.

Options of compile:

  • -o, --output <target:path>: Target and output folder, e.g. ts-node:out/. Can be repeated to generate multiple targets in one run.
  • -d, --definition <file>: Additionally writes the resolved definition (all imports merged and validated) as JSON to file.
  • -v, --verbose: Enables additional logging.

The input can be a .jrpc file (also an http(s):// URL) or a definition JSON that was emitted with --definition. The JSON is only guaranteed to be readable by the same version of the compiler that wrote it; a different version is rejected with an error.

Language

// Line comments, # line comments and /* block comments */ are supported
import "./other";            // Imports another file (relative to the importing file)
define csharp_namespace Foo; // Options for the targets

Types

Built-in types: int, float, string, boolean (or bool), void (only as return type) and bytes (binary data, needs define allow_bytes true;).

  • Optional field/parameter: name?: string
  • Array: name: string[]
  • Map: name: {string, Other} (the key must be string or int)
  • Optional return value: Method()?: Type
  • Optional parameters must come after all required parameters.
  • Enum values can be negative; they must be increasing.
  • Strings support the escapes \", \\, \/, \n, \r, \t and \uXXXX.

Services

  • notification Name(...) has no return value and sends no response.
  • Decorators: @Description("..."), @Param("name", "..."), @Returns("...").
  • Parameters can be sent positionally (array) or by name (object).

Options (define)

Option Used by Description
rust_crate rust Name of the generated crate (required)
csharp_namespace csharp Namespace of the generated code (default JRPC)
dart_library_name dart Name of the generated library (required)
go_package go Name of the generated package (default jrpc)
go_module go Module path; if set, a go.mod is written
allow_bytes all Enables the bytes type. use_messagepack is an old alias.

Reserved words

Names that are keywords in a target language are renamed there, the names on the wire do not change. Rust uses raw identifiers (r#type, and self_ for the keywords that cannot be raw), C# uses @name, TypeScript, Dart, Python and Go append _ to parameters/fields. Serde and JSON attributes keep the original names.

Errors

Errors point to the exact location in the source:

error: Unexpected token, expected colon (found 'int')
 --> /project/test.jrpc:2:5
  |
2 |   x int;
  |     ^^^

All errors are reported in one run: a syntax error only skips the affected definition, and the semantic checks (below) report one error per definition. Semantic errors are only reported once the syntax is valid.

Besides syntax errors the compiler checks for undefined and duplicate types, services, methods, fields, parameters and enum values, invalid map keys, misplaced void/bytes and invalid decorators.

Editor support

Language server

The language server is part of the command line tool: jrpc-cli lsp speaks LSP over stdio. It is also available as the standalone binary jrpc-lsp (cargo install --path lsp).

Features: diagnostics (using the real compiler, including imports and unsaved buffers, all errors at once), semantic tokens, inlay hints (the numbers of enum values that are not written out), code actions (import a type from another file, fix typos in type names, create a missing type or enum, write out enum values, add missing @Description/@Param/@Returns), hover (types, methods with their decorator documentation, options, built-ins), completion (keywords, types, options, decorators, @Param names, import paths), go to definition (also across imports), find references, rename, document highlight, document/workspace symbols, document links for imports, folding and formatting.

Zed

The extension in zed/ adds highlighting, brackets, indentation, an outline and the language server. Install it in Zed with zed: install dev extension and select the zed folder. The grammar is loaded from the repository configured in zed/extension.toml; pin rev to the commit that contains tree-sitter-jrpc/src.

The extension looks for the language server in this order and only downloads something if nothing else is found:

  1. lsp.jrpc-lsp.binary in the Zed settings
  2. jrpc-cli in the PATH (started as jrpc-cli lsp)
  3. jrpc-lsp in the PATH
  4. The latest release of this repository (jrpc-cli-<target>.tar.gz/.zip), which is downloaded once per version
{ "lsp": { "jrpc-lsp": { "binary": { "path": "/path/to/jrpc-cli", "arguments": ["lsp"] } } } }

Releases

Pushing a tag runs .woodpecker/release.yaml, which builds jrpc-cli for Linux (x86_64, aarch64), and Windows (x86_64) and attaches the archives to a release. It needs the secret forgejo_token. There are no macOS binaries; on macOS install the CLI with cargo install --path ..

The queries live in tree-sitter-jrpc/queries; run zed/sync-queries.sh after changing them. After changing tree-sitter-jrpc/grammar.js run tree-sitter generate in that folder.

Binary data

define allow_bytes true; enables the type bytes. It can be used like every other type (fields, parameters, return values, bytes[], map values, optional). On the wire it is a base64 string (standard alphabet, with padding), so the messages stay plain JSON-RPC 2.0. In the generated code it is Bytes (a wrapper of Vec<u8>) in Rust, Uint8Array in TypeScript, byte[] in C#, Uint8List in Dart, bytes in Python and []byte in Go. Invalid base64 is answered with -32602.

Generated code

Names from the definition are kept as they are (AddNumbers stays AddNumbers in Python and TypeScript); Go exports them (val_number becomes ValNumber, the JSON name is unchanged).

  • Python (python): a package with dataclasses (kw_only), IntEnums and one file per service with <Service>Client and <Service>Server (abstract, async). The output folder is the package. Transports are plugged in with ClientProvider(send) and ServerProvider().session(send).on_message(msg).
  • Go (go): one package without dependencies. Optional values are pointers, services have a <Service>Client, an interface <Service>Server and a handler <Service>Handler{Impl: ...} that is added to a ServiceProvider. ServiceProvider.HandleMessage(ctx, data) returns the response to send and ClientProvider.OnMessage(data) takes responses.

Both are checked by the end to end tests in e2e/ (e2e/run.sh python|go|ts|csharp|dart|rust), which also run in CI (.woodpecker/e2e.yaml).

Transports

The generated clients and servers do not care how messages are transported. For Python, Go and TypeScript the generated code also contains ready to use transports without dependencies (transport.py, transport.go, ts_transport.ts):

Transport Server Client
TCP / stdio / pipes (framed with Content-Length like the language server protocol, or one message per line) Python serve_tcp, serve(server, StdioMessages()); Go ServeListener, Serve(ctx, p, NewStdioConn(f)); TS serveStream(provider, stdioStream()) Python connect_tcp, Connection(...); Go Dial, NewClientConnection; TS connectStream
HTTP POST Python serve_http, handle_http; Go: a ServiceProvider is an http.Handler; TS httpHandler (Deno, Bun, fetch API) Python http_client; Go NewHTTPClient; TS httpClient
WebSocket (no library is imported, objects like the ones of websockets, gorilla/websocket or the browser/Deno/Node.js WebSocket are used) Python serve(server, WebSocketMessages(ws)); Go Serve(ctx, p, NewWebSocketConn(ws)); TS serveWebSocket Python Connection(WebSocketMessages(ws)); Go NewClientConnection(NewWebSocketConn(ws)); TS connectWebSocket

Requests of a connection are handled concurrently. When a connection is closed, the requests that wait for a response fail. Batches are supported by the servers. C#, Rust and Dart come with the client and server only, or with a transport interface (C#), and leave the transport to you.

Calling the client from the server

Both sides of a connection are peers: every service can be provided by either side, the generated <Service>Client works on both. The client provides a service (the implementation of the generated server class) and the server calls it over the connection it is serving.

Client provides the service Server calls the client
Python connection.provider.add_service(Impl()) on_session= of serve/serve_tcp gives the Session, XClient(session.client)
Go connection.Provider.AddService(ctx, &XHandler{Impl: impl}) Serve(..., WithPeer(func(peer *ClientProvider) {...})), NewXClient(peer)
TypeScript connection.serve(serviceProvider) serveWebSocket returns the session, serveStream(..., onSession), new XClient(session.client)
C# a JRpcServer with a session on the transport of the client new XClient(session.Client)

Requests of the server fail when the connection is closed. This needs a connection that stays open, so it does not work over HTTP. Rust and Dart do not support it yet.

Status

  • Zig is not supported (the old TypeScript implementation only generated types for it).
  • bytes is sent as a base64 string (standard alphabet, with padding) in JSON. A binary wire format like MessagePack is not supported yet.
  • The Dart target only generates clients. Its output is analyzed and the client is tested against a hand written server in CI.

TODOS

  1. More documentation
  2. Null checks/enforcements in all languages
  3. More and better tests