- Rust 59.1%
- C 15.9%
- TypeScript 7.5%
- Python 6.8%
- C# 3.2%
- Other 7.5%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| .woodpecker | ||
| e2e | ||
| examples | ||
| libjrpc | ||
| lsp | ||
| src | ||
| tree-sitter-jrpc | ||
| zed | ||
| .gitignore | ||
| Cargo.lock | ||
| Cargo.toml | ||
| README.md | ||
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 tofile.-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 bestringorint) - 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,\tand\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:
lsp.jrpc-lsp.binaryin the Zed settingsjrpc-cliin thePATH(started asjrpc-cli lsp)jrpc-lspin thePATH- 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>Clientand<Service>Server(abstract,async). The output folder is the package. Transports are plugged in withClientProvider(send)andServerProvider().session(send).on_message(msg). - Go (
go): one package without dependencies. Optional values are pointers, services have a<Service>Client, an interface<Service>Serverand a handler<Service>Handler{Impl: ...}that is added to aServiceProvider.ServiceProvider.HandleMessage(ctx, data)returns the response to send andClientProvider.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).
bytesis 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
- More documentation
- Null checks/enforcements in all languages
- More and better tests