diff --git a/Makefile b/Makefile index 065fcc43871b..dec6669a36d4 100644 --- a/Makefile +++ b/Makefile @@ -55,7 +55,7 @@ endef # Fast bootstrap. fast: release-image barretenberg boxes playground docs aztec-up \ - bb-tests l1-contracts-tests yarn-project-tests boxes-tests playground-tests aztec-up-tests docs-tests noir-protocol-circuits-tests release-image-tests spartan claude-tests + bb-tests l1-contracts-tests yarn-project-tests boxes-tests playground-tests aztec-up-tests docs-tests noir-protocol-circuits-tests release-image-tests spartan claude-tests ipc-codegen-tests # Full bootstrap. full: fast bb-full-tests bb-cpp-full yarn-project-benches @@ -269,6 +269,17 @@ bb-tests: bb-cpp-native-tests bb-acir-tests bb-ts-tests bb-sol-tests bb-bbup-tes bb-full-tests: bb-cpp-wasm-threads-tests bb-cpp-asan-tests bb-cpp-smt-tests +#============================================================================== +# IPC Codegen +#============================================================================== + +.PHONY: ipc-codegen ipc-codegen-tests +ipc-codegen: + $(call build,$@,ipc-codegen) + +ipc-codegen-tests: ipc-codegen + $(call test,$@,ipc-codegen) + #============================================================================== # .claude tooling #============================================================================== diff --git a/barretenberg/.gitignore b/barretenberg/.gitignore index ba04309b3eac..e7d2a9f1bcc7 100644 --- a/barretenberg/.gitignore +++ b/barretenberg/.gitignore @@ -9,7 +9,8 @@ cmake-build-debug *_opt.pil bench-out -# Generated code from msgpack schema (run `yarn generate` in ts/) +# Generated code (produced by ipc-codegen, not committed) +**/generated/ +# Legacy generated paths from the cbind pipeline (kept for any leftover from migration) rust/barretenberg-rs/src/generated_types.rs rust/barretenberg-rs/src/api.rs -ts/src/cbind/generated/ diff --git a/ipc-codegen/.gitignore b/ipc-codegen/.gitignore new file mode 100644 index 000000000000..6626e04f7af9 --- /dev/null +++ b/ipc-codegen/.gitignore @@ -0,0 +1,11 @@ +# Local build artifacts produced by the echo wire-compat examples. +examples/cpp/*/echo_client +examples/cpp/*/echo_server +examples/rust/*/target/ +examples/zig/*/.zig-cache/ +examples/zig/*/zig-out/ +examples/ts/*/node_modules/ + +# All files under any generated/ directory are produced by bootstrap.sh build +# (either codegen output or one-time-copied templates) and are never committed. +**/generated/ diff --git a/ipc-codegen/.rebuild_patterns b/ipc-codegen/.rebuild_patterns new file mode 100644 index 000000000000..15d048b8fba4 --- /dev/null +++ b/ipc-codegen/.rebuild_patterns @@ -0,0 +1,5 @@ +^ipc-codegen/src/.*\.ts$ +^ipc-codegen/templates/ +^ipc-codegen/examples/ +^ipc-codegen/package\.json$ +^ipc-codegen/bootstrap\.sh$ diff --git a/ipc-codegen/README.md b/ipc-codegen/README.md new file mode 100644 index 000000000000..b6a494c6ccd6 --- /dev/null +++ b/ipc-codegen/README.md @@ -0,0 +1,166 @@ +# ipc-codegen + +Schema-driven IPC code generator for **C++**, **TypeScript**, **Rust**, and **Zig**. + +Given a JSON schema describing a service's commands and responses, emits matching +type definitions plus a client and/or server in the target language. All wire I/O +is msgpack-over-UDS; languages talk to each other byte-compatibly. + +## Quick start + +```sh +cd ipc-codegen +./bootstrap.sh build # generate echo example bindings, compile all 4 languages +./bootstrap.sh test # run the 18-test cross-language wire-compat matrix +``` + +## Layout + +``` +ipc-codegen/ + bootstrap.sh # build / test / update_goldens / hash + src/ # generator (TypeScript, runs under Node 22+) + generate.ts # CLI entry point + schema_visitor.ts # JSON schema -> CompiledSchema IR + cpp_codegen.ts # IR -> C++ output + typescript_codegen.ts # IR -> TypeScript output + rust_codegen.ts # IR -> Rust output + zig_codegen.ts # IR -> Zig output + naming.ts # snake_case / PascalCase helpers + templates/ # static templates copied alongside generated code + cpp/{ipc_client,ipc_server,msgpack_struct_map_impl}.hpp + ts/{ipc_client,ipc_server}.ts + rust/{backend,error,ipc_client,ipc_server,uds_backend,ffi_backend}.rs + zig/{backend,uds_backend,ffi_backend,ipc_client,ipc_server,ffi_client}.zig + examples/ # 4-language echo service (test harness, see below) + SCHEMA_SPEC.md # wire protocol and schema-format reference +``` + +The package contains no service schemas of its own. Each consumer owns and commits its schema next to the C++ server that defines the wire format, and invokes `generate.ts` with that local path. + +## CLI: `src/generate.ts` + +Invoked once per (schema, language) pair. Run directly with `node --experimental-strip-types`, or via `bootstrap.sh`. + +``` +node --experimental-strip-types --experimental-transform-types --no-warnings \ + src/generate.ts --schema --lang --out [flags] +``` + +### Required flags + +| Flag | Purpose | +|---|---| +| `--schema ` | Path to the JSON schema. | +| `--lang ` | Target language. | +| `--out ` | Output directory. Files are (re)written every run; static templates are copied alongside (and re-copied only if missing for one-time scaffolding files). | + +### Role flags + +| Flag | Purpose | +|---|---| +| `--server` | Emit server dispatch (matches request name to handler, deserializes, calls handler, serializes response). | +| `--client` | Emit a typed client class/struct with one method per command. | +| `--uds` | Include the Unix-domain-socket backend (Rust/Zig). | +| `--ffi` | Include the FFI backend (Rust/Zig). | + +### Naming flags + +| Flag | Purpose | +|---|---| +| `--prefix ` | Type prefix applied to generated type names (`CircuitProve`, etc.). Auto-detected from the schema if omitted. | +| `--strip-method-prefix` | TS only. Drops the prefix from client *method* names: `bbCircuitProve()` → `circuitProve()`. Types keep the prefix. | + +### C++-specific flags + +| Flag | Purpose | +|---|---| +| `--cpp-namespace ` | C++ namespace, e.g. `bb::wsdb`. Default: lowercased prefix. | +| `--cpp-wire-namespace ` | Inner namespace for wire types, default `wire`. | +| `--cpp-include-dir ` | Include-path prefix for cross-includes between generated files, e.g. `myservice/generated`. Leave unset when generated files are in the same directory as their consumer. | + +### Other + +| Flag | Purpose | +|---|---| +| `--curve-constants` | TS only. Also emit `curve_constants.ts` with bn254/grumpkin/secp moduli & generators (currently only used by bb). | +| `--skeleton ` | One-shot scaffolding: writes a `_handlers.{ts,rs,zig,cpp}` stub, `main`, and a build file into `` if they don't already exist. Skipped on subsequent runs. | + +## Worked examples + +Paths below are illustrative — consumers commit their own schema next to the C++ server that owns the wire format and supply absolute or relative paths on the command line. + +### TypeScript client + server, with curve constants + +```sh +src/generate.ts \ + --schema /path/to/myservice_schema.json \ + --lang ts \ + --out /path/to/output/generated \ + --server --client \ + --prefix MyService --strip-method-prefix --curve-constants +``` + +Produces `api_types.ts`, `async.ts`, `sync.ts`, `server.ts`, `ipc_client.ts` (template), `ipc_server.ts` (template), `curve_constants.ts`. + +### C++ server + client, under a project sub-include path + +```sh +src/generate.ts \ + --schema /path/to/myservice_schema.json \ + --lang cpp \ + --out /path/to/myservice/generated \ + --server --client \ + --cpp-namespace my::ns --prefix MyService \ + --cpp-include-dir myservice/generated +``` + +Produces `myservice_types.hpp`, `myservice_ipc_client.{hpp,cpp}`, `myservice_ipc_server.hpp`, plus the `ipc_client.hpp` / `ipc_server.hpp` / `msgpack_struct_map_impl.hpp` templates. Cross-includes use the supplied `--cpp-include-dir` prefix (`#include "myservice/generated/myservice_types.hpp"`). + +### Rust UDS + FFI client + +```sh +src/generate.ts \ + --schema /path/to/myservice_schema.json \ + --lang rust \ + --out /path/to/crate/src/generated \ + --server --client --uds --ffi \ + --prefix MyService \ + --skeleton /path/to/crate/src +``` + +Produces `myservice_types.rs`, `myservice_client.rs`, `myservice_server.rs`, `ipc_server.rs` (template), plus the backend templates (`backend.rs`, `error.rs`, `uds_backend.rs`, `ffi_backend.rs`). The skeleton flag also writes a one-time `myservice_handlers.rs`, `main.rs`, and `Cargo.toml` into the skeleton dir so a new service crate is buildable on first run. + +### Zig client + server + +```sh +src/generate.ts \ + --schema /path/to/myservice_schema.json \ + --lang zig \ + --out /path/to/output/generated \ + --server --client --uds --ffi \ + --prefix MyService +``` + +Produces `myservice_types.zig`, `myservice_client.zig`, `myservice_server.zig`, plus backend templates. + +## Adding a new service + +1. **Define the C++ command structs** in your service's `.hpp`, each with `MSGPACK_SCHEMA_NAME` and `SERIALIZATION_FIELDS(...)`. Group them into a single `Command` and `Response` `NamedUnion`. +2. **Snapshot the schema.** Build the service binary and run ` msgpack schema` to dump the JSON. Commit it next to the C++ source that defines it (e.g. alongside the `Command` / `Response` headers). This is the wire-format source of truth. +3. **Wire your consumer's build to invoke `src/generate.ts`** with the flags above, passing the absolute path to the committed schema and the desired output directory. Generated files go under a `generated/` directory which is gitignored by convention. +4. **Run `./bootstrap.sh test`** in `ipc-codegen/` to confirm the codegen and cross-language wire compat tests still pass. + +## Schemas are the source of truth + +The JSON schema is the wire contract between client and server. Consumers commit it next to the C++ server that defines the underlying `SERIALIZATION_FIELDS`, so the file lives close to what it describes and tracks with that code. Whenever a server-side command changes, refresh the JSON snapshot by running ` msgpack schema` against the rebuilt binary and committing the diff. Diverged schemas are a CI failure (each consumer is responsible for guarding its own snapshot). + +Each generated file embeds a `SCHEMA_HASH` so callers can detect at connection time that their bindings predate the server. + +## Wire-format contract + +`examples/echo-schema/golden/*.msgpack` is a frozen set of byte-level fixtures covering every relevant msgpack encoding boundary (variable-width ints, fixstr/str8/str16, bin8/bin16, optional `Some`/`None`, empty containers, multi-byte UTF-8). The per-language golden tests (`examples/{rust,ts}/echo/...`) both decode the fixtures and re-encode round-trip — pinning down canonical msgpack output across implementations. + +If you intentionally change the wire format, run `./bootstrap.sh update_goldens` and review the diff. Any byte-level change is a breaking change for external implementations of the schema. + +See `SCHEMA_SPEC.md` for the wire protocol details. diff --git a/ipc-codegen/SCHEMA_SPEC.md b/ipc-codegen/SCHEMA_SPEC.md new file mode 100644 index 000000000000..f2a0d1f9f26c --- /dev/null +++ b/ipc-codegen/SCHEMA_SPEC.md @@ -0,0 +1,241 @@ +# Aztec IPC Schema Format Specification + +This document specifies the JSON schema format used for cross-language code generation +in the Aztec IPC system. The schema is the contract between the C++ binary's +`msgpack schema` command and all language code generators. + +## Overview + +Each IPC service binary (aztec-wsdb, aztec-cdb, aztec-avm, bb) exports its schema via: + +```bash +./aztec-wsdb msgpack schema # Outputs JSON to stdout +``` + +The output is a JSON object representing the service's API, derived at compile time +from C++ type metadata via the `MsgpackSchemaPacker` infrastructure. + +## Top-Level Structure + +```json +{ + "commands": ["named_union", [ + ["CommandNameA", { "__typename": "CommandNameA", "field1": , ... }], + ["CommandNameB", { "__typename": "CommandNameB", "field1": , ... }] + ]], + "responses": ["named_union", [ + ["ResponseNameA", { "__typename": "ResponseNameA", "field1": , ... }], + ["ErrorResponse", { "__typename": "ErrorResponse", "message": "string" }] + ]] +} +``` + +- `commands` and `responses` are both **NamedUnion** types (see below). +- Commands and responses are positionally paired: the Nth command corresponds to the Nth + non-error response. The error response (ending in `ErrorResponse`) is shared across all commands. + +## Type Encodings + +Types in the schema are represented as one of: + +### Primitive Types (JSON strings) + +| Schema String | C++ Type | Description | +|---------------|----------|-------------| +| `"bool"` | `bool` | Boolean | +| `"int"` | `int` | Signed 32-bit integer | +| `"unsigned int"` | `unsigned int` / `uint32_t` | Unsigned 32-bit integer | +| `"unsigned short"` | `unsigned short` / `uint16_t` | Unsigned 16-bit integer | +| `"unsigned long"` | `unsigned long` / `uint64_t` | Unsigned 64-bit integer | +| `"unsigned char"` | `unsigned char` / `uint8_t` | Unsigned 8-bit integer | +| `"double"` | `double` | 64-bit floating point | +| `"string"` | `std::string` | UTF-8 string | +| `"bin32"` | Fixed-size byte arrays | Raw binary data (e.g., field elements) | +| `"field2"` | `Fq2` | Extension field: pair of 32-byte field elements | +| `"MerkleTreeId"` | `MerkleTreeId` enum | C++ enum, serialized as uint32 | +| `"unordered_map"` | `std::unordered_map` | Map type (special-cased per usage) | + +### Container Types (JSON arrays) + +Container types are encoded as 2-element arrays: `[kind, [args...]]` + +#### `vector` +```json +["vector", []] +``` +Example: `["vector", ["unsigned char"]]` = `std::vector` = byte array + +**Special case**: `["vector", ["unsigned char"]]` is treated as raw bytes, not an array of integers. + +#### `array` +```json +["array", [, ]] +``` +Example: `["array", ["unsigned char", 32]]` = `std::array` = 32-byte fixed buffer + +**Special case**: `["array", ["unsigned char", N]]` is treated as raw bytes (like `vector`). + +#### `optional` +```json +["optional", []] +``` +Example: `["optional", ["string"]]` = `std::optional` + +#### `shared_ptr` +```json +["shared_ptr", []] +``` +Treated as a transparent wrapper; the inner type is used directly. + +#### `tuple` +```json +["tuple", [, , ...]] +``` +Example: `["tuple", ["string", "unsigned long"]]` = `std::tuple` + +#### `alias` +```json +["alias", [, ]] +``` +Alias for a type that serializes as another msgpack type (e.g., `uint256_t` serializes as raw bytes). +Treated as raw bytes in code generation. + +### Struct Types (JSON objects) + +Structs are JSON objects with a `__typename` field and named fields: + +```json +{ + "__typename": "SomeStruct", + "field_a": "unsigned int", + "field_b": ["vector", ["unsigned char"]], + "field_c": { + "__typename": "NestedStruct", + "x": "unsigned long" + } +} +``` + +- `__typename` identifies the struct for deduplication and named reference. +- Field names are the original C++ field names (snake_case by convention). +- Field values are type encodings (primitives, containers, or nested structs). +- Nested structs are inlined on first occurrence and referenced by `__typename` string thereafter. + +### NamedUnion Type + +```json +["named_union", [ + ["VariantName1", ], + ["VariantName2", ] +]] +``` + +A tagged union where each variant has a string name and a type schema. +This is the top-level type for both `commands` and `responses`. + +## Wire Protocol + +The schema defines the types; this section specifies how they are serialized on the wire. + +### Framing + +All messages use length-prefix framing: + +``` +[4 bytes: payload length, little-endian uint32][payload: msgpack bytes] +``` + +### Request Wire Format + +A request is a 1-element msgpack **array** containing a NamedUnion: + +``` +msgpack array(1) [ + msgpack array(2) [ + msgpack string: "CommandName", + msgpack map: { field1: value1, field2: value2, ... } + ] +] +``` + +In msgpack terms: `[[command_name, {fields...}]]` + +The outer array (tuple wrapper) exists for extensibility. The inner 2-element array +is the NamedUnion encoding. + +### Response Wire Format + +A response is a NamedUnion (no tuple wrapper): + +``` +msgpack array(2) [ + msgpack string: "ResponseName" | "ErrorResponse", + msgpack map: { field1: value1, field2: value2, ... } +] +``` + +If the response variant name ends with `ErrorResponse`, the response indicates an error. +The error struct always has a `message` field (string). + +### NamedUnion Wire Encoding + +A NamedUnion value is always encoded as a **2-element msgpack array**: +- Element 0: `string` — the variant name (matches `MSGPACK_SCHEMA_NAME` in C++) +- Element 1: `map` — the variant's fields, encoded as a msgpack map with string keys + +### Struct Wire Encoding + +Structs are encoded as msgpack **maps** with string keys matching the original C++ field names. +The `__typename` field from the schema is NOT included in the wire encoding — it is only +used for schema identification. + +### Type Wire Encoding Summary + +| Schema Type | msgpack Encoding | +|-------------|------------------| +| `bool` | msgpack bool | +| `unsigned int`, `int` | msgpack integer (smallest encoding that fits) | +| `unsigned short` | msgpack integer | +| `unsigned long` | msgpack integer | +| `unsigned char` | msgpack integer | +| `double` | msgpack float64 | +| `string` | msgpack str | +| `bin32`, `bytes` | msgpack bin | +| `vector` | msgpack bin (NOT array of integers) | +| `array` | msgpack bin | +| `vector` | msgpack array | +| `array` | msgpack array (fixed length) | +| `optional` | msgpack nil (if absent) or value | +| `field2` | msgpack ext type or array of 2 bin values | +| `enum` | msgpack integer (uint32) | +| struct | msgpack map with string keys | +| NamedUnion | msgpack array(2): [string, map] | + +### Integer Encoding Note + +msgpack uses the **smallest encoding that fits the value**, not the declared type. +A `uint64_t` value of `5` encodes as a single byte (positive fixint), not as a +uint64 encoding. Decoders MUST accept any integer encoding width for any integer field. + +## Schema Versioning + +Schema compatibility can be validated by computing a SHA-256 hash of the raw JSON schema +output. This hash should be checked at connection time when possible. A mismatch indicates +that the service binary and client were generated from different schema versions. + +## Adding a New Command + +To add a new command to a service: + +1. Define the command struct in C++ with `MSGPACK_SCHEMA_NAME` and `SERIALIZATION_FIELDS` +2. Add a nested `Response` struct with its own `MSGPACK_SCHEMA_NAME` and `SERIALIZATION_FIELDS` +3. Add both to the service's `Command` and `CommandResponse` NamedUnion types +4. Re-snapshot the schema JSON and re-run ipc-codegen for every target language +5. Verify generated code compiles in all target languages + +## Source Files + +- Schema visitor (IR compiler): `ipc-codegen/src/schema_visitor.ts` +- CLI entry point: `ipc-codegen/src/generate.ts` + +The schema JSON is produced by the consumer's own C++ msgpack reflection (typically a ` msgpack schema` subcommand that walks `SERIALIZATION_FIELDS` and `NamedUnion`s and prints the IR). ipc-codegen treats the resulting JSON as the source of truth and never reaches back into the producer. diff --git a/ipc-codegen/bootstrap.sh b/ipc-codegen/bootstrap.sh new file mode 100755 index 000000000000..9adc773cfb90 --- /dev/null +++ b/ipc-codegen/bootstrap.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# IPC codegen package. +# Generates IPC bindings from committed JSON schemas under schemas/, in TS, C++, +# Rust and Zig. Zero npm dependencies — runs with just Node.js (v22+). +# +# The build's only direct consumer is its own cross-language test harness under +# examples/. Service consumers (bb, wsdb, cdb, avm) are wired up by their +# own bootstrap scripts, which invoke `ipc-codegen/bootstrap.sh build` as +# a build-time prerequisite. + +source $(git rev-parse --show-toplevel)/ci3/source_bootstrap + +hash=$(cache_content_hash .rebuild_patterns) + +NODE_FLAGS="--experimental-strip-types --experimental-transform-types --no-warnings" + +gen() { node $NODE_FLAGS src/generate.ts "$@"; } + +function build { + echo_header "ipc-codegen build" + + # Service generation (bb, wsdb, cdb, avm) is invoked by each service's own + # bootstrap as those consumers migrate over. The build step here only + # generates the echo example bindings + compiles the per-language test + # binaries that test_cmds emits commands against. + examples/echo-schema/generate.sh + + echo "Building Rust echo binaries..." + (cd examples/rust/echo && cargo build --quiet) + + echo "Building Zig echo binaries..." + (cd examples/zig/echo && zig build) + + echo "Installing TS echo deps..." + if [ ! -d examples/ts/echo/node_modules ]; then + (cd examples/ts/echo && npm install --no-package-lock --quiet) + fi + + # C++ needs msgpack-c headers. We pick them up from a local bb cmake build + # for now; if absent the C++ matrix pairs are skipped in test_cmds below. + local MSGPACK_INC + MSGPACK_INC="$(cd ../barretenberg/cpp/build/_deps/msgpack-c/src/msgpack-c/include 2>/dev/null && pwd)" || true + if [ -n "${MSGPACK_INC:-}" ] && [ -d "$MSGPACK_INC" ]; then + echo "Building C++ echo binaries..." + local CXX_FLAGS="-std=c++20 -I $MSGPACK_INC -DMSGPACK_NO_BOOST -DMSGPACK_USE_STD_VARIANT_ADAPTOR" + (cd examples/cpp/echo && clang++ $CXX_FLAGS -o echo_server echo_server.cpp) + (cd examples/cpp/echo && clang++ $CXX_FLAGS -o echo_client echo_client.cpp generated/echo_ipc_client.cpp) + else + echo "Skipping C++ echo build — msgpack-c not present at ../barretenberg/cpp/build/_deps/" + fi + + # NB: the golden msgpack fixtures under examples/echo-schema/golden/ are + # COMMITTED and FROZEN — they're the binding wire-format contract. Don't + # regenerate them here. If a deliberate wire-format change requires + # refreshing them, run `./bootstrap.sh update_goldens` and commit the diff. +} + +function update_goldens { + echo_header "ipc-codegen update_goldens" + # Rebuild the rust generate_golden binary first. + (cd examples/rust/echo && cargo build --quiet --bin generate_golden) + examples/rust/echo/target/debug/generate_golden --output-dir examples/echo-schema/golden + echo "" + echo "Goldens refreshed. Review the diff carefully — these are the wire-format" + echo "contract, and any byte-level change is a breaking change for external" + echo "implementations of the schema." +} + +function test_cmds { + # Discover which languages can participate. Rust/TS/Zig are unconditional + # (build() always builds them); C++ is conditional on bb's msgpack being present. + local matrix_langs=(rust ts zig) + if [ -x examples/cpp/echo/echo_server ]; then + matrix_langs+=(cpp) + fi + + local prefix="$hash:CPUS=1:TIMEOUT=120s" + local script="ipc-codegen/examples/scripts/run_cross_language_test.sh" + + # Golden tests (Rust + TS each verify they can deserialize the goldens + # baked by build()). + echo "$prefix $script golden rust" + echo "$prefix $script golden ts" + + # Matrix: one command per (server, client) pair. + for server in "${matrix_langs[@]}"; do + for client in "${matrix_langs[@]}"; do + echo "$prefix $script matrix $server $client" + done + done +} + +function test { + echo_header "ipc-codegen test" + test_cmds | filter_test_cmds | parallelize +} + +case "$cmd" in + "") + build + ;; + "hash") + echo "$hash" + ;; + *) + default_cmd_handler "$@" + ;; +esac diff --git a/ipc-codegen/examples/cpp/echo/echo_client.cpp b/ipc-codegen/examples/cpp/echo/echo_client.cpp new file mode 100644 index 000000000000..946c8156eb46 --- /dev/null +++ b/ipc-codegen/examples/cpp/echo/echo_client.cpp @@ -0,0 +1,49 @@ +// Echo IPC client (C++) — uses the generated EchoIpcClient. +// Usage: echo_client --socket /tmp/echo.sock + +#include "generated/echo_ipc_client.hpp" + +#include +#include +#include + +int main(int argc, char **argv) { + const char *socket_path = nullptr; + for (int i = 1; i < argc - 1; i++) { + if (std::string_view(argv[i]) == "--socket") + socket_path = argv[i + 1]; + } + if (!socket_path) { + std::cerr << "Usage: echo_client --socket \n"; + return 1; + } + + echo::EchoIpcClient client(socket_path); + + { + auto resp = client.bytes({.data = {0xDE, 0xAD, 0xBE, 0xEF, 0x42}}); + assert((resp.data == std::vector{0xDE, 0xAD, 0xBE, 0xEF, 0x42})); + std::cerr << "echo_client(cpp): EchoBytes OK\n"; + } + + { + auto resp = + client.fields({.a = 42, .b = 999999, .name = "hello wire compat"}); + assert(resp.a == 42 && resp.b == 999999 && + resp.name == "hello wire compat"); + std::cerr << "echo_client(cpp): EchoFields OK\n"; + } + + { + auto resp = + client.nested({.inner = {.values = {{1, 2, 3}, {4, 5}}, .flag = true}}); + assert((resp.inner.values == + std::vector>{{1, 2, 3}, {4, 5}})); + assert(resp.inner.flag == true); + std::cerr << "echo_client(cpp): EchoNested OK\n"; + } + + client.shutdown(); + std::cerr << "echo_client(cpp): all tests passed\n"; + return 0; +} diff --git a/ipc-codegen/examples/cpp/echo/echo_server.cpp b/ipc-codegen/examples/cpp/echo/echo_server.cpp new file mode 100644 index 000000000000..3c640cd73897 --- /dev/null +++ b/ipc-codegen/examples/cpp/echo/echo_server.cpp @@ -0,0 +1,48 @@ +// Echo IPC server (C++) — provides handler specializations for the +// header-only generated dispatch. +// Usage: echo_server --socket /tmp/echo.sock + +#include "generated/echo_ipc_server.hpp" + +#include +#include + +namespace echo { + +struct EchoCtx {}; // empty context for the echo service + +// Template specializations — echo input fields back in response. +template <> +wire::EchoBytesResponse handle_bytes(EchoCtx & /*ctx*/, wire::EchoBytes &&cmd) { + return {.data = std::move(cmd.data)}; +} + +template <> +wire::EchoFieldsResponse handle_fields(EchoCtx & /*ctx*/, + wire::EchoFields &&cmd) { + return {.a = cmd.a, .b = cmd.b, .name = std::move(cmd.name)}; +} + +template <> +wire::EchoNestedResponse handle_nested(EchoCtx & /*ctx*/, + wire::EchoNested &&cmd) { + return {.inner = std::move(cmd.inner)}; +} + +} // namespace echo + +int main(int argc, char **argv) { + const char *socket_path = nullptr; + for (int i = 1; i < argc - 1; i++) { + if (std::string_view(argv[i]) == "--socket") + socket_path = argv[i + 1]; + } + if (!socket_path) { + std::cerr << "Usage: echo_server --socket \n"; + return 1; + } + + echo::EchoCtx ctx; + echo::serve(socket_path, ctx); + return 0; +} diff --git a/ipc-codegen/examples/echo-schema/generate.sh b/ipc-codegen/examples/echo-schema/generate.sh new file mode 100755 index 000000000000..280425ce4876 --- /dev/null +++ b/ipc-codegen/examples/echo-schema/generate.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +# Generate echo service types for all 4 languages from schema.json. +# Uses the codegen CLI — same as any service developer would. +set -euo pipefail + +DIR="$(cd "$(dirname "$0")" && pwd)" +EXAMPLES="$(cd "$DIR/.." && pwd)" +CODEGEN="$(cd "$DIR/../.." && pwd)" +NODE="node --experimental-strip-types --experimental-transform-types --no-warnings" +GEN="$CODEGEN/src/generate.ts" +SCHEMA="$DIR/schema.json" + +echo "Generating echo types from $SCHEMA" + +$NODE "$GEN" --schema "$SCHEMA" --lang cpp --server --client --uds --out "$EXAMPLES/cpp/echo/generated" --prefix Echo --cpp-namespace echo +$NODE "$GEN" --schema "$SCHEMA" --lang ts --server --client --uds --out "$EXAMPLES/ts/echo/generated" --prefix Echo +$NODE "$GEN" --schema "$SCHEMA" --lang rust --server --client --uds --out "$EXAMPLES/rust/echo/src/generated" --prefix Echo +$NODE "$GEN" --schema "$SCHEMA" --lang zig --server --client --uds --out "$EXAMPLES/zig/echo/generated" --prefix Echo + +echo "Done." diff --git a/ipc-codegen/examples/echo-schema/golden/echo_bytes_bin16.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_bytes_bin16.msgpack new file mode 100644 index 000000000000..a24108950f18 Binary files /dev/null and b/ipc-codegen/examples/echo-schema/golden/echo_bytes_bin16.msgpack differ diff --git a/ipc-codegen/examples/echo-schema/golden/echo_bytes_empty.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_bytes_empty.msgpack new file mode 100644 index 000000000000..08696c9d133f Binary files /dev/null and b/ipc-codegen/examples/echo-schema/golden/echo_bytes_empty.msgpack differ diff --git a/ipc-codegen/examples/echo-schema/golden/echo_bytes_request.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_bytes_request.msgpack new file mode 100644 index 000000000000..fbd6f3e3f32d --- /dev/null +++ b/ipc-codegen/examples/echo-schema/golden/echo_bytes_request.msgpack @@ -0,0 +1 @@ +EchoBytesdataޭB \ No newline at end of file diff --git a/ipc-codegen/examples/echo-schema/golden/echo_bytes_response.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_bytes_response.msgpack new file mode 100644 index 000000000000..f15b07218068 --- /dev/null +++ b/ipc-codegen/examples/echo-schema/golden/echo_bytes_response.msgpack @@ -0,0 +1 @@ +EchoBytesResponsedataޭB \ No newline at end of file diff --git a/ipc-codegen/examples/echo-schema/golden/echo_fields_max.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_fields_max.msgpack new file mode 100644 index 000000000000..46fa67c1a04a --- /dev/null +++ b/ipc-codegen/examples/echo-schema/golden/echo_fields_max.msgpack @@ -0,0 +1 @@ +EchoFieldsabname \ No newline at end of file diff --git a/ipc-codegen/examples/echo-schema/golden/echo_fields_request.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_fields_request.msgpack new file mode 100644 index 000000000000..d72172943bc0 Binary files /dev/null and b/ipc-codegen/examples/echo-schema/golden/echo_fields_request.msgpack differ diff --git a/ipc-codegen/examples/echo-schema/golden/echo_fields_response.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_fields_response.msgpack new file mode 100644 index 000000000000..1b2aba194ac4 Binary files /dev/null and b/ipc-codegen/examples/echo-schema/golden/echo_fields_response.msgpack differ diff --git a/ipc-codegen/examples/echo-schema/golden/echo_fields_str16.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_fields_str16.msgpack new file mode 100644 index 000000000000..69649cf16c88 Binary files /dev/null and b/ipc-codegen/examples/echo-schema/golden/echo_fields_str16.msgpack differ diff --git a/ipc-codegen/examples/echo-schema/golden/echo_fields_uint_boundary.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_fields_uint_boundary.msgpack new file mode 100644 index 000000000000..9f17a3ebe15b Binary files /dev/null and b/ipc-codegen/examples/echo-schema/golden/echo_fields_uint_boundary.msgpack differ diff --git a/ipc-codegen/examples/echo-schema/golden/echo_fields_unicode.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_fields_unicode.msgpack new file mode 100644 index 000000000000..0691f2bfbd58 Binary files /dev/null and b/ipc-codegen/examples/echo-schema/golden/echo_fields_unicode.msgpack differ diff --git a/ipc-codegen/examples/echo-schema/golden/echo_nested_flag_false.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_nested_flag_false.msgpack new file mode 100644 index 000000000000..30af0cb49794 Binary files /dev/null and b/ipc-codegen/examples/echo-schema/golden/echo_nested_flag_false.msgpack differ diff --git a/ipc-codegen/examples/echo-schema/golden/echo_nested_flag_none.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_nested_flag_none.msgpack new file mode 100644 index 000000000000..b5d2addb83d6 --- /dev/null +++ b/ipc-codegen/examples/echo-schema/golden/echo_nested_flag_none.msgpack @@ -0,0 +1 @@ +EchoNestedinnervaluesflag \ No newline at end of file diff --git a/ipc-codegen/examples/echo-schema/golden/echo_nested_request.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_nested_request.msgpack new file mode 100644 index 000000000000..6c8ded7184ed --- /dev/null +++ b/ipc-codegen/examples/echo-schema/golden/echo_nested_request.msgpack @@ -0,0 +1 @@ +EchoNestedinnervaluesflag \ No newline at end of file diff --git a/ipc-codegen/examples/echo-schema/golden/echo_nested_response.msgpack b/ipc-codegen/examples/echo-schema/golden/echo_nested_response.msgpack new file mode 100644 index 000000000000..c8966a9d4f46 --- /dev/null +++ b/ipc-codegen/examples/echo-schema/golden/echo_nested_response.msgpack @@ -0,0 +1 @@ +EchoNestedResponseinnervaluesflag \ No newline at end of file diff --git a/ipc-codegen/examples/echo-schema/schema.json b/ipc-codegen/examples/echo-schema/schema.json new file mode 100644 index 000000000000..47b6755e8ea9 --- /dev/null +++ b/ipc-codegen/examples/echo-schema/schema.json @@ -0,0 +1,52 @@ +{ + "commands": ["named_union", [ + ["EchoBytes", { + "__typename": "EchoBytes", + "data": ["vector", ["unsigned char"]] + }], + ["EchoFields", { + "__typename": "EchoFields", + "a": "unsigned int", + "b": "unsigned long", + "name": "string" + }], + ["EchoNested", { + "__typename": "EchoNested", + "inner": { + "__typename": "EchoInner", + "values": ["vector", [["vector", ["unsigned char"]]]], + "flag": ["optional", ["bool"]] + } + }], + ["EchoShutdown", { + "__typename": "EchoShutdown" + }] + ]], + "responses": ["named_union", [ + ["EchoBytesResponse", { + "__typename": "EchoBytesResponse", + "data": ["vector", ["unsigned char"]] + }], + ["EchoFieldsResponse", { + "__typename": "EchoFieldsResponse", + "a": "unsigned int", + "b": "unsigned long", + "name": "string" + }], + ["EchoNestedResponse", { + "__typename": "EchoNestedResponse", + "inner": { + "__typename": "EchoInner", + "values": ["vector", [["vector", ["unsigned char"]]]], + "flag": ["optional", ["bool"]] + } + }], + ["EchoShutdownResponse", { + "__typename": "EchoShutdownResponse" + }], + ["EchoErrorResponse", { + "__typename": "EchoErrorResponse", + "message": "string" + }] + ]] +} diff --git a/ipc-codegen/examples/rust/echo/Cargo.lock b/ipc-codegen/examples/rust/echo/Cargo.lock new file mode 100644 index 000000000000..e121ba639343 --- /dev/null +++ b/ipc-codegen/examples/rust/echo/Cargo.lock @@ -0,0 +1,131 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "echo-wire-compat" +version = "0.1.0" +dependencies = [ + "rmp-serde", + "serde", + "thiserror", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" diff --git a/ipc-codegen/examples/rust/echo/Cargo.toml b/ipc-codegen/examples/rust/echo/Cargo.toml new file mode 100644 index 000000000000..321f54c6fd8d --- /dev/null +++ b/ipc-codegen/examples/rust/echo/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "echo-wire-compat" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "echo_server" +path = "src/echo_server.rs" + +[[bin]] +name = "echo_client" +path = "src/echo_client.rs" + +[dependencies] +rmp-serde = "1.1" +serde = { version = "1.0", features = ["derive"] } +thiserror = "1.0" diff --git a/ipc-codegen/examples/rust/echo/src/bin/generate_golden.rs b/ipc-codegen/examples/rust/echo/src/bin/generate_golden.rs new file mode 100644 index 000000000000..bacb28c7355d --- /dev/null +++ b/ipc-codegen/examples/rust/echo/src/bin/generate_golden.rs @@ -0,0 +1,167 @@ +//! Generate golden msgpack files for wire compatibility testing. +//! Usage: generate_golden --output-dir golden/ +//! +//! The goldens are a binding wire-format contract: any new implementation +//! of the echo service (in any language) must decode these bytes into the +//! expected values, and re-encode the same inputs back to byte-identical +//! output. They cover the msgpack encoding boundaries that codegen tweaks +//! are most likely to silently break: +//! +//! - Variable-width integer encodings (fixint / uint8 / uint16 / uint32 / uint64) +//! - String encodings (fixstr / str8 / str16) plus multi-byte UTF-8 +//! - Bin encodings (bin8 / bin16) +//! - Optional = Some vs None +//! - Empty containers + +use echo_wire_compat::types_gen::*; +use std::fs; +use std::path::Path; + +fn main() { + let args: Vec = std::env::args().collect(); + let output_dir = args + .iter() + .position(|a| a == "--output-dir") + .and_then(|i| args.get(i + 1)) + .expect("Usage: generate_golden --output-dir "); + + fs::create_dir_all(output_dir).unwrap(); + + // ---------------------------------------------------------------------- + // Original happy-path cases. + // ---------------------------------------------------------------------- + write_request( + output_dir, + "echo_bytes_request.msgpack", + Command::EchoBytes(EchoBytes::new(vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42])), + ); + + write_request( + output_dir, + "echo_fields_request.msgpack", + Command::EchoFields(EchoFields::new(42, 999999, "hello wire compat".to_string())), + ); + + write_request( + output_dir, + "echo_nested_request.msgpack", + Command::EchoNested(EchoNested::new(EchoInner { + values: vec![vec![1, 2, 3], vec![4, 5]], + flag: Some(true), + })), + ); + + write_response( + output_dir, + "echo_bytes_response.msgpack", + Response::EchoBytesResponse(EchoBytesResponse { + data: vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42], + }), + ); + + write_response( + output_dir, + "echo_fields_response.msgpack", + Response::EchoFieldsResponse(EchoFieldsResponse { + a: 42, + b: 999999, + name: "hello wire compat".to_string(), + }), + ); + + write_response( + output_dir, + "echo_nested_response.msgpack", + Response::EchoNestedResponse(EchoNestedResponse { + inner: EchoInner { + values: vec![vec![1, 2, 3], vec![4, 5]], + flag: Some(true), + }, + }), + ); + + // ---------------------------------------------------------------------- + // Boundary cases — these are what catch silent format regressions. + // ---------------------------------------------------------------------- + + // Empty Vec. bin8-with-len-0 vs bin16-with-len-0 vs absent — picks one. + write_request( + output_dir, + "echo_bytes_empty.msgpack", + Command::EchoBytes(EchoBytes::new(vec![])), + ); + + // 256-byte Vec. Crosses the bin8 → bin16 boundary (bin8 max is 255). + write_request( + output_dir, + "echo_bytes_bin16.msgpack", + Command::EchoBytes(EchoBytes::new(vec![0xAA; 256])), + ); + + // u32::MAX (= 2^32 - 1) and u64::MAX. Largest uint encodings; empty string + // exercises fixstr-len-0 framing. + write_request( + output_dir, + "echo_fields_max.msgpack", + Command::EchoFields(EchoFields::new(u32::MAX, u64::MAX, String::new())), + ); + + // u32 = 128 (smallest uint8) and u64 above u32::MAX (forces uint64 encoding). + write_request( + output_dir, + "echo_fields_uint_boundary.msgpack", + Command::EchoFields(EchoFields::new(128, (u32::MAX as u64) + 1, "x".to_string())), + ); + + // Multi-byte UTF-8 in name. Catches encoders that mistakenly count bytes + // by char-count, or that switch str/bin tags depending on content. + write_request( + output_dir, + "echo_fields_unicode.msgpack", + Command::EchoFields(EchoFields::new(0, 0, "héllo τέστ 🚀 mañana".to_string())), + ); + + // 300-char ASCII string. Crosses fixstr (≤31) → str8 (≤255) → str16 boundary. + write_request( + output_dir, + "echo_fields_str16.msgpack", + Command::EchoFields(EchoFields::new(0, 0, "a".repeat(300))), + ); + + // Optional = None plus empty outer Vec>. + write_request( + output_dir, + "echo_nested_flag_none.msgpack", + Command::EchoNested(EchoNested::new(EchoInner { + values: vec![], + flag: None, + })), + ); + + // Optional = Some(false) plus a Vec> containing an empty inner. + write_request( + output_dir, + "echo_nested_flag_false.msgpack", + Command::EchoNested(EchoNested::new(EchoInner { + values: vec![vec![]], + flag: Some(false), + })), + ); + + eprintln!("Generated golden files in {}", output_dir); +} + +fn write_request(dir: &str, name: &str, command: Command) { + let value = vec![command]; + let bytes = rmp_serde::to_vec_named(&value).unwrap(); + let path = Path::new(dir).join(name); + fs::write(&path, &bytes).unwrap(); + eprintln!(" {} ({} bytes)", name, bytes.len()); +} + +fn write_response(dir: &str, name: &str, response: Response) { + let bytes = rmp_serde::to_vec_named(&response).unwrap(); + let path = Path::new(dir).join(name); + fs::write(&path, &bytes).unwrap(); + eprintln!(" {} ({} bytes)", name, bytes.len()); +} diff --git a/ipc-codegen/examples/rust/echo/src/bin/golden_test.rs b/ipc-codegen/examples/rust/echo/src/bin/golden_test.rs new file mode 100644 index 000000000000..257723ac4627 --- /dev/null +++ b/ipc-codegen/examples/rust/echo/src/bin/golden_test.rs @@ -0,0 +1,253 @@ +//! Golden file wire-format conformance test (Rust). +//! For each golden file, asserts: +//! 1. We can decode the bytes into the expected typed value. +//! 2. Re-encoding the same value produces byte-identical output. +//! The combination pins down the wire format as a binding contract. + +use echo_wire_compat::types_gen::*; +use std::fs; + +fn main() { + let args: Vec = std::env::args().collect(); + let dir = args + .iter() + .position(|a| a == "--golden-dir") + .and_then(|i| args.get(i + 1)) + .expect("Usage: golden_test --golden-dir "); + + let mut pass = 0; + let mut fail = 0; + + // Helpers close over (pass, fail) via outparams. + let bytes_eq = |a: &[u8], b: &[u8]| -> bool { a == b }; + + // ------ Request goldens (wire format: Vec) ------ + + macro_rules! check_request { + ($file:expr, $variant:ident, $expect_check:expr) => {{ + let path = format!("{dir}/{}", $file); + match fs::read(&path) { + Err(e) => { eprintln!(" FAIL: {}: read: {e}", $file); fail += 1; } + Ok(bytes) => { + match rmp_serde::from_slice::>(&bytes) { + Err(e) => { eprintln!(" FAIL: {}: decode: {e}", $file); fail += 1; } + Ok(cmds) if cmds.len() != 1 => { + eprintln!(" FAIL: {}: expected 1 command, got {}", $file, cmds.len()); + fail += 1; + } + Ok(cmds) => match cmds.into_iter().next().unwrap() { + Command::$variant(v) => { + let check_fn: fn(&_) -> Result<(), String> = $expect_check; + if let Err(e) = check_fn(&v) { + eprintln!(" FAIL: {}: {e}", $file); + fail += 1; + } else { + // Roundtrip: re-encode and compare bytes. + let re = rmp_serde::to_vec_named(&vec![Command::$variant(v)]).unwrap(); + if !bytes_eq(&re, &bytes) { + eprintln!(" FAIL: {}: roundtrip byte mismatch ({} vs {} bytes)", + $file, re.len(), bytes.len()); + fail += 1; + } else { + eprintln!(" PASS: {}", $file); + pass += 1; + } + } + } + other => { + eprintln!(" FAIL: {}: wrong variant ({:?})", $file, std::mem::discriminant(&other)); + fail += 1; + } + } + } + } + } + }}; + } + + // ------ Response goldens (wire format: Response, NamedUnion) ------ + + macro_rules! check_response { + ($file:expr, $variant:ident, $expect_check:expr) => {{ + let path = format!("{dir}/{}", $file); + match fs::read(&path) { + Err(e) => { + eprintln!(" FAIL: {}: read: {e}", $file); + fail += 1; + } + Ok(bytes) => match rmp_serde::from_slice::(&bytes) { + Err(e) => { + eprintln!(" FAIL: {}: decode: {e}", $file); + fail += 1; + } + Ok(Response::$variant(v)) => { + let check_fn: fn(&_) -> Result<(), String> = $expect_check; + if let Err(e) = check_fn(&v) { + eprintln!(" FAIL: {}: {e}", $file); + fail += 1; + } else { + let re = rmp_serde::to_vec_named(&Response::$variant(v)).unwrap(); + if !bytes_eq(&re, &bytes) { + eprintln!(" FAIL: {}: roundtrip byte mismatch", $file); + fail += 1; + } else { + eprintln!(" PASS: {}", $file); + pass += 1; + } + } + } + Ok(_) => { + eprintln!(" FAIL: {}: wrong variant", $file); + fail += 1; + } + }, + } + }}; + } + + // ============ Original happy-path cases ============ + + check_request!("echo_bytes_request.msgpack", EchoBytes, |v: &EchoBytes| { + if v.data != vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42] { + Err("data".into()) + } else { + Ok(()) + } + }); + check_request!( + "echo_fields_request.msgpack", + EchoFields, + |v: &EchoFields| { + if v.a != 42 || v.b != 999999 || v.name != "hello wire compat" { + Err("fields".into()) + } else { + Ok(()) + } + } + ); + check_request!( + "echo_nested_request.msgpack", + EchoNested, + |v: &EchoNested| { + if v.inner.values != vec![vec![1u8, 2, 3], vec![4, 5]] || v.inner.flag != Some(true) { + Err("nested".into()) + } else { + Ok(()) + } + } + ); + + check_response!( + "echo_bytes_response.msgpack", + EchoBytesResponse, + |v: &EchoBytesResponse| { + if v.data != vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42] { + Err("data".into()) + } else { + Ok(()) + } + } + ); + check_response!( + "echo_fields_response.msgpack", + EchoFieldsResponse, + |v: &EchoFieldsResponse| { + if v.a != 42 || v.b != 999999 || v.name != "hello wire compat" { + Err("fields".into()) + } else { + Ok(()) + } + } + ); + check_response!( + "echo_nested_response.msgpack", + EchoNestedResponse, + |v: &EchoNestedResponse| { + if v.inner.values != vec![vec![1u8, 2, 3], vec![4, 5]] || v.inner.flag != Some(true) { + Err("nested".into()) + } else { + Ok(()) + } + } + ); + + // ============ Boundary cases ============ + + check_request!("echo_bytes_empty.msgpack", EchoBytes, |v: &EchoBytes| { + if !v.data.is_empty() { + Err(format!("expected empty, got {} bytes", v.data.len())) + } else { + Ok(()) + } + }); + check_request!("echo_bytes_bin16.msgpack", EchoBytes, |v: &EchoBytes| { + if v.data.len() != 256 || v.data.iter().any(|&b| b != 0xAA) { + Err("expected 256 x 0xAA".into()) + } else { + Ok(()) + } + }); + check_request!("echo_fields_max.msgpack", EchoFields, |v: &EchoFields| { + if v.a != u32::MAX || v.b != u64::MAX || !v.name.is_empty() { + Err("expected u32::MAX/u64::MAX/empty".into()) + } else { + Ok(()) + } + }); + check_request!( + "echo_fields_uint_boundary.msgpack", + EchoFields, + |v: &EchoFields| { + if v.a != 128 || v.b != (u32::MAX as u64) + 1 || v.name != "x" { + Err("expected 128/u32max+1/\"x\"".into()) + } else { + Ok(()) + } + } + ); + check_request!( + "echo_fields_unicode.msgpack", + EchoFields, + |v: &EchoFields| { + if v.name != "héllo τέστ 🚀 mañana" { + Err(format!("unicode mismatch: {:?}", v.name)) + } else { + Ok(()) + } + } + ); + check_request!("echo_fields_str16.msgpack", EchoFields, |v: &EchoFields| { + if v.name.len() != 300 || v.name.chars().any(|c| c != 'a') { + Err("expected 300 x 'a'".into()) + } else { + Ok(()) + } + }); + check_request!( + "echo_nested_flag_none.msgpack", + EchoNested, + |v: &EchoNested| { + if !v.inner.values.is_empty() || v.inner.flag.is_some() { + Err("expected empty values + flag=None".into()) + } else { + Ok(()) + } + } + ); + check_request!( + "echo_nested_flag_false.msgpack", + EchoNested, + |v: &EchoNested| { + if v.inner.values != vec![Vec::::new()] || v.inner.flag != Some(false) { + Err("expected [[]] + flag=Some(false)".into()) + } else { + Ok(()) + } + } + ); + + eprintln!("\nResults: {pass}/{} passed, {fail} failed", pass + fail); + if fail > 0 { + std::process::exit(1); + } +} diff --git a/ipc-codegen/examples/rust/echo/src/echo_client.rs b/ipc-codegen/examples/rust/echo/src/echo_client.rs new file mode 100644 index 000000000000..31928ce5c106 --- /dev/null +++ b/ipc-codegen/examples/rust/echo/src/echo_client.rs @@ -0,0 +1,47 @@ +//! Echo IPC client — uses GENERATED typed client (EchoApi) + UDS backend. +//! Usage: echo_client --socket /tmp/echo.sock +//! Exits 0 on success, 1 on failure. + +use echo_wire_compat::generated::echo_client::EchoApi; +use echo_wire_compat::generated::echo_types::EchoInner; +use echo_wire_compat::generated::error::Result; +use echo_wire_compat::generated::uds_backend::UdsBackend; + +fn main() -> Result<()> { + let args: Vec = std::env::args().collect(); + let socket_path = args.iter() + .position(|a| a == "--socket") + .and_then(|i| args.get(i + 1)) + .expect("Usage: echo_client --socket "); + + let backend = UdsBackend::connect(socket_path)?; + let mut client = EchoApi::new(backend); + + // Test 1: EchoBytes + let test_data = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]; + let resp = client.bytes(&test_data)?; + assert_eq!(resp.data, test_data, "EchoBytes data mismatch"); + eprintln!("echo_client(rust): EchoBytes OK"); + + // Test 2: EchoFields + let resp = client.fields(42, 999999, "hello wire compat".to_string())?; + assert_eq!(resp.a, 42); + assert_eq!(resp.b, 999999); + assert_eq!(resp.name, "hello wire compat"); + eprintln!("echo_client(rust): EchoFields OK"); + + // Test 3: EchoNested + let inner = EchoInner { + values: vec![vec![1, 2, 3], vec![4, 5]], + flag: Some(true), + }; + let resp = client.nested(inner.clone())?; + assert_eq!(resp.inner.values, inner.values); + assert_eq!(resp.inner.flag, inner.flag); + eprintln!("echo_client(rust): EchoNested OK"); + + // Shutdown + client.shutdown()?; + eprintln!("echo_client(rust): all tests passed"); + Ok(()) +} diff --git a/ipc-codegen/examples/rust/echo/src/echo_server.rs b/ipc-codegen/examples/rust/echo/src/echo_server.rs new file mode 100644 index 000000000000..df6c013abab3 --- /dev/null +++ b/ipc-codegen/examples/rust/echo/src/echo_server.rs @@ -0,0 +1,71 @@ +//! Echo IPC server — uses GENERATED dispatch + server template + types. +//! Usage: echo_server --socket /tmp/echo.sock + +use echo_wire_compat::generated::echo_server::Handler; +use echo_wire_compat::generated::echo_types::*; +use echo_wire_compat::generated::error::{IpcError, Result}; +use echo_wire_compat::generated::ipc_server; +use std::cell::RefCell; + +struct EchoHandler; + +impl Handler for EchoHandler { + fn bytes(&mut self, cmd: EchoBytes) -> Result { + Ok(EchoBytesResponse { data: cmd.data }) + } + fn fields(&mut self, cmd: EchoFields) -> Result { + Ok(EchoFieldsResponse { a: cmd.a, b: cmd.b, name: cmd.name }) + } + fn nested(&mut self, cmd: EchoNested) -> Result { + Ok(EchoNestedResponse { inner: cmd.inner }) + } +} + +fn main() -> Result<()> { + let args: Vec = std::env::args().collect(); + let socket_path = args.iter() + .position(|a| a == "--socket") + .and_then(|i| args.get(i + 1)) + .expect("Usage: echo_server --socket "); + + let _ = std::fs::remove_file(socket_path); + + // Wrap handler in RefCell so the Fn closure can borrow mutably. + let handler = RefCell::new(EchoHandler); + + ipc_server::serve(socket_path, |payload: &[u8]| { + // Deserialize: [Command] + let request: Vec = rmp_serde::from_slice(payload) + .unwrap_or_default(); + + let command = match request.into_iter().next() { + Some(cmd) => cmd, + None => { + let err = Response::EchoErrorResponse(EchoErrorResponse { + message: "empty request".to_string(), + }); + return rmp_serde::to_vec_named(&err).unwrap_or_default(); + } + }; + + // Check for shutdown before dispatch + let is_shutdown = matches!(&command, Command::EchoShutdown(_)); + + let response = match echo_wire_compat::generated::echo_server::dispatch( + &mut *handler.borrow_mut(), command + ) { + Ok(resp) => resp, + Err(_e) => { + if is_shutdown { + Response::EchoShutdownResponse(EchoShutdownResponse {}) + } else { + Response::EchoErrorResponse(EchoErrorResponse { + message: _e.to_string(), + }) + } + } + }; + + rmp_serde::to_vec_named(&response).unwrap_or_default() + }).map_err(|e| IpcError::Io(e)) +} diff --git a/ipc-codegen/examples/rust/echo/src/lib.rs b/ipc-codegen/examples/rust/echo/src/lib.rs new file mode 100644 index 000000000000..1fae1aba7198 --- /dev/null +++ b/ipc-codegen/examples/rust/echo/src/lib.rs @@ -0,0 +1,16 @@ +// Generated modules live in src/generated/ +pub mod generated { + pub mod echo_types; + pub mod echo_server; + pub mod echo_client; + pub mod backend; + pub mod error; + pub mod ipc_server; + pub mod uds_backend; +} + +// Re-export under the names that generated server/client code expects +// (they use `crate::types_gen`, `crate::error`, `crate::backend`) +pub use generated::echo_types as types_gen; +pub use generated::error; +pub use generated::backend; diff --git a/ipc-codegen/examples/scripts/run_cross_language_test.sh b/ipc-codegen/examples/scripts/run_cross_language_test.sh new file mode 100755 index 000000000000..7ab75449befa --- /dev/null +++ b/ipc-codegen/examples/scripts/run_cross_language_test.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash +# +# Run a single cross-language IPC wire-compat test. +# All binaries are expected to be prebuilt by `ipc-codegen/bootstrap.sh build`. +# +# Usage: +# run_cross_language_test.sh golden # lang in {rust, ts} +# run_cross_language_test.sh matrix +# # langs in {rust, ts, cpp, zig} +# +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +EXAMPLES_DIR="$(dirname "$SCRIPT_DIR")" +cd "$EXAMPLES_DIR" + +# Map language -> server command / client command. Each command is run with +# `--socket ` appended. +server_cmd_for() { + case "$1" in + rust) echo "rust/echo/target/debug/echo_server" ;; + ts) echo "ts/echo/node_modules/.bin/tsx ts/echo/echo_server.ts" ;; + cpp) echo "cpp/echo/echo_server" ;; + zig) echo "zig/echo/zig-out/bin/echo_server" ;; + *) echo "unknown lang: $1" >&2; exit 1 ;; + esac +} + +client_cmd_for() { + case "$1" in + rust) echo "rust/echo/target/debug/echo_client" ;; + ts) echo "ts/echo/node_modules/.bin/tsx ts/echo/echo_client.ts" ;; + cpp) echo "cpp/echo/echo_client" ;; + zig) echo "zig/echo/zig-out/bin/echo_client" ;; + *) echo "unknown lang: $1" >&2; exit 1 ;; + esac +} + +run_golden() { + local lang="$1" + case "$lang" in + rust) + rust/echo/target/debug/golden_test --golden-dir echo-schema/golden + ;; + ts) + ts/echo/node_modules/.bin/tsx ts/echo/golden_test.ts + ;; + *) + echo "golden tests only defined for rust and ts (got: $lang)" >&2 + exit 1 + ;; + esac +} + +run_matrix() { + local server_lang="$1" + local client_lang="$2" + local server_cmd client_cmd + server_cmd=$(server_cmd_for "$server_lang") + client_cmd=$(client_cmd_for "$client_lang") + local socket="/tmp/echo-matrix-${server_lang}-${client_lang}-$$.sock" + + # Start server in background, wait for socket, run client. + $server_cmd --socket "$socket" & + local server_pid=$! + trap "kill $server_pid 2>/dev/null || true; rm -f $socket" EXIT + + # Generous timeout: under docker_isolate with CPUS=1, `npx tsx` cold start + # alone routinely takes several seconds. Native binaries (rust/c++/zig) bind + # the socket in <100ms either way, so this only stretches the worst-case wait. + for _ in $(seq 1 300); do + [ -S "$socket" ] && break + sleep 0.1 + done + if [ ! -S "$socket" ]; then + echo "server did not create socket within 30s" >&2 + exit 1 + fi + + $client_cmd --socket "$socket" +} + +kind="${1:-}" +case "$kind" in + golden) + run_golden "${2:?golden requires }" + ;; + matrix) + run_matrix "${2:?matrix requires }" "${3:?matrix requires }" + ;; + *) + echo "Usage: $0 golden | matrix " >&2 + exit 1 + ;; +esac diff --git a/ipc-codegen/examples/ts/echo/echo_client.ts b/ipc-codegen/examples/ts/echo/echo_client.ts new file mode 100644 index 000000000000..5d42daf5f04e --- /dev/null +++ b/ipc-codegen/examples/ts/echo/echo_client.ts @@ -0,0 +1,64 @@ +/** + * Echo IPC client (TypeScript) — uses GENERATED types + IPC client template. + * Usage: npx tsx echo_client.ts --socket /tmp/echo.sock + * Exits 0 on success, 1 on failure. + * + * Note: The generated AsyncApi client depends on barretenberg-specific interfaces + * (IMsgpackBackendAsync, BBApiException). For this standalone test we use the + * generated IpcClient template directly with raw call(). + */ +import { IpcClient } from './generated/ipc_client.js'; +import type { + EchoBytesResponse, EchoFieldsResponse, EchoNestedResponse, +} from './generated/echo_types.js'; + +const args = process.argv.slice(2); +const socketIdx = args.indexOf('--socket'); +const socketPath = socketIdx >= 0 ? args[socketIdx + 1] : undefined; +if (!socketPath) { + console.error('Usage: echo_client.ts --socket '); + process.exit(1); +} + +function assertEqual(actual: any, expected: any, label: string) { + const a = JSON.stringify(actual); + const e = JSON.stringify(expected); + if (a !== e) throw new Error(`${label}: expected ${e}, got ${a}`); +} + +async function run() { + const client = await IpcClient.connect(socketPath); + + // Test 1: EchoBytes + const testData = Buffer.from([0xDE, 0xAD, 0xBE, 0xEF, 0x42]); + const [name1, resp1] = await client.call('EchoBytes', { data: testData }) as [string, EchoBytesResponse]; + assertEqual(name1, 'EchoBytesResponse', 'EchoBytes name'); + assertEqual(Buffer.from(resp1.data).toString('hex'), testData.toString('hex'), 'EchoBytes data'); + console.error('echo_client(ts): EchoBytes OK'); + + // Test 2: EchoFields + const [name2, resp2] = await client.call('EchoFields', { a: 42, b: 999999, name: 'hello wire compat' }) as [string, EchoFieldsResponse]; + assertEqual(name2, 'EchoFieldsResponse', 'EchoFields name'); + assertEqual(resp2.a, 42, 'EchoFields a'); + assertEqual(resp2.b, 999999, 'EchoFields b'); + assertEqual(resp2.name, 'hello wire compat', 'EchoFields name field'); + console.error('echo_client(ts): EchoFields OK'); + + // Test 3: EchoNested + const inner = { values: [Buffer.from([1, 2, 3]), Buffer.from([4, 5])], flag: true }; + const [name3, resp3] = await client.call('EchoNested', { inner }) as [string, EchoNestedResponse]; + assertEqual(name3, 'EchoNestedResponse', 'EchoNested name'); + assertEqual(resp3.inner.flag, true, 'EchoNested flag'); + assertEqual(resp3.inner.values.length, 2, 'EchoNested values length'); + console.error('echo_client(ts): EchoNested OK'); + + // Shutdown + await client.call('EchoShutdown', {}); + client.close(); + console.error('echo_client(ts): all tests passed'); +} + +run().catch(e => { + console.error(`echo_client(ts): FAILED: ${e.message}`); + process.exit(1); +}); diff --git a/ipc-codegen/examples/ts/echo/echo_server.ts b/ipc-codegen/examples/ts/echo/echo_server.ts new file mode 100644 index 000000000000..9b9336a20ac6 --- /dev/null +++ b/ipc-codegen/examples/ts/echo/echo_server.ts @@ -0,0 +1,35 @@ +/** + * Echo IPC server (TypeScript) — uses GENERATED dispatch + IPC server template. + * Usage: npx tsx echo_server.ts --socket /tmp/echo.sock + */ +import { createServer } from './generated/ipc_server.js'; +import { dispatch } from './generated/server.js'; +import type { Handler } from './generated/server.js'; +import type { + EchoBytes, EchoBytesResponse, + EchoFields, EchoFieldsResponse, + EchoNested, EchoNestedResponse, +} from './generated/echo_types.js'; + +const args = process.argv.slice(2); +const socketIdx = args.indexOf('--socket'); +const socketPath = socketIdx >= 0 ? args[socketIdx + 1] : undefined; +if (!socketPath) { + console.error('Usage: echo_server.ts --socket '); + process.exit(1); +} + +// Implement the GENERATED Handler interface — echo everything back +const handler: Handler = { + async echoBytes(cmd: EchoBytes): Promise { + return { data: cmd.data }; + }, + async echoFields(cmd: EchoFields): Promise { + return { a: cmd.a, b: cmd.b, name: cmd.name }; + }, + async echoNested(cmd: EchoNested): Promise { + return { inner: cmd.inner }; + }, +}; + +createServer(socketPath, (commandName, payload) => dispatch(handler, commandName, payload)); diff --git a/ipc-codegen/examples/ts/echo/golden_test.ts b/ipc-codegen/examples/ts/echo/golden_test.ts new file mode 100644 index 000000000000..7bade9e64c32 --- /dev/null +++ b/ipc-codegen/examples/ts/echo/golden_test.ts @@ -0,0 +1,208 @@ +/** + * Golden file wire-format conformance test (TypeScript). + * + * For each golden file, asserts: + * 1. We can decode the bytes into the expected typed value. + * 2. Re-encoding the same value produces byte-identical output. + * The combination pins down the wire format as a binding contract. + * + * Usage: npx tsx golden_test.ts + */ + +import * as fs from "node:fs"; +import * as path from "node:path"; +import { Decoder, Encoder } from "msgpackr"; + +const decoder = new Decoder({ useRecords: false }); +// `variableMapSize: true` makes msgpackr emit fixmap (1-byte header) for small +// maps instead of always reaching for map16. Without it the encoder produces +// a semantically-equivalent but byte-different encoding, so round-tripping +// the goldens would fail even though the wire is otherwise correct. +const encoder = new Encoder({ useRecords: false, variableMapSize: true }); +const goldenDir = path.join( + import.meta.dirname!, + "../../echo-schema", + "golden", +); + +let pass = 0; +let fail = 0; + +function bytesEqual(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (a[i] !== b[i]) return false; + } + return true; +} + +function deepEqual(a: any, b: any): boolean { + // For our test data: bigints, strings, numbers, plain arrays of u8 (which + // msgpackr decodes as Uint8Array), and nested objects. The JSON-stringify + // trick falls down on bigint and Uint8Array; do a structural walk. + if (a === b) return true; + if (typeof a === "bigint" || typeof b === "bigint") return a === b; + if (a instanceof Uint8Array && b instanceof Uint8Array) + return bytesEqual(a, b); + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + return a.every((x, i) => deepEqual(x, b[i])); + } + if (a && b && typeof a === "object" && typeof b === "object") { + const ka = Object.keys(a).sort(); + const kb = Object.keys(b).sort(); + if (ka.length !== kb.length || !ka.every((k, i) => k === kb[i])) + return false; + return ka.every((k) => deepEqual(a[k], b[k])); + } + return false; +} + +/** Read golden, decode, check expectation, and (optionally) verify re-encode + * byte-equals golden. Strict roundtrip is the binding wire-format check, but + * msgpackr has one known divergence from rmp-serde: positive bigints are + * encoded as int64 (`d3`) instead of uint64 (`cf`). Both encodings are + * accepted by every msgpack decoder we care about, so the wire is still + * interoperable — we just can't pin the bytes here. */ +function check( + file: string, + expectedDecoded: any, + opts: { strictRoundtrip?: boolean } = {}, +) { + const strictRoundtrip = opts.strictRoundtrip ?? true; + try { + const golden = fs.readFileSync(path.join(goldenDir, file)); + const decoded = decoder.unpack(golden); + if (!deepEqual(decoded, expectedDecoded)) { + throw new Error( + `decoded mismatch:\n got: ${stringify(decoded)}\n exp: ${stringify(expectedDecoded)}`, + ); + } + if (strictRoundtrip) { + const reencoded = encoder.encode(decoded); + if (!bytesEqual(reencoded, golden)) { + throw new Error( + `roundtrip byte mismatch (decoded OK but re-encoded ${reencoded.length} bytes vs golden ${golden.length})`, + ); + } + } + console.log(` PASS: ${file}`); + pass++; + } catch (e: any) { + console.log(` FAIL: ${file}: ${e.message}`); + fail++; + } +} + +function stringify(v: any): string { + return JSON.stringify(v, (_k, x) => { + if (typeof x === "bigint") return `${x}n`; + if (x instanceof Uint8Array) return `[${Array.from(x).join(",")}]`; + return x; + }); +} + +console.log("Golden file wire-format conformance tests (TypeScript):\n"); + +// Request format: [[CommandName, {fields}]] +function req(cmdName: string, fields: any) { + return [[cmdName, fields]]; +} +// Response format: [ResponseName, {fields}] +function resp(respName: string, fields: any) { + return [respName, fields]; +} + +// ============ Original happy-path cases ============ +check( + "echo_bytes_request.msgpack", + req("EchoBytes", { data: new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x42]) }), +); +check( + "echo_fields_request.msgpack", + req("EchoFields", { a: 42, b: 999999, name: "hello wire compat" }), +); +check( + "echo_nested_request.msgpack", + req("EchoNested", { + inner: { + values: [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5])], + flag: true, + }, + }), +); + +check( + "echo_bytes_response.msgpack", + resp("EchoBytesResponse", { + data: new Uint8Array([0xde, 0xad, 0xbe, 0xef, 0x42]), + }), +); +check( + "echo_fields_response.msgpack", + resp("EchoFieldsResponse", { a: 42, b: 999999, name: "hello wire compat" }), +); +check( + "echo_nested_response.msgpack", + resp("EchoNestedResponse", { + inner: { + values: [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5])], + flag: true, + }, + }), +); + +// ============ Boundary cases ============ + +// bin8 empty + bin16 (256 bytes) — bin8/bin16 framing boundary. +check("echo_bytes_empty.msgpack", req("EchoBytes", { data: new Uint8Array() })); +check( + "echo_bytes_bin16.msgpack", + req("EchoBytes", { data: new Uint8Array(256).fill(0xaa) }), +); + +// u32::MAX + u64::MAX + empty string. Largest uint encodings; fixstr-len-0. +// msgpackr decodes u64 fields as bigint when > Number.MAX_SAFE_INTEGER (2^53-1). +// Strict roundtrip is OK here because u64::MAX requires uint64 and msgpackr +// agrees with rmp-serde at the extreme. +check( + "echo_fields_max.msgpack", + req("EchoFields", { a: 4294967295, b: 18446744073709551615n, name: "" }), +); + +// u32 = 128 (fixint → uint8 boundary), u64 above u32::MAX (forces uint64). +// strictRoundtrip: false — see check()'s comment about the bigint/uint64 quirk. +check( + "echo_fields_uint_boundary.msgpack", + req("EchoFields", { a: 128, b: 4294967296n, name: "x" }), + { strictRoundtrip: false }, +); + +// Multi-byte UTF-8 in name. +check( + "echo_fields_unicode.msgpack", + req("EchoFields", { a: 0, b: 0, name: "héllo τέστ 🚀 mañana" }), +); + +// 300-char ASCII string. Crosses fixstr (≤31) and str8 (≤255) into str16. +check( + "echo_fields_str16.msgpack", + req("EchoFields", { a: 0, b: 0, name: "a".repeat(300) }), +); + +// Optional = absent (msgpackr decodes missing-with-nil to undefined or +// strips the key entirely depending on the encoder; rmp-serde emits a nil +// value for None inside a struct, so we expect flag: null here). +check( + "echo_nested_flag_none.msgpack", + req("EchoNested", { inner: { values: [], flag: null } }), +); + +// Optional = Some(false) with values=[empty inner]. +check( + "echo_nested_flag_false.msgpack", + req("EchoNested", { inner: { values: [new Uint8Array()], flag: false } }), +); + +console.log(`\nResults: ${pass}/${pass + fail} passed, ${fail} failed`); +if (fail > 0) process.exit(1); diff --git a/ipc-codegen/examples/ts/echo/package.json b/ipc-codegen/examples/ts/echo/package.json new file mode 100644 index 000000000000..b8b506bd9c24 --- /dev/null +++ b/ipc-codegen/examples/ts/echo/package.json @@ -0,0 +1,9 @@ +{ + "name": "echo-wire-compat-ts", + "private": true, + "type": "module", + "dependencies": { + "msgpackr": "^1.10.0", + "tsx": "^4.19.0" + } +} diff --git a/ipc-codegen/examples/zig/echo/build.zig b/ipc-codegen/examples/zig/echo/build.zig new file mode 100644 index 000000000000..bf3f9a6f61d5 --- /dev/null +++ b/ipc-codegen/examples/zig/echo/build.zig @@ -0,0 +1,36 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const msgpack_dep = b.dependency("zig_msgpack", .{ + .target = target, + .optimize = optimize, + }); + const msgpack_mod = msgpack_dep.module("msgpack"); + + // Echo server + const server_exe = b.addExecutable(.{ + .name = "echo_server", + .root_module = b.createModule(.{ + .root_source_file = b.path("echo_server.zig"), + .target = target, + .optimize = optimize, + }), + }); + server_exe.root_module.addImport("msgpack", msgpack_mod); + b.installArtifact(server_exe); + + // Echo client + const client_exe = b.addExecutable(.{ + .name = "echo_client", + .root_module = b.createModule(.{ + .root_source_file = b.path("echo_client.zig"), + .target = target, + .optimize = optimize, + }), + }); + client_exe.root_module.addImport("msgpack", msgpack_mod); + b.installArtifact(client_exe); +} diff --git a/ipc-codegen/examples/zig/echo/build.zig.zon b/ipc-codegen/examples/zig/echo/build.zig.zon new file mode 100644 index 000000000000..eca36acc8919 --- /dev/null +++ b/ipc-codegen/examples/zig/echo/build.zig.zon @@ -0,0 +1,24 @@ +.{ + .name = .echo_zig, + .version = "0.1.0", + .fingerprint = 0x15539c02bc3573e2, + .minimum_zig_version = "0.14.0", + .dependencies = .{ + // Pinned to the 0.0.14 tag (not heads/main) so the manifest hash is + // stable. heads/main drifts whenever zig-msgpack's main branch moves, + // which breaks `zig fetch` with a hash-mismatch error on CI runners + // that pull a different snapshot than the one this hash was captured + // against. + .zig_msgpack = .{ + .url = "https://github.com/zigcc/zig-msgpack/archive/refs/tags/0.0.17.tar.gz", + .hash = "zig_msgpack-0.0.14-evvueL5SBQACmim6j6klQ9wWIIG_UxGlPvVYdiNy0KT8", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "echo_server.zig", + "echo_client.zig", + "generated", + }, +} diff --git a/ipc-codegen/examples/zig/echo/echo_client.zig b/ipc-codegen/examples/zig/echo/echo_client.zig new file mode 100644 index 000000000000..9281ea21d764 --- /dev/null +++ b/ipc-codegen/examples/zig/echo/echo_client.zig @@ -0,0 +1,69 @@ +/// Echo IPC client (Zig) — uses GENERATED typed client + UDS backend. +/// Usage: echo_client --socket /tmp/echo.sock +const std = @import("std"); +const echo_client = @import("generated/echo_client.zig"); +const uds_backend = @import("generated/uds_backend.zig"); +const types = @import("generated/echo_types.zig"); + +pub fn main() !void { + var args = std.process.args(); + _ = args.next(); + var socket_path: ?[]const u8 = null; + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--socket")) { + socket_path = args.next(); + } + } + const path = socket_path orelse { + std.debug.print("Usage: echo_client --socket \n", .{}); + std.process.exit(1); + }; + + var backend = try uds_backend.UdsBackend.connect(path); + const EchoClient = echo_client.Client(uds_backend.UdsBackend); + var client = EchoClient.init(&backend); + + // Test 1: EchoBytes + { + const cmd = types.EchoBytes{ .data = &[_]u8{ 0xDE, 0xAD, 0xBE, 0xEF, 0x42 } }; + const resp = try client.bytes(cmd); + if (!std.mem.eql(u8, resp.data, &[_]u8{ 0xDE, 0xAD, 0xBE, 0xEF, 0x42 })) { + std.debug.print("echo_client(zig): EchoBytes FAIL\n", .{}); + std.process.exit(1); + } + std.debug.print("echo_client(zig): EchoBytes OK\n", .{}); + } + + // Test 2: EchoFields + { + const cmd = types.EchoFields{ .a = 42, .b = 999999, .name = "hello wire compat" }; + const resp = try client.fields(cmd); + if (resp.a != 42 or resp.b != 999999 or !std.mem.eql(u8, resp.name, "hello wire compat")) { + std.debug.print("echo_client(zig): EchoFields FAIL\n", .{}); + std.process.exit(1); + } + std.debug.print("echo_client(zig): EchoFields OK\n", .{}); + } + + // Test 3: EchoNested + { + const values = &[_][]const u8{ &[_]u8{ 1, 2, 3 }, &[_]u8{ 4, 5 } }; + const cmd = types.EchoNested{ + .inner = types.EchoInner{ .values = values, .flag = true }, + }; + const resp = try client.nested(cmd); + if (resp.inner.values.len != 2) { + std.debug.print("echo_client(zig): EchoNested FAIL\n", .{}); + std.process.exit(1); + } + if (resp.inner.flag != true) { + std.debug.print("echo_client(zig): EchoNested flag FAIL\n", .{}); + std.process.exit(1); + } + std.debug.print("echo_client(zig): EchoNested OK\n", .{}); + } + + // Shutdown + try client.shutdown(); + std.debug.print("echo_client(zig): all tests passed\n", .{}); +} diff --git a/ipc-codegen/examples/zig/echo/echo_server.zig b/ipc-codegen/examples/zig/echo/echo_server.zig new file mode 100644 index 000000000000..ad9071b39cb1 --- /dev/null +++ b/ipc-codegen/examples/zig/echo/echo_server.zig @@ -0,0 +1,63 @@ +/// Echo IPC server (Zig) — uses GENERATED types + IPC server template. +/// Hand-written dispatch + echo handlers. +/// Usage: echo_server --socket /tmp/echo.sock +const std = @import("std"); +const msgpack = @import("msgpack"); +const Payload = msgpack.Payload; +const types = @import("generated/echo_types.zig"); +const ipc_server = @import("generated/ipc_server.zig"); + +const alloc = std.heap.page_allocator; + +pub fn main() !void { + var args = std.process.args(); + _ = args.next(); + var socket_path: ?[]const u8 = null; + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--socket")) { + socket_path = args.next(); + } + } + const path = socket_path orelse { + std.debug.print("Usage: echo_server --socket \n", .{}); + return error.InvalidArgument; + }; + + try ipc_server.serve(path, dispatch); +} + +fn dispatch(cmd_name: []const u8, fields: Payload) ipc_server.DispatchResult { + // Shutdown + if (std.mem.eql(u8, cmd_name, "EchoShutdown")) { + return .{ .resp_name = "EchoShutdownResponse", .resp_payload = Payload.mapPayload(alloc) }; + } + + // EchoBytes — echo back + if (std.mem.eql(u8, cmd_name, "EchoBytes")) { + const cmd = types.EchoBytes.fromPayload(fields) catch return makeError("deser failed"); + const resp = types.EchoBytesResponse{ .data = cmd.data }; + return .{ .resp_name = "EchoBytesResponse", .resp_payload = resp.toPayload(alloc) }; + } + + // EchoFields — echo back + if (std.mem.eql(u8, cmd_name, "EchoFields")) { + const cmd = types.EchoFields.fromPayload(fields) catch return makeError("deser failed"); + const resp = types.EchoFieldsResponse{ .a = cmd.a, .b = cmd.b, .name = cmd.name }; + return .{ .resp_name = "EchoFieldsResponse", .resp_payload = resp.toPayload(alloc) }; + } + + // EchoNested — echo back + if (std.mem.eql(u8, cmd_name, "EchoNested")) { + const cmd = types.EchoNested.fromPayload(fields) catch return makeError("deser failed"); + const resp = types.EchoNestedResponse{ .inner = cmd.inner }; + return .{ .resp_name = "EchoNestedResponse", .resp_payload = resp.toPayload(alloc) }; + } + + return makeError("unknown command"); +} + +fn makeError(message: []const u8) ipc_server.DispatchResult { + var err_map = Payload.mapPayload(alloc); + err_map.mapPut("message", Payload.strToPayload(message, alloc) catch return .{ .resp_name = "EchoErrorResponse", .resp_payload = Payload.mapPayload(alloc) }) catch {}; + return .{ .resp_name = "EchoErrorResponse", .resp_payload = err_map }; +} diff --git a/ipc-codegen/src/cpp_codegen.ts b/ipc-codegen/src/cpp_codegen.ts new file mode 100644 index 000000000000..4f21192969da --- /dev/null +++ b/ipc-codegen/src/cpp_codegen.ts @@ -0,0 +1,1237 @@ +/** + * C++ IPC Client Code Generator + * + * Generates a C++ IPC client from a CompiledSchema. The generated client: + * - Connects to a server over Unix Domain Socket via ipc::IpcClient + * - Wraps each command in a NamedUnion, serializes with msgpack, sends, receives, deserializes + * - Has one method per command, returning the typed response + * + * Usage: + * const gen = new CppCodegen({ namespace: 'bb::cdb', prefix: 'Cdb' }); + * const header = gen.generateHeader(schema); + * const impl = gen.generateImpl(schema); + */ + +import type { + CompiledSchema, + Type, + Struct, + Field, + Command, +} from "./schema_visitor.ts"; +import { toSnakeCase } from "./naming.ts"; + +export interface CppCodegenOptions { + /** C++ namespace for generated code, e.g. 'bb::cdb' */ + namespace: string; + /** Prefix for command/response types, e.g. 'Cdb' */ + prefix: string; + /** Header path for the *_execute.hpp file that defines Command/CommandResponse NamedUnions */ + executeHeader: string; + /** Header path for the *_commands.hpp file that defines the command structs */ + commandsHeader: string; + /** + * External types: types that already exist in barretenberg headers. + * Map from type name → header include path. + * These types will be #included instead of generated. + */ + externals?: Record; + /** + * Using-namespace declarations to add at the top of generated commands file. + * E.g. ['bb::world_state', 'bb::crypto::merkle_tree'] + */ + usingNamespaces?: string[]; + /** + * Additional headers to include in generated commands file. + */ + additionalIncludes?: string[]; + /** + * Override for the generated output directory include path. + * Used when commandsHeader doesn't point to the generated/ directory + * (e.g. bb keeps hand-written commands but generates server dispatch). + */ + generatedIncludeDir?: string; + /** + * Sub-namespace for wire types (e.g. 'wire' → types in ns::wire). + * When set, standalone types are wrapped in this sub-namespace, + * and the server dispatch deserializes into wire types then converts to domain types. + */ + wireNamespace?: string; +} + +export class CppCodegen { + constructor(private opts: CppCodegenOptions) {} + + /** Convert a command name to a C++ method name (snake_case without prefix) */ + private methodName(commandName: string): string { + // Strip prefix: "CdbGetContractInstance" -> "GetContractInstance" -> "get_contract_instance" + const withoutPrefix = commandName.startsWith(this.opts.prefix) + ? commandName.slice(this.opts.prefix.length) + : commandName; + return toSnakeCase(withoutPrefix); + } + + /** Check if the response has fields (non-void return) */ + private hasResponseFields(command: Command, schema: CompiledSchema): boolean { + const resp = schema.responses.get(command.responseType); + return !!resp && resp.fields.length > 0; + } + + /** Generate the method signature using command struct types directly */ + private generateMethodSignature( + command: Command, + schema: CompiledSchema, + className?: string, + ): string { + const method = this.methodName(command.name); + const hasFields = this.hasResponseFields(command, schema); + // Wire types use top-level response names (BbFooResponse). + // Command types with nested Response use Cmd::Response. + const retType = hasFields + ? this.opts.wireNamespace + ? command.responseType + : `${command.name}::Response` + : "void"; + + // If the command has fields, take the whole command struct by value + const params = command.fields.length > 0 ? `${command.name} cmd` : ""; + + const prefix = className ? `${className}::` : ""; + const constSuffix = !this.isWriteCommand(command) ? " const" : ""; + + return `${retType} ${prefix}${method}(${params})${constSuffix}`; + } + + /** Check if a command modifies state (non-const) */ + private isWriteCommand(command: Command): boolean { + const name = command.name.toLowerCase(); + return ( + name.includes("add") || + name.includes("create") || + name.includes("commit") || + name.includes("revert") || + name.includes("register") || + name.includes("shutdown") || + name.includes("delete") || + name.includes("sync") || + name.includes("rollback") || + name.includes("unwind") + ); + } + + /** Generate the header file */ + generateHeader(schema: CompiledSchema, schemaHash?: string): string { + const { namespace: ns, prefix } = this.opts; + const wireNs = this.opts.wireNamespace; + const className = `${prefix}IpcClient`; + + const methods = schema.commands + .map((cmd) => { + const sig = this.generateMethodSignature(cmd, schema); + return ` ${sig};`; + }) + .join("\n"); + + const hashConstant = schemaHash + ? `\n/** Schema version hash for compatibility checking */\nstatic constexpr const char SCHEMA_HASH[] = "${schemaHash}";\n` + : ""; + + // When wireNamespace is set, include wire types and bring them into scope + const wireInclude = wireNs + ? `#include "${this.generatedInclude(`${toSnakeCase(prefix)}_types.hpp`)}"\n` + : ""; + const wireUsing = wireNs ? `using namespace ${wireNs};\n` : ""; + + const typesInclude = this.generatedInclude( + `${toSnakeCase(prefix)}_types.hpp`, + ); + + return `// AUTOGENERATED FILE - DO NOT EDIT +#pragma once + +#include "${typesInclude}" +#include "${this.generatedInclude("ipc_client.hpp")}" +// clang-format on + +#include +#include + +namespace ${ns} { +${wireUsing}${hashConstant} +/** + * @brief Auto-generated IPC client. + * + * Each method sends a msgpack-serialized command to the server over UDS + * and returns the typed response. All methods block until the response arrives. + */ +class ${className} { + public: + explicit ${className}(const std::string& socket_path); + ~${className}(); + + ${className}(const ${className}&) = delete; + ${className}& operator=(const ${className}&) = delete; + +${methods} + + private: + template + Resp send(Cmd&& cmd) const; + + mutable std::unique_ptr<::ipc::IpcClient> client_; +}; + +} // namespace ${ns} +`; + } + + /** Generate the implementation file — string-based serialization, no NamedUnion */ + generateImpl(schema: CompiledSchema): string { + const { namespace: ns, prefix } = this.opts; + const className = `${prefix}IpcClient`; + const errorType = schema.errorTypeName || `${prefix}ErrorResponse`; + + const methods = schema.commands + .map((cmd) => { + return this.generateMethodImpl(cmd, schema, className); + }) + .join("\n"); + + return `// AUTOGENERATED FILE - DO NOT EDIT + +#include "${this.headerIncludePath()}" +#include "msgpack_struct_map_impl.hpp" + +#include +#include + +namespace ${ns} { + +${className}::${className}(const std::string& socket_path) + : client_(std::make_unique<::ipc::IpcClient>(socket_path.c_str())) +{} + +${className}::~${className}() = default; + +template +Resp ${className}::send(Cmd&& cmd) const +{ + // Serialize as [[CommandName, {payload}]] + msgpack::sbuffer send_buffer; + msgpack::packer pk(send_buffer); + pk.pack_array(1); + pk.pack_array(2); + pk.pack(std::string(Cmd::MSGPACK_SCHEMA_NAME)); + pk.pack(std::forward(cmd)); + + // Send request, receive response + std::vector request_bytes(send_buffer.data(), send_buffer.data() + send_buffer.size()); + auto response_bytes = client_->call(request_bytes); + + if (response_bytes.empty()) { + throw std::runtime_error("Empty response from server"); + } + + // Parse response: [ResponseName, {payload}] + auto unpacked = msgpack::unpack( + reinterpret_cast(response_bytes.data()), response_bytes.size()); + auto obj = unpacked.get(); + + if (obj.type != msgpack::type::ARRAY || obj.via.array.size != 2 || + obj.via.array.ptr[0].type != msgpack::type::STR) { + throw std::runtime_error("Invalid response format from server"); + } + + std::string resp_name(obj.via.array.ptr[0].via.str.ptr, obj.via.array.ptr[0].via.str.size); + if (resp_name == "${errorType}") { + std::string message; + auto& payload = obj.via.array.ptr[1]; + // Extract message field from the error map + if (payload.type == msgpack::type::MAP) { + for (uint32_t i = 0; i < payload.via.map.size; ++i) { + auto& kv = payload.via.map.ptr[i]; + if (kv.key.type == msgpack::type::STR) { + std::string key(kv.key.via.str.ptr, kv.key.via.str.size); + if (key == "message" && kv.val.type == msgpack::type::STR) { + message = std::string(kv.val.via.str.ptr, kv.val.via.str.size); + } + } + } + } + throw std::runtime_error("Server error: " + message); + } + + Resp result; + obj.via.array.ptr[1].convert(result); + return result; +} + +${methods} +} // namespace ${ns} +`; + } + + /** Generate a single method implementation */ + private generateMethodImpl( + command: Command, + schema: CompiledSchema, + className: string, + ): string { + const sig = this.generateMethodSignature(command, schema, className); + const hasFields = this.hasResponseFields(command, schema); + const respType = this.opts.wireNamespace + ? command.responseType + : `${command.name}::Response`; + + const cmdExpr = + command.fields.length > 0 ? "std::move(cmd)" : `${command.name}{}`; + + if (!hasFields) { + return `${sig} +{ + send<${command.name}, ${respType}>(${cmdExpr}); +} +`; + } + + return `${sig} +{ + return send<${command.name}, ${respType}>(${cmdExpr}); +} +`; + } + + /** Get the generated/ directory include prefix. + * Returns either the explicit --cpp-include-dir value (e.g. "myservice/generated") + * or empty for callers that include generated files by their bare filename. */ + private generatedDir(): string { + if (this.opts.generatedIncludeDir) { + return this.opts.generatedIncludeDir; + } + const lastSlash = this.opts.commandsHeader.lastIndexOf("/"); + return lastSlash >= 0 + ? this.opts.commandsHeader.substring(0, lastSlash) + : ""; + } + + /** Form an include path: `/` if dir is non-empty, else bare ``. */ + private generatedInclude(filename: string): string { + const dir = this.generatedDir(); + return dir ? `${dir}/${filename}` : filename; + } + + /** Compute the include path for the generated client header */ + private headerIncludePath(): string { + return this.generatedInclude( + `${toSnakeCase(this.opts.prefix)}_ipc_client.hpp`, + ); + } + + // ----------------------------------------------------------------------- + // Standalone types (no barretenberg dependencies) + // ----------------------------------------------------------------------- + + /** Generate standalone C++ types with MSGPACK_DEFINE_MAP — no barretenberg deps */ + generateStandaloneTypes(schema: CompiledSchema): string { + const { namespace: ns, prefix } = this.opts; + + // Map schema types to C++ types + const mapType = (type: import("./schema_visitor.ts").Type): string => { + switch (type.kind) { + case "primitive": + switch (type.primitive) { + case "bool": + return "bool"; + case "u8": + return "uint8_t"; + case "u16": + return "uint16_t"; + case "u32": + return "uint32_t"; + case "u64": + return "uint64_t"; + case "f64": + return "double"; + case "string": + return "std::string"; + case "bytes": + return "std::vector"; + case "fr": + return "Fr"; // std::array + case "field2": + return "std::array"; + case "enum_u32": + return "uint32_t"; + case "map_u32_pair": + return "std::unordered_map, uint64_t>>"; + } + break; + case "vector": + return `std::vector<${mapType(type.element!)}>`; + case "array": + return `std::array<${mapType(type.element!)}, ${type.size}>`; + case "optional": + return `std::optional<${mapType(type.element!)}>`; + case "struct": + return type.struct!.name; + } + return "void"; + }; + + const allStructs = [ + ...schema.structs.values(), + ...schema.responses.values(), + ]; + const structs = allStructs + .map((s) => { + const fields = s.fields + .map((f) => ` ${mapType(f.type)} ${f.name};`) + .join("\n"); + const fieldNames = s.fields.map((f) => f.name).join(", "); + const schemaName = ` static constexpr const char MSGPACK_SCHEMA_NAME[] = "${s.name}";`; + const serialization = fieldNames + ? ` SERIALIZATION_FIELDS(${fieldNames})` + : ` template void msgpack(_PackFn&& pack_fn) { pack_fn(); }`; + return `struct ${s.name} {\n${schemaName}\n${fields}\n${serialization}\n bool operator==(const ${s.name}&) const = default;\n};`; + }) + .join("\n\n"); + + return `// AUTOGENERATED FILE - DO NOT EDIT +// Standalone types for ${prefix} service. +#pragma once + +#include +#include +#include +#include +#include +#include + +// --------------------------------------------------------------------------- +// Self-contained serialization macro. +// Defines a msgpack() method that enumerates field name/value pairs. +// Works with msgpack packers (serialization) and schema reflectors. +// Skipped if the consumer already defines SERIALIZATION_FIELDS (which then +// wins, so wire and domain types share the same enumeration semantics). +// --------------------------------------------------------------------------- +#ifndef SERIALIZATION_FIELDS +#define _SF_E1(x) #x, x +#define _SF_E2(x, ...) #x, x, _SF_E1(__VA_ARGS__) +#define _SF_E3(x, ...) #x, x, _SF_E2(__VA_ARGS__) +#define _SF_E4(x, ...) #x, x, _SF_E3(__VA_ARGS__) +#define _SF_E5(x, ...) #x, x, _SF_E4(__VA_ARGS__) +#define _SF_E6(x, ...) #x, x, _SF_E5(__VA_ARGS__) +#define _SF_E7(x, ...) #x, x, _SF_E6(__VA_ARGS__) +#define _SF_E8(x, ...) #x, x, _SF_E7(__VA_ARGS__) +#define _SF_E9(x, ...) #x, x, _SF_E8(__VA_ARGS__) +#define _SF_E10(x, ...) #x, x, _SF_E9(__VA_ARGS__) +#define _SF_E11(x, ...) #x, x, _SF_E10(__VA_ARGS__) +#define _SF_E12(x, ...) #x, x, _SF_E11(__VA_ARGS__) +#define _SF_E13(x, ...) #x, x, _SF_E12(__VA_ARGS__) +#define _SF_E14(x, ...) #x, x, _SF_E13(__VA_ARGS__) +#define _SF_E15(x, ...) #x, x, _SF_E14(__VA_ARGS__) +#define _SF_E16(x, ...) #x, x, _SF_E15(__VA_ARGS__) +#define _SF_E17(x, ...) #x, x, _SF_E16(__VA_ARGS__) +#define _SF_E18(x, ...) #x, x, _SF_E17(__VA_ARGS__) +#define _SF_E19(x, ...) #x, x, _SF_E18(__VA_ARGS__) +#define _SF_E20(x, ...) #x, x, _SF_E19(__VA_ARGS__) +#define _SF_CNT(_1,_2,_3,_4,_5,_6,_7,_8,_9,_10,_11,_12,_13,_14,_15,_16,_17,_18,_19,_20,N,...) N +#define _SF_NUM(...) _SF_CNT(__VA_ARGS__,20,19,18,17,16,15,14,13,12,11,10,9,8,7,6,5,4,3,2,1) +#define _SF_CAT(a, b) a##b +#define _SF_SEL(n) _SF_CAT(_SF_E, n) +#define _SF_NVP(...) _SF_SEL(_SF_NUM(__VA_ARGS__))(__VA_ARGS__) +#define SERIALIZATION_FIELDS(...) \\ + template void msgpack(_PackFn pack_fn) { pack_fn(_SF_NVP(__VA_ARGS__)); } +#endif + +/// 32-byte field element (Fr/Fq). Fixed-size, stack-allocated. +using Fr = std::array; + +namespace ${ns}${this.opts.wireNamespace ? "::" + this.opts.wireNamespace : ""} { + +${structs} + +} // namespace ${ns}${this.opts.wireNamespace ? "::" + this.opts.wireNamespace : ""} +`; + } + + /** Generate standalone server dispatch (no barretenberg deps) */ + generateStandaloneServer(schema: CompiledSchema): string { + const { namespace: ns, prefix } = this.opts; + const errorType = schema.errorTypeName || `${prefix}ErrorResponse`; + + const dispatchCases = schema.commands + .filter((c) => !c.name.endsWith("Shutdown")) + .map((c) => { + return ` if (cmd_name == "${c.name}") { + ${c.name} cmd; cmd_payload.convert(cmd); + auto resp = handle_${toSnakeCase(c.name.startsWith(prefix) ? c.name.slice(prefix.length) : c.name)}(cmd); + pk.pack_array(2); pk.pack(std::string("${c.responseType}")); pk.pack(resp); + }`; + }) + .join(" else "); + + const stubs = schema.commands + .filter((c) => !c.name.endsWith("Shutdown")) + .map((c) => { + const method = toSnakeCase( + c.name.startsWith(prefix) ? c.name.slice(prefix.length) : c.name, + ); + return `// TODO: implement ${c.name} +inline ${c.responseType} handle_${method}(const ${c.name}& /*cmd*/) { + throw std::runtime_error("not implemented: ${c.name}"); +}`; + }) + .join("\n\n"); + + const shutdownName = + schema.commands.find((c) => c.name.endsWith("Shutdown"))?.name || + `${prefix}Shutdown`; + const shutdownResp = shutdownName + "Response"; + + return `// AUTOGENERATED FILE - DO NOT EDIT +// ${prefix} server dispatch — only depends on msgpack-c. +// Implement the handle_* functions to build your ${prefix} service. +#pragma once + +#include "types_gen.hpp" +#include "${this.generatedInclude("ipc_server.hpp")}" +#include + +namespace ${ns} { + +// --------------------------------------------------------------------------- +// Dispatch: routes commands to handler functions +// --------------------------------------------------------------------------- + +inline std::vector dispatch(const std::vector& payload) { + auto oh = msgpack::unpack(reinterpret_cast(payload.data()), payload.size()); + auto obj = oh.get(); + auto& inner = obj.via.array.ptr[0]; + std::string cmd_name(inner.via.array.ptr[0].via.str.ptr, inner.via.array.ptr[0].via.str.size); + auto& cmd_payload = inner.via.array.ptr[1]; + + msgpack::sbuffer resp_buf; + msgpack::packer pk(resp_buf); + + try { + if (cmd_name == "${shutdownName}") { + pk.pack_array(2); pk.pack(std::string("${shutdownResp}")); pk.pack_map(0); + } else ${dispatchCases} else { + pk.pack_array(2); pk.pack(std::string("${errorType}")); + pk.pack_map(1); pk.pack(std::string("message")); pk.pack(std::string("unknown command: ") + cmd_name); + } + } catch (const std::exception& e) { + resp_buf.clear(); + msgpack::packer epk(resp_buf); + epk.pack_array(2); epk.pack(std::string("${errorType}")); + epk.pack_map(1); epk.pack(std::string("message")); epk.pack(std::string(e.what())); + } + + return std::vector(resp_buf.data(), resp_buf.data() + resp_buf.size()); +} + +/// Start the server on the given socket path. +inline void serve(const char* socket_path) { + ipc::serve(socket_path, dispatch); +} + +// --------------------------------------------------------------------------- +// Handler stubs — implement these to build your ${prefix} service. +// --------------------------------------------------------------------------- + +${stubs} + +} // namespace ${ns} +`; + } + + /** Generate standalone client wrapper (no barretenberg deps) */ + generateStandaloneClient(schema: CompiledSchema): string { + const { namespace: ns, prefix } = this.opts; + const errorType = schema.errorTypeName || `${prefix}ErrorResponse`; + + const methods = schema.commands + .filter((c) => !c.name.endsWith("Shutdown")) + .map((c) => { + const method = toSnakeCase( + c.name.startsWith(prefix) ? c.name.slice(prefix.length) : c.name, + ); + const hasFields = c.fields.length > 0; + const param = hasFields ? `const ${c.name}& cmd` : ""; + const packCmd = hasFields ? "cmd" : `${c.name}{}`; + return ` ${c.responseType} ${method}(${param}) { + msgpack::sbuffer buf; + msgpack::packer pk(buf); + pk.pack_array(1); pk.pack_array(2); pk.pack(std::string("${c.name}")); pk.pack(${packCmd}); + auto resp = client_.call(std::vector(buf.data(), buf.data() + buf.size())); + auto oh = msgpack::unpack(reinterpret_cast(resp.data()), resp.size()); + auto obj = oh.get(); + std::string resp_name(obj.via.array.ptr[0].via.str.ptr, obj.via.array.ptr[0].via.str.size); + if (resp_name == "${errorType}") throw std::runtime_error("server error"); + ${c.responseType} result; obj.via.array.ptr[1].convert(result); + return result; + }`; + }) + .join("\n\n"); + + return `// AUTOGENERATED FILE - DO NOT EDIT +// ${prefix} typed IPC client — only depends on msgpack-c. +#pragma once + +#include "types_gen.hpp" +#include "${this.generatedInclude("ipc_client.hpp")}" +#include + +namespace ${ns} { + +class ${prefix}Client { + public: + explicit ${prefix}Client(const char* socket_path) : client_(socket_path) {} + +${methods} + + void shutdown() { + msgpack::sbuffer buf; + msgpack::packer pk(buf); + pk.pack_array(1); pk.pack_array(2); pk.pack(std::string("${prefix}Shutdown")); pk.pack_map(0); + client_.call(std::vector(buf.data(), buf.data() + buf.size())); + } + + private: + ipc::IpcClient client_; +}; + +} // namespace ${ns} +`; + } + + // ----------------------------------------------------------------------- + // Barretenberg commands generation (uses native bb types, has execute()) + // ----------------------------------------------------------------------- + + /** + * Map an IR type to a barretenberg C++ type. + * Uses native types (bb::fr, MerkleTreeId, StateReference) instead of + * standalone equivalents (Fr, uint32_t, std::unordered_map<...>). + */ + private mapTypeBb(type: import("./schema_visitor.ts").Type): string { + const externals = this.opts.externals || {}; + + switch (type.kind) { + case "primitive": + switch (type.primitive) { + case "bool": + return "bool"; + case "u8": + return "uint8_t"; + case "u16": + return "uint16_t"; + case "u32": + return "uint32_t"; + case "u64": + return "uint64_t"; + case "f64": + return "double"; + case "string": + return "std::string"; + case "bytes": + return "std::vector"; + case "fr": + return "bb::fr"; + case "field2": + return "std::array"; + case "enum_u32": + // Preserve original enum name if available + return type.originalName || "uint32_t"; + case "map_u32_pair": + // StateReference is the only map type we've seen + return "StateReference"; + } + break; + case "vector": + return `std::vector<${this.mapTypeBb(type.element!)}>`; + case "array": + return `std::array<${this.mapTypeBb(type.element!)}, ${type.size}>`; + case "optional": + return `std::optional<${this.mapTypeBb(type.element!)}>`; + case "struct": + return type.struct!.name; + } + return "void"; + } + + /** + * Generate barretenberg-specific commands header. + * + * This generates command structs that use native barretenberg types + * (bb::fr, MerkleTreeId, etc.) and include execute() declarations. + * External types (NullifierLeafValue, WorldStateRevision, etc.) are + * imported via #include instead of being generated. + */ + generateCommands(schema: CompiledSchema): string { + const { namespace: ns, prefix } = this.opts; + const externals = this.opts.externals || {}; + + // Collect unique include paths from externals + const externalIncludes = new Set(); + for (const header of Object.values(externals)) { + externalIncludes.add(header); + } + for (const inc of this.opts.additionalIncludes || []) { + externalIncludes.add(inc); + } + + const includeLines = [...externalIncludes] + .sort() + .map((h) => `#include "${h}"`) + .join("\n"); + const usingLines = (this.opts.usingNamespaces || []) + .map((ns) => `using namespace ${ns};`) + .join("\n"); + + // Generate non-external struct types (only SiblingPathAndIndex-like types + // that are NOT commands/responses and NOT external) + const helperStructs: string[] = []; + for (const [name, struct] of schema.structs) { + if (name.startsWith(prefix)) continue; // Commands are generated below + if (externals[name]) continue; // External — imported via #include + // Generate this helper struct + const fields = struct.fields + .map((f) => ` ${this.mapTypeBb(f.type)} ${f.name};`) + .join("\n"); + const fieldNames = struct.fields.map((f) => f.name).join(", "); + const serialization = fieldNames + ? ` SERIALIZATION_FIELDS(${fieldNames});` + : ` template void msgpack(_PackFn&& pack_fn) { pack_fn(); }`; + helperStructs.push( + `struct ${name} {\n static constexpr const char MSGPACK_SCHEMA_NAME[] = "${name}";\n${fields}\n${serialization}\n bool operator==(const ${name}&) const = default;\n};`, + ); + } + for (const [name, struct] of schema.responses) { + if (name.startsWith(prefix)) continue; // Responses generated nested inside commands + if (externals[name]) continue; + // Non-prefixed response types (shouldn't normally happen) + } + + // Generate command structs with nested Response and execute() + const commandStructs = schema.commands + .map((cmd) => { + const respStruct = schema.responses.get(cmd.responseType); + + // Command fields + const cmdFields = cmd.fields + .map((f) => ` ${this.mapTypeBb(f.type)} ${f.name};`) + .join("\n"); + const cmdFieldNames = cmd.fields.map((f) => f.name).join(", "); + const cmdSerialization = cmdFieldNames + ? ` SERIALIZATION_FIELDS(${cmdFieldNames});` + : ` template void msgpack(_PackFn&& pack_fn) { pack_fn(); }`; + + // Response fields + let responseBlock: string; + if (respStruct && respStruct.fields.length > 0) { + const respFields = respStruct.fields + .map((f) => ` ${this.mapTypeBb(f.type)} ${f.name};`) + .join("\n"); + const respFieldNames = respStruct.fields + .map((f) => f.name) + .join(", "); + responseBlock = ` struct Response { + static constexpr const char MSGPACK_SCHEMA_NAME[] = "${cmd.responseType}"; +${respFields} + SERIALIZATION_FIELDS(${respFieldNames}); + bool operator==(const Response&) const = default; + };`; + } else { + responseBlock = ` struct Response { + static constexpr const char MSGPACK_SCHEMA_NAME[] = "${cmd.responseType}"; + template void msgpack(_PackFn&& pack_fn) { pack_fn(); } + bool operator==(const Response&) const = default; + };`; + } + + return `struct ${cmd.name} { + static constexpr const char MSGPACK_SCHEMA_NAME[] = "${cmd.name}"; +${responseBlock} +${cmdFields} + Response execute(${prefix}Request& request) &&; +${cmdSerialization} + bool operator==(const ${cmd.name}&) const = default; +};`; + }) + .join("\n\n"); + + return `// AUTOGENERATED FILE - DO NOT EDIT +#pragma once + +${includeLines} +#include +#include +#include +#include + +namespace ${ns} { + +${usingLines} + +// Forward declaration +struct ${prefix}Request; + +${helperStructs.join("\n\n")}${helperStructs.length > 0 ? "\n\n" : ""}// --------------------------------------------------------------------------- +// Commands +// --------------------------------------------------------------------------- + +${commandStructs} + +} // namespace ${ns} +`; + } + + // ----------------------------------------------------------------------- + // Server-side code generation (uses standalone ipc_server.hpp template) + // ----------------------------------------------------------------------- + + /** Generate the server dispatch — header-only, template */ + generateServerHeader(schema: CompiledSchema): string { + const { namespace: ns, prefix } = this.opts; + const errorTypeName = schema.errorTypeName || `${prefix}ErrorResponse`; + const typesHeader = `${toSnakeCase(prefix)}_types.hpp`; + + // Handler declarations — template + const handlerDecls = schema.commands + .filter((c) => !c.name.endsWith("Shutdown")) + .map((c) => { + const method = toSnakeCase( + c.name.startsWith(prefix) ? c.name.slice(prefix.length) : c.name, + ); + return `template\nwire::${c.responseType} handle_${method}(Ctx& ctx, wire::${c.name}&& cmd);`; + }) + .join("\n\n"); + + // Handler entries for dispatch map + const handlerEntries = schema.commands + .map((cmd) => { + const isShutdown = cmd.name.endsWith("Shutdown"); + const method = toSnakeCase( + cmd.name.startsWith(prefix) + ? cmd.name.slice(prefix.length) + : cmd.name, + ); + + if (isShutdown) { + return ` { "${cmd.name}", []([[maybe_unused]] Ctx& ctx, [[maybe_unused]] const msgpack::object& payload) -> std::vector { + msgpack::sbuffer buf; + msgpack::packer pk(buf); + pk.pack_array(2); pk.pack(std::string("${cmd.responseType}")); pk.pack_map(0); + THROW ::ipc::ShutdownRequested(std::vector(buf.data(), buf.data() + buf.size())); + } }`; + } + + const deserialize = + cmd.fields.length > 0 + ? `wire::${cmd.name} wire_cmd; payload.convert(wire_cmd);` + : `wire::${cmd.name} wire_cmd;`; + + return ` { "${cmd.name}", [](Ctx& ctx, [[maybe_unused]] const msgpack::object& payload) -> std::vector { + ${deserialize} + auto wire_resp = handle_${method}(ctx, std::move(wire_cmd)); + msgpack::sbuffer buf; + msgpack::packer pk(buf); + pk.pack_array(2); pk.pack(std::string("${cmd.responseType}")); pk.pack(wire_resp); + return std::vector(buf.data(), buf.data() + buf.size()); + } }`; + }) + .join(",\n"); + + return `// AUTOGENERATED FILE - DO NOT EDIT +// Header-only server dispatch — template for service context. +#pragma once + +#include "${typesHeader}" +#include "ipc_server.hpp" +#include "msgpack_struct_map_impl.hpp" + +// THROW/RETHROW mirror barretenberg's try_catch_shim macros so the same +// generated header works in both standalone builds (where exceptions are +// available) and in WASM builds (where the includer can predefine these to +// abort-with-message variants before #including this header). +#ifndef THROW +#define THROW throw +#define RETHROW throw +#endif +#include + +#include +#include +#include +#include +#include +#include + +namespace ${ns} { + +// Wire types are in the 'wire' sub-namespace (from ${typesHeader}) +// Handler declarations — implement these in your handler file. +// Template specializations must be visible before make_handler() is instantiated. + +${handlerDecls} + +// --------------------------------------------------------------------------- +// Dispatch — template on service context type +// --------------------------------------------------------------------------- + +namespace detail { + +inline std::vector make_error(const std::string& message) +{ + msgpack::sbuffer buf; + msgpack::packer pk(buf); + pk.pack_array(2); + pk.pack(std::string("${errorTypeName}")); + pk.pack_map(1); + pk.pack(std::string("message")); + pk.pack(message); + return std::vector(buf.data(), buf.data() + buf.size()); +} + +} // namespace detail + +template +::ipc::Handler make_${toSnakeCase(prefix)}_handler(Ctx& ctx) +{ + using HandlerFn = std::function(Ctx&, const msgpack::object&)>; + static const std::unordered_map table = { +${handlerEntries}, + }; + + return [&ctx](const std::vector& raw_request) -> std::vector { + auto unpacked = msgpack::unpack( + reinterpret_cast(raw_request.data()), raw_request.size()); + auto obj = unpacked.get(); + + if (obj.type != msgpack::type::ARRAY || obj.via.array.size != 1) { + std::cerr << "Error: Expected array of size 1\\n"; + return {}; + } + + auto& inner = obj.via.array.ptr[0]; + if (inner.type != msgpack::type::ARRAY || inner.via.array.size != 2 || + inner.via.array.ptr[0].type != msgpack::type::STR) { + std::cerr << "Error: Expected [CommandName, {payload}]\\n"; + return {}; + } + + std::string cmd_name(inner.via.array.ptr[0].via.str.ptr, inner.via.array.ptr[0].via.str.size); + auto& cmd_payload = inner.via.array.ptr[1]; + + auto it = table.find(cmd_name); + if (it == table.end()) { + return detail::make_error("unknown command: " + cmd_name); + } +#ifdef BB_NO_EXCEPTIONS + return it->second(ctx, cmd_payload); +#else + try { + return it->second(ctx, cmd_payload); + } catch (const ::ipc::ShutdownRequested&) { + throw; + } catch (const std::exception& e) { + std::cerr << "Error processing " << cmd_name << ": " << e.what() << '\\n'; + return detail::make_error(e.what()); + } +#endif + }; +} + +template +void serve(const char* socket_path, Ctx& ctx, std::atomic* shutdown_flag = nullptr) +{ + ::ipc::serve(socket_path, make_${toSnakeCase(prefix)}_handler(ctx), shutdown_flag); +} + +} // namespace ${ns} +`; + } + + /** Generate the server dispatch implementation — map-based O(1) lookup */ + generateServerImpl(schema: CompiledSchema): string { + const { namespace: ns, prefix } = this.opts; + const requestType = `${prefix}Request`; + const errorTypeName = schema.errorTypeName || `${prefix}ErrorResponse`; + + const serverHeaderPath = this.generatedInclude( + `${toSnakeCase(prefix)}_ipc_server.hpp`, + ); + + // Generate handler lambdas for each command + const wireNs = this.opts.wireNamespace; + const handlerEntries = schema.commands + .map((cmd) => { + const isShutdown = cmd.name.endsWith("Shutdown"); + + // When wireNamespace is set: deserialize wire type, call handle_xxx() which returns wire response + // When not set: wire types ARE domain types, call cmd.execute(request) directly + const method = toSnakeCase( + cmd.name.startsWith(prefix) + ? cmd.name.slice(prefix.length) + : cmd.name, + ); + let body: string; + + if (wireNs) { + if (isShutdown) { + // Shutdown: no handler call, just serialize empty response and throw + body = `msgpack::sbuffer buf; + msgpack::packer pk(buf); + pk.pack_array(2); pk.pack(std::string("${cmd.responseType}")); pk.pack_map(0);`; + } else { + const wireType = `${wireNs}::${cmd.name}`; + const deserialize = + cmd.fields.length > 0 + ? `${wireType} wire_cmd; payload.convert(wire_cmd);` + : `${wireType} wire_cmd;`; + body = `${deserialize} + auto wire_resp = handle_${method}(request, std::move(wire_cmd)); + msgpack::sbuffer buf; + msgpack::packer pk(buf); + pk.pack_array(2); pk.pack(std::string("${cmd.responseType}")); pk.pack(wire_resp);`; + } + } else { + const deserialize = + cmd.fields.length > 0 + ? `${cmd.name} cmd; payload.convert(cmd);` + : `${cmd.name} cmd;`; + body = `${deserialize} + auto resp = std::move(cmd).execute(request); + msgpack::sbuffer buf; + msgpack::packer pk(buf); + pk.pack_array(2); pk.pack(std::string("${cmd.responseType}")); pk.pack(resp);`; + } + + if (isShutdown) { + return ` { "${cmd.name}", []([[maybe_unused]] ${requestType}& request, [[maybe_unused]] const msgpack::object& payload) -> std::vector { + ${body} + throw ::ipc::ShutdownRequested(std::vector(buf.data(), buf.data() + buf.size())); + } }`; + } + return ` { "${cmd.name}", [](${requestType}& request, [[maybe_unused]] const msgpack::object& payload) -> std::vector { + ${body} + return std::vector(buf.data(), buf.data() + buf.size()); + } }`; + }) + .join(",\n"); + + // Include wire types header when wire/domain split is used + const wireTypesInclude = wireNs + ? `#include "${this.generatedInclude(`${toSnakeCase(prefix)}_types.hpp`)}"\n` + : ""; + + return `// AUTOGENERATED FILE - DO NOT EDIT + +#include "${serverHeaderPath}" +${wireTypesInclude}#include "msgpack_struct_map_impl.hpp" + +#include +#include +#include +#include + +namespace ${ns} { + +using CommandHandler = std::function(${requestType}&, const msgpack::object&)>; + +static const std::unordered_map& get_dispatch_table() +{ + static const std::unordered_map table = { +${handlerEntries}, + }; + return table; +} + +static std::vector make_error(const std::string& message) +{ + msgpack::sbuffer buf; + msgpack::packer pk(buf); + pk.pack_array(2); + pk.pack(std::string("${errorTypeName}")); + pk.pack_map(1); + pk.pack(std::string("message")); + pk.pack(message); + return std::vector(buf.data(), buf.data() + buf.size()); +} + +::ipc::Handler make_${toSnakeCase(prefix)}_handler(${requestType}& request) +{ + return [&request](const std::vector& raw_request) -> std::vector { + // Parse: [[CommandName, {payload}]] + auto unpacked = msgpack::unpack( + reinterpret_cast(raw_request.data()), raw_request.size()); + auto obj = unpacked.get(); + + if (obj.type != msgpack::type::ARRAY || obj.via.array.size != 1) { + std::cerr << "Error: Expected array of size 1\\n"; + return {}; + } + + auto& inner = obj.via.array.ptr[0]; + if (inner.type != msgpack::type::ARRAY || inner.via.array.size != 2 || + inner.via.array.ptr[0].type != msgpack::type::STR) { + std::cerr << "Error: Expected [CommandName, {payload}]\\n"; + return {}; + } + + std::string cmd_name(inner.via.array.ptr[0].via.str.ptr, inner.via.array.ptr[0].via.str.size); + auto& cmd_payload = inner.via.array.ptr[1]; + + try { + auto& table = get_dispatch_table(); + auto it = table.find(cmd_name); + if (it == table.end()) { + return make_error("unknown command: " + cmd_name); + } + return it->second(request, cmd_payload); + } catch (const ::ipc::ShutdownRequested&) { + throw; + } catch (const std::exception& e) { + std::cerr << "Error processing " << cmd_name << ": " << e.what() << '\\n'; + return make_error(e.what()); + } + }; +} + +} // namespace ${ns} +`; + } + + // ----------------------------------------------------------------------- + // Skeleton generation (one-time handler stubs + main) + // ----------------------------------------------------------------------- + + /** Generate handler stub implementations that throw "not implemented" */ + generateHandlerStubs(schema: CompiledSchema): string { + const { namespace: ns, prefix } = this.opts; + const typesHeader = `${toSnakeCase(prefix)}_ipc_server.hpp`; + const ctxName = `${prefix}Context`; + + const stubs = schema.commands + .filter((c) => !c.name.endsWith("Shutdown")) + .map((c) => { + const method = toSnakeCase( + c.name.startsWith(prefix) ? c.name.slice(prefix.length) : c.name, + ); + return `template<> +wire::${c.responseType} handle_${method}(${ctxName}& /*ctx*/, wire::${c.name}&& /*cmd*/) +{ + throw std::runtime_error("not implemented: ${c.name}"); +}`; + }) + .join("\n\n"); + + return `// Handler stubs — implement your service logic here. +// This file is generated ONCE. Edit freely — it will not be overwritten. +#include "generated/${typesHeader}" +#include + +struct ${ctxName} { + // Add your shared state here (database connection, etc.) +}; + +namespace ${ns} { + +${stubs} + +// Explicit template instantiation — must be at the bottom after all handlers. +template ::ipc::Handler make_${toSnakeCase(prefix)}_handler(${ctxName}& ctx); + +} // namespace ${ns} +`; + } + + /** Generate a main.cpp entry point for a standalone service */ + generateMain(schema: CompiledSchema): string { + const { namespace: ns, prefix } = this.opts; + const ctxName = `${prefix}Context`; + + return `// Entry point for ${prefix} service. +// This file is generated ONCE. Edit freely — it will not be overwritten. +#include "generated/${toSnakeCase(prefix)}_ipc_server.hpp" +#include "${toSnakeCase(prefix)}_handlers.cpp" + +#include +#include +#include + +static std::atomic shutdown_flag{ false }; + +int main(int argc, char* argv[]) +{ + if (argc < 2) { + std::cerr << "Usage: " << argv[0] << " \\n"; + return 1; + } + + ${ctxName} ctx{}; + std::signal(SIGTERM, [](int) { shutdown_flag.store(true); }); + std::signal(SIGINT, [](int) { shutdown_flag.store(true); }); + + std::cerr << "${prefix} server starting on " << argv[1] << "\\n"; + ::ipc::serve(argv[1], ${ns}::make_${toSnakeCase(prefix)}_handler(ctx), &shutdown_flag); + return 0; +} +`; + } + + /** Generate CMakeLists.txt for a standalone service */ + generateBuildFile(schema: CompiledSchema): string { + const { prefix } = this.opts; + const snakePrefix = toSnakeCase(prefix); + + return `cmake_minimum_required(VERSION 3.20) +project(${snakePrefix}_service CXX) + +set(CMAKE_CXX_STANDARD 20) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +# Generated IPC code +file(GLOB GENERATED_SOURCES generated/*.cpp generated/*.hpp) + +add_executable(${snakePrefix} + main.cpp + \${GENERATED_SOURCES} +) + +target_include_directories(${snakePrefix} PRIVATE \${CMAKE_CURRENT_SOURCE_DIR}) +target_link_libraries(${snakePrefix} PRIVATE pthread) +`; + } + + /** Generate .gitignore for the skeleton project */ + generateGitignore(): string { + return `# Generated IPC code — do not edit, re-run generate.sh instead +generated/ +build/ +`; + } + + /** Generate a shell script to re-run codegen */ + generateGenerateScript(schemaPath: string): string { + const { prefix, namespace: ns } = this.opts; + return `#!/usr/bin/env bash +# Re-generate IPC types, server, and client from schema. +# Run from the project root directory. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)" +SCHEMA="${schemaPath}" + +node --experimental-strip-types "$(dirname "$SCRIPT_DIR")/codegen/src/generate.ts" \\ + --schema "$SCHEMA" \\ + --lang cpp \\ + --out "$SCRIPT_DIR/generated" \\ + --prefix ${prefix} \\ + --cpp-namespace ${ns} \\ + --server +`; + } +} diff --git a/ipc-codegen/src/generate.ts b/ipc-codegen/src/generate.ts new file mode 100644 index 000000000000..4d85eb2308ce --- /dev/null +++ b/ipc-codegen/src/generate.ts @@ -0,0 +1,593 @@ +// CI trigger +/** + * IPC code generation CLI. + * + * Usage: + * generate.ts --schema --lang --out [flags] + * + * Required: + * --schema JSON schema file + * --lang Target language + * --out Output directory for always-regenerated code + * + * Optional: + * --prefix Type prefix (auto-detected if omitted) + * --server Generate server dispatch + * --client Generate client + * --skeleton Generate handler stubs + main (one-time, not regenerated) + * --cpp-namespace C++ namespace (e.g. bb::wsdb) + * --cpp-wire-namespace Wire types sub-namespace (default: wire) + * --curve-constants Generate TS curve constants from JSON at + * + * Zero npm dependencies — runs with Node.js 22+ via --experimental-strip-types. + */ + +import { createHash } from "crypto"; +import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"; +import { execSync } from "child_process"; +import { dirname, join, resolve } from "path"; +import { fileURLToPath } from "url"; +import { SchemaVisitor, type CompiledSchema } from "./schema_visitor.ts"; +import { TypeScriptCodegen } from "./typescript_codegen.ts"; +import { RustCodegen } from "./rust_codegen.ts"; +import { ZigCodegen } from "./zig_codegen.ts"; +import { CppCodegen } from "./cpp_codegen.ts"; +import { toSnakeCase } from "./naming.ts"; + +// @ts-ignore +const __dirname = dirname(fileURLToPath(import.meta.url)); + +// --------------------------------------------------------------------------- +// Argument parsing +// --------------------------------------------------------------------------- + +interface Args { + schema: string; + lang: string; + out: string; + prefix: string; + server: boolean; + client: boolean; + skeleton: string; + cppNamespace: string; + cppWireNamespace: string; + cppIncludeDir: string; + uds: boolean; + ffi: boolean; + curveConstants: string; + stripMethodPrefix: boolean; +} + +function parseArgs(argv: string[]): Args { + const args: Args = { + schema: "", + lang: "", + out: "", + prefix: "", + server: false, + client: false, + skeleton: "", + cppNamespace: "", + cppWireNamespace: "wire", + cppIncludeDir: "", + uds: false, + ffi: false, + curveConstants: "", + stripMethodPrefix: false, + }; + + for (let i = 0; i < argv.length; i++) { + switch (argv[i]) { + case "--schema": + args.schema = argv[++i]; + break; + case "--lang": + args.lang = argv[++i]; + break; + case "--out": + args.out = argv[++i]; + break; + case "--prefix": + args.prefix = argv[++i]; + break; + case "--server": + args.server = true; + break; + case "--client": + args.client = true; + break; + case "--skeleton": + args.skeleton = argv[++i]; + break; + case "--cpp-namespace": + args.cppNamespace = argv[++i]; + break; + case "--cpp-wire-namespace": + args.cppWireNamespace = argv[++i]; + break; + case "--cpp-include-dir": + args.cppIncludeDir = argv[++i]; + break; + case "--uds": + args.uds = true; + break; + case "--ffi": + args.ffi = true; + break; + case "--curve-constants": + args.curveConstants = argv[++i]; + break; + case "--strip-method-prefix": + args.stripMethodPrefix = true; + break; + default: + console.error(`Unknown flag: ${argv[i]}`); + process.exit(1); + } + } + + if (!args.schema || !args.lang || !args.out) { + console.error(`Usage: generate.ts --schema --lang --out [flags] + +Required: + --schema JSON schema file + --lang Target language (ts, rust, zig, cpp) + --out Output directory + +Optional: + --server Generate server dispatch + --client Generate client + --skeleton Generate handler stubs + main (one-time) + --prefix Type prefix (auto-detected if omitted) + --cpp-namespace C++ namespace (e.g. my::ns) + --cpp-wire-namespace Wire types sub-namespace (default: wire) + --cpp-include-dir Include path for generated dir (e.g. myservice/generated) + --curve-constants Generate TS curve constants from JSON at + --strip-method-prefix Strip prefix from TS method names (e.g. BbCircuitProve -> circuitProve)`); + process.exit(1); + } + + return args; +} + +// --------------------------------------------------------------------------- +// Schema loading +// --------------------------------------------------------------------------- + +function computeSchemaHash(schemaJson: string): string { + return createHash("sha256").update(schemaJson).digest("hex"); +} + +function loadSchema(schemaPath: string): { + compiled: CompiledSchema; + schemaHash: string; +} { + const rawJson = readFileSync(schemaPath, "utf-8").trim(); + const schema = JSON.parse(rawJson); + const visitor = new SchemaVisitor(); + const compiled = visitor.visit(schema.commands, schema.responses); + const schemaHash = computeSchemaHash(rawJson); + return { compiled, schemaHash }; +} + +/** Detect common prefix from command names (e.g. WsdbGetTreeInfo, WsdbCreateFork → Wsdb) */ +function detectPrefix(compiled: CompiledSchema): string { + const names = compiled.commands.map((c) => c.name); + if (names.length === 0) return ""; + let prefix = names[0]; + for (const name of names.slice(1)) { + while (prefix && !name.startsWith(prefix)) { + prefix = prefix.slice(0, -1); + } + } + const words = prefix.match(/[A-Z][a-z]*/g) || []; + let result = ""; + for (const word of words) { + const candidate = result + word; + if (names.every((n) => n.startsWith(candidate))) { + result = candidate; + } else { + break; + } + } + return result; +} + +// --------------------------------------------------------------------------- +// Template copying +// --------------------------------------------------------------------------- + +function copyTemplate(lang: string, filename: string, outDir: string) { + const templatePath = join(__dirname, "..", "templates", lang, filename); + const destPath = join(outDir, filename); + writeFileSync(destPath, readFileSync(templatePath, "utf-8")); + console.log(` ${destPath} (template)`); +} + +/** Copy template only if destination doesn't exist (idempotent, one-time) */ +function copyTemplateOnce(lang: string, filename: string, outDir: string) { + const destPath = join(outDir, filename); + if (existsSync(destPath)) { + console.log(` ${destPath} (exists, skipped)`); + return; + } + copyTemplate(lang, filename, outDir); +} + +// --------------------------------------------------------------------------- +// C++ clang-format +// --------------------------------------------------------------------------- + +function formatCpp(files: string[]) { + if (files.length === 0) return; + try { + execSync(`clang-format-20 -i ${files.join(" ")}`, { stdio: "ignore" }); + } catch { + // clang-format-20 may not be available + } +} + +// --------------------------------------------------------------------------- +// Generation +// --------------------------------------------------------------------------- + +function generate(args: Args) { + const absSchema = resolve(args.schema); + const absOut = resolve(args.out); + mkdirSync(absOut, { recursive: true }); + + const { compiled, schemaHash } = loadSchema(absSchema); + const prefix = args.prefix || detectPrefix(compiled); + + console.log( + `Schema: ${absSchema} (${compiled.commands.length} commands, prefix=${prefix})`, + ); + + function writeFile(name: string, content: string) { + const path = join(absOut, name); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + console.log(` ${path}`); + return path; + } + + const cppFiles: string[] = []; + + switch (args.lang) { + case "ts": { + const gen = new TypeScriptCodegen({ + stripMethodPrefix: args.stripMethodPrefix ? prefix : undefined, + }); + writeFile("api_types.ts", gen.generateTypes(compiled, schemaHash)); + if (args.server) { + writeFile("server.ts", gen.generateServerApi(compiled)); + copyTemplate("ts", "ipc_server.ts", absOut); + } + if (args.client) { + writeFile("async.ts", gen.generateAsyncApi(compiled)); + writeFile("sync.ts", gen.generateSyncApi(compiled)); + copyTemplate("ts", "ipc_client.ts", absOut); + } + if (args.curveConstants) { + generateCurveConstants(absOut, resolve(args.curveConstants)); + } + // Skeleton (one-time handler stubs + main + build files) + if (args.skeleton) { + const skelDir = resolve(args.skeleton); + mkdirSync(skelDir, { recursive: true }); + const writeSkeleton = ( + name: string, + content: string, + opts?: { executable?: boolean }, + ) => { + const path = join(skelDir, name); + if (existsSync(path)) { + console.log(` ${path} (exists, skipped)`); + return; + } + writeFileSync(path, content); + if (opts?.executable) { + try { + execSync(`chmod +x ${path}`); + } catch {} + } + console.log(` ${path} (skeleton)`); + }; + writeSkeleton( + `${toSnakeCase(prefix)}_handlers.ts`, + gen.generateHandlerStubs(compiled, prefix), + ); + writeSkeleton("main.ts", gen.generateMain(compiled, prefix)); + writeSkeleton("package.json", gen.generateBuildFile(prefix)); + writeSkeleton(".gitignore", gen.generateGitignore()); + writeSkeleton( + "generate.sh", + gen.generateGenerateScript(args.schema, prefix), + { executable: true }, + ); + } + break; + } + case "rust": { + const gen = new RustCodegen({ prefix }); + writeFile( + `${toSnakeCase(prefix)}_types.rs`, + gen.generateTypes(compiled, schemaHash), + ); + if (args.server) { + writeFile( + `${toSnakeCase(prefix)}_server.rs`, + gen.generateServer(compiled), + ); + copyTemplate("rust", "ipc_server.rs", absOut); + } + if (args.client) { + writeFile( + `${toSnakeCase(prefix)}_client.rs`, + gen.generateApi(compiled), + ); + } + // Backend templates (copied once, not overwritten) + if (args.uds || args.ffi) { + copyTemplateOnce("rust", "backend.rs", absOut); + copyTemplateOnce("rust", "error.rs", absOut); + } + if (args.uds) { + copyTemplateOnce("rust", "uds_backend.rs", absOut); + } + if (args.ffi) { + copyTemplateOnce("rust", "ffi_backend.rs", absOut); + } + // Skeleton (one-time handler stubs + main + build files) + if (args.skeleton) { + const skelDir = resolve(args.skeleton); + mkdirSync(skelDir, { recursive: true }); + const writeSkeleton = ( + name: string, + content: string, + opts?: { executable?: boolean }, + ) => { + const path = join(skelDir, name); + if (existsSync(path)) { + console.log(` ${path} (exists, skipped)`); + return; + } + writeFileSync(path, content); + if (opts?.executable) { + try { + execSync(`chmod +x ${path}`); + } catch {} + } + console.log(` ${path} (skeleton)`); + }; + writeSkeleton( + `${toSnakeCase(prefix)}_handlers.rs`, + gen.generateHandlerStubs(compiled), + ); + writeSkeleton("main.rs", gen.generateMain(compiled)); + writeSkeleton("Cargo.toml", gen.generateBuildFile(compiled)); + writeSkeleton(".gitignore", gen.generateGitignore()); + writeSkeleton("generate.sh", gen.generateGenerateScript(args.schema), { + executable: true, + }); + } + break; + } + case "zig": { + const gen = new ZigCodegen({ prefix, clientName: `${prefix}Client` }); + writeFile( + `${toSnakeCase(prefix)}_types.zig`, + gen.generateTypes(compiled, schemaHash), + ); + if (args.server) { + writeFile( + `${toSnakeCase(prefix)}_server.zig`, + gen.generateServer(compiled), + ); + copyTemplate("zig", "ipc_server.zig", absOut); + } + if (args.client) { + writeFile( + `${toSnakeCase(prefix)}_client.zig`, + gen.generateClient(compiled), + ); + } + // Backend templates (copied once, not overwritten) + if (args.uds || args.ffi) { + copyTemplateOnce("zig", "backend.zig", absOut); + } + if (args.uds) { + copyTemplateOnce("zig", "uds_backend.zig", absOut); + } + if (args.ffi) { + copyTemplateOnce("zig", "ffi_backend.zig", absOut); + } + // Skeleton (one-time handler stubs + main + build files) + if (args.skeleton) { + const skelDir = resolve(args.skeleton); + mkdirSync(skelDir, { recursive: true }); + const writeSkeleton = ( + name: string, + content: string, + opts?: { executable?: boolean }, + ) => { + const path = join(skelDir, name); + if (existsSync(path)) { + console.log(` ${path} (exists, skipped)`); + return; + } + writeFileSync(path, content); + if (opts?.executable) { + try { + execSync(`chmod +x ${path}`); + } catch {} + } + console.log(` ${path} (skeleton)`); + }; + writeSkeleton( + `${toSnakeCase(prefix)}_handlers.zig`, + gen.generateHandlerStubs(compiled), + ); + writeSkeleton("main.zig", gen.generateMain(compiled)); + writeSkeleton("build.zig", gen.generateBuildFile(compiled)); + writeSkeleton("build.zig.zon", gen.generateBuildZon(compiled)); + writeSkeleton(".gitignore", gen.generateGitignore()); + writeSkeleton("generate.sh", gen.generateGenerateScript(args.schema), { + executable: true, + }); + } + break; + } + case "cpp": { + const ns = args.cppNamespace || prefix.toLowerCase(); + const wireNs = args.cppWireNamespace; + const gen = new CppCodegen({ + namespace: ns, + prefix, + executeHeader: "", + commandsHeader: "", + wireNamespace: wireNs, + generatedIncludeDir: args.cppIncludeDir, + }); + + cppFiles.push( + writeFile( + `${toSnakeCase(prefix)}_types.hpp`, + gen.generateStandaloneTypes(compiled), + ), + ); + // Bundled cpp templates are copied verbatim. Push them through clang-format + // alongside the generated files so they pass downstream format-check + // (each consumer's local .clang-format gets picked up via the file path). + if (args.server || args.client) { + // Bundled msgpack adaptor — keeps the generated client/server free of + // any framework-specific msgpack includes. + copyTemplate("cpp", "msgpack_struct_map_impl.hpp", absOut); + cppFiles.push(join(absOut, "msgpack_struct_map_impl.hpp")); + } + if (args.server) { + cppFiles.push( + writeFile( + `${toSnakeCase(prefix)}_ipc_server.hpp`, + gen.generateServerHeader(compiled), + ), + ); + copyTemplate("cpp", "ipc_server.hpp", absOut); + cppFiles.push(join(absOut, "ipc_server.hpp")); + } + if (args.client) { + cppFiles.push( + writeFile( + `${toSnakeCase(prefix)}_ipc_client.hpp`, + gen.generateHeader(compiled, schemaHash), + ), + ); + cppFiles.push( + writeFile( + `${toSnakeCase(prefix)}_ipc_client.cpp`, + gen.generateImpl(compiled), + ), + ); + copyTemplate("cpp", "ipc_client.hpp", absOut); + cppFiles.push(join(absOut, "ipc_client.hpp")); + } + + // Skeleton (one-time handler stubs + main + build files) + if (args.skeleton) { + const skelDir = resolve(args.skeleton); + mkdirSync(skelDir, { recursive: true }); + const writeSkeleton = ( + name: string, + content: string, + opts?: { executable?: boolean }, + ) => { + const path = join(skelDir, name); + if (existsSync(path)) { + console.log(` ${path} (exists, skipped)`); + return; + } + writeFileSync(path, content); + if (opts?.executable) { + try { + execSync(`chmod +x ${path}`); + } catch {} + } + console.log(` ${path} (skeleton)`); + if (path.endsWith(".cpp") || path.endsWith(".hpp")) { + cppFiles.push(path); + } + }; + writeSkeleton( + `${toSnakeCase(prefix)}_handlers.cpp`, + gen.generateHandlerStubs(compiled), + ); + writeSkeleton("main.cpp", gen.generateMain(compiled)); + writeSkeleton("CMakeLists.txt", gen.generateBuildFile(compiled)); + writeSkeleton(".gitignore", gen.generateGitignore()); + writeSkeleton("generate.sh", gen.generateGenerateScript(args.schema), { + executable: true, + }); + } + + formatCpp(cppFiles); + break; + } + default: + console.error( + `Unknown language: ${args.lang}. Available: ts, rust, zig, cpp`, + ); + process.exit(1); + } + + console.log("Done."); +} + +// --------------------------------------------------------------------------- +// Curve constants (special case for bb) +// --------------------------------------------------------------------------- + +function hexToBigInt(hex: string): bigint { + return BigInt("0x" + hex); +} + +function hexToByteList(hex: string): string { + const bytes: number[] = []; + for (let i = 0; i < hex.length; i += 2) + bytes.push(parseInt(hex.substring(i, i + 2), 16)); + return `new Uint8Array([${bytes.join(", ")}])`; +} + +function serializeCoordinate(coord: string | string[]): string { + return Array.isArray(coord) + ? `[${coord.map((c) => hexToByteList(c)).join(", ")}]` + : hexToByteList(coord); +} + +function generateCurveConstants(outputDir: string, constantsPath: string) { + const constants = JSON.parse(readFileSync(constantsPath, "utf-8")); + const content = `// AUTOGENERATED FILE - DO NOT EDIT +export const BN254_FR_MODULUS = ${hexToBigInt(constants.bn254_fr_modulus)}n; +export const BN254_FQ_MODULUS = ${hexToBigInt(constants.bn254_fq_modulus)}n; +export const BN254_G1_GENERATOR = { x: ${serializeCoordinate(constants.bn254_g1_generator.x)}, y: ${serializeCoordinate(constants.bn254_g1_generator.y)} } as const; +export const BN254_G2_GENERATOR = { x: ${serializeCoordinate(constants.bn254_g2_generator.x)}, y: ${serializeCoordinate(constants.bn254_g2_generator.y)} } as const; +export const GRUMPKIN_FR_MODULUS = ${hexToBigInt(constants.grumpkin_fr_modulus)}n; +export const GRUMPKIN_FQ_MODULUS = ${hexToBigInt(constants.grumpkin_fq_modulus)}n; +export const GRUMPKIN_G1_GENERATOR = { x: ${serializeCoordinate(constants.grumpkin_g1_generator.x)}, y: ${serializeCoordinate(constants.grumpkin_g1_generator.y)} } as const; +export const SECP256K1_FR_MODULUS = ${hexToBigInt(constants.secp256k1_fr_modulus)}n; +export const SECP256K1_FQ_MODULUS = ${hexToBigInt(constants.secp256k1_fq_modulus)}n; +export const SECP256K1_G1_GENERATOR = { x: ${serializeCoordinate(constants.secp256k1_g1_generator.x)}, y: ${serializeCoordinate(constants.secp256k1_g1_generator.y)} } as const; +export const SECP256R1_FR_MODULUS = ${hexToBigInt(constants.secp256r1_fr_modulus)}n; +export const SECP256R1_FQ_MODULUS = ${hexToBigInt(constants.secp256r1_fq_modulus)}n; +export const SECP256R1_G1_GENERATOR = { x: ${serializeCoordinate(constants.secp256r1_g1_generator.x)}, y: ${serializeCoordinate(constants.secp256r1_g1_generator.y)} } as const; +`; + mkdirSync(outputDir, { recursive: true }); + writeFileSync(join(outputDir, "curve_constants.ts"), content); + console.log(` ${join(outputDir, "curve_constants.ts")}`); +} + +// --------------------------------------------------------------------------- +// Main +// --------------------------------------------------------------------------- + +const args = parseArgs(process.argv.slice(2)); +generate(args); diff --git a/ipc-codegen/src/naming.ts b/ipc-codegen/src/naming.ts new file mode 100644 index 000000000000..7ae896683fda --- /dev/null +++ b/ipc-codegen/src/naming.ts @@ -0,0 +1,27 @@ +/** + * Shared naming utilities for code generators + */ + +/** + * Convert camelCase or PascalCase to snake_case + * @example toSnakeCase("Blake2s") -> "blake2s" + * @example toSnakeCase("poseidonHash") -> "poseidon_hash" + */ +export function toSnakeCase(name: string): string { + return name.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, ''); +} + +/** + * Convert snake_case to PascalCase + * @example toPascalCase("blake2s") -> "Blake2s" + * @example toPascalCase("poseidon_hash") -> "PoseidonHash" + */ +export function toPascalCase(name: string): string { + // Already PascalCase (no underscores and starts with uppercase) + if (!name.includes('_') && name[0] === name[0].toUpperCase()) { + return name; + } + return name.split('_').map(part => + part.charAt(0).toUpperCase() + part.slice(1).toLowerCase() + ).join(''); +} diff --git a/ipc-codegen/src/rust_codegen.ts b/ipc-codegen/src/rust_codegen.ts new file mode 100644 index 000000000000..e3ce5acf9d3a --- /dev/null +++ b/ipc-codegen/src/rust_codegen.ts @@ -0,0 +1,783 @@ +/** + * Rust Code Generator - String template based + * + * Philosophy: + * - String templates for file structure + * - Simple type mapping + * - Idiomatic Rust conventions + * - No complex abstraction + */ + +import type { CompiledSchema, Type, Struct, Field } from './schema_visitor.ts'; +import { toSnakeCase, toPascalCase } from './naming.ts'; + +export interface RustCodegenOptions { + /** Prefix for stripping from method names, e.g. 'Wsdb' makes WsdbGetTreeInfo -> get_tree_info */ + prefix?: string; + /** API struct name, e.g. 'WsdbApi'. Defaults to 'BarretenbergApi' */ + apiStructName?: string; + /** Import path for Backend trait. Defaults to 'crate::backend::Backend' */ + backendImport?: string; + /** Import path for error types. Defaults to 'crate::error::{IpcError, Result}' */ + errorImport?: string; + /** Import path for generated types. Defaults to 'crate::types_gen::*' */ + typesImport?: string; + /** Module doc comment for types file */ + typesDocComment?: string; + /** Module doc comment for api file */ + apiDocComment?: string; +} + +export class RustCodegen { + private errorTypeName: string = 'ErrorResponse'; + private opts: Required; + + constructor(options?: RustCodegenOptions) { + const prefix = options?.prefix ?? ''; + const name = prefix || 'Barretenberg'; + this.opts = { + prefix, + apiStructName: options?.apiStructName ?? `${name}Api`, + backendImport: options?.backendImport ?? 'super::backend::Backend', + errorImport: options?.errorImport ?? `super::error::{IpcError, Result}`, + typesImport: options?.typesImport ?? `super::${toSnakeCase(prefix || 'bb')}_types::*`, + typesDocComment: options?.typesDocComment ?? `Generated types for ${name} IPC protocol`, + apiDocComment: options?.apiDocComment ?? `${name} IPC client API`, + }; + } + + // Type mapping: Schema type -> Rust type + private mapType(type: Type): string { + switch (type.kind) { + case 'primitive': + switch (type.primitive) { + case 'bool': return 'bool'; + case 'u8': return 'u8'; + case 'u16': return 'u16'; + case 'u32': return 'u32'; + case 'u64': return 'u64'; + case 'f64': return 'f64'; + case 'string': return 'String'; + case 'bytes': return 'Vec'; + case 'fr': return 'Fr'; // 32-byte field element + case 'field2': return '[Fr; 2]'; // Extension field (Fq2) + } + break; + + case 'vector': + return `Vec<${this.mapType(type.element!)}>`; + + case 'array': + const elemType = this.mapType(type.element!); + // Large arrays become Vec for ergonomics + return type.size! > 32 ? `Vec<${elemType}>` : `[${elemType}; ${type.size}]`; + + case 'optional': + return `Option<${this.mapType(type.element!)}>`; + + case 'struct': + // Convert struct names to PascalCase for Rust conventions + return toPascalCase(type.struct!.name); + } + + return 'Unknown'; + } + + // Check if field needs serde(with = "serde_bytes") + private needsSerdeBytes(type: Type): boolean { + return type.kind === 'primitive' && type.primitive === 'bytes'; + } + + // Check if field needs serde(with = "serde_vec_bytes") + private needsSerdeVecBytes(type: Type): boolean { + return type.kind === 'vector' && this.needsSerdeBytes(type.element!); + } + + // Check if field needs serde(with = "serde_array4_bytes") - for [Vec; 4] (Poseidon2 state) + private needsSerdeArray4Bytes(type: Type): boolean { + return type.kind === 'array' && type.size === 4 && this.needsSerdeBytes(type.element!); + } + + // Generate struct field + private generateField(field: Field): string { + const rustName = toSnakeCase(field.name); + const rustType = this.mapType(field.type); + let attrs = ''; + + // Add serde rename if needed + if (field.name !== rustName) { + attrs += ` #[serde(rename = "${field.name}")]\n`; + } + + // Add serde bytes handling + if (this.needsSerdeArray4Bytes(field.type)) { + attrs += ` #[serde(with = "serde_array4_bytes")]\n`; + } else if (this.needsSerdeVecBytes(field.type)) { + attrs += ` #[serde(with = "serde_vec_bytes")]\n`; + } else if (this.needsSerdeBytes(field.type)) { + attrs += ` #[serde(with = "serde_bytes")]\n`; + } + + return `${attrs} pub ${rustName}: ${rustType},`; + } + + // Generate a struct definition + private generateStruct(struct: Struct, isCommand: boolean): string { + const rustName = toPascalCase(struct.name); + const fields = struct.fields.map(f => this.generateField(f)).join('\n'); + + // Add serde rename if struct name changed + const serdeRename = struct.name !== rustName + ? `\n#[serde(rename = "${struct.name}")]` + : ''; + + // Commands have a __typename used for NamedUnion identification, but it's handled + // by the Command enum's custom serde, not by the struct itself. + const typenameField = isCommand + ? ` #[serde(rename = "__typename", skip, default)]\n pub type_name: String,\n` + : ''; + + // Generate constructor for commands + const constructor = isCommand ? this.generateConstructor(struct, rustName) : ''; + + return `/// ${struct.name} +#[derive(Debug, Clone, Serialize, Deserialize)]${serdeRename} +pub struct ${rustName} { +${typenameField}${fields} +}${constructor}`; + } + + // Generate constructor for command structs + private generateConstructor(struct: Struct, rustName: string): string { + const params = struct.fields.map(f => + `${toSnakeCase(f.name)}: ${this.mapType(f.type)}` + ).join(', '); + + const fieldInits = [ + ` type_name: "${struct.name}".to_string(),`, + ...struct.fields.map(f => ` ${toSnakeCase(f.name)},`), + ].join('\n'); + + return ` + +impl ${rustName} { + pub fn new(${params}) -> Self { + Self { +${fieldInits} + } + } +}`; + } + + // Generate Command enum + private generateCommandEnum(schema: CompiledSchema): string { + const names = schema.commands.map(c => c.name); + const variants = names + .map(name => { + const rustName = toPascalCase(name); + return ` ${rustName}(${rustName}),`; + }) + .join('\n'); + + const serializeCases = names + .map(name => { + const rustName = toPascalCase(name); + return ` Command::${rustName}(data) => { + tuple.serialize_element("${name}")?; + tuple.serialize_element(data)?; + }`; + }) + .join('\n'); + + const deserializeCases = names + .map(name => { + const rustName = toPascalCase(name); + return ` "${name}" => { + let data = seq.next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(1, &self))?; + Ok(Command::${rustName}(data)) + }`; + }) + .join('\n'); + + const variantNames = names + .map(name => `"${name}"`) + .join(', '); + + return `/// Command enum - wraps all possible commands +#[derive(Debug, Clone)] +pub enum Command { +${variants} +} + +impl Serialize for Command { + fn serialize(&self, serializer: S) -> Result + where S: serde::Serializer { + use serde::ser::SerializeTuple; + let mut tuple = serializer.serialize_tuple(2)?; + match self { +${serializeCases} + } + tuple.end() + } +} + +impl<'de> Deserialize<'de> for Command { + fn deserialize(deserializer: D) -> Result + where D: serde::Deserializer<'de> { + use serde::de::{SeqAccess, Visitor}; + struct CommandVisitor; + + impl<'de> Visitor<'de> for CommandVisitor { + type Value = Command; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a 2-element array [name, payload]") + } + fn visit_seq(self, mut seq: A) -> Result + where A: SeqAccess<'de> { + let name: String = seq.next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(0, &self))?; + match name.as_str() { +${deserializeCases} + _ => Err(serde::de::Error::unknown_variant(&name, &[${variantNames}])), + } + } + } + deserializer.deserialize_tuple(2, CommandVisitor) + } +}`; + } + + // Generate Response enum + private generateResponseEnum(schema: CompiledSchema): string { + // Include all response types from commands plus ErrorResponse if it exists + const commandResponseTypes = Array.from(new Set(schema.commands.map(c => c.responseType))); + const errorName = schema.errorTypeName || 'ErrorResponse'; + const responseTypes = schema.responses.has(errorName) + ? [...commandResponseTypes, errorName] + : commandResponseTypes; + const variants = responseTypes + .map(name => { + const rustName = toPascalCase(name); + return ` ${rustName}(${rustName}),`; + }) + .join('\n'); + + const serializeCases = responseTypes + .map(name => { + const rustName = toPascalCase(name); + return ` Response::${rustName}(data) => { + tuple.serialize_element("${name}")?; + tuple.serialize_element(data)?; + }`; + }) + .join('\n'); + + const deserializeCases = responseTypes + .map(name => { + const rustName = toPascalCase(name); + return ` "${name}" => { + let data = seq.next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(1, &self))?; + Ok(Response::${rustName}(data)) + }`; + }) + .join('\n'); + + const variantNames = responseTypes.map(name => `"${name}"`).join(', '); + + return `/// Response enum - wraps all possible responses +#[derive(Debug, Clone)] +pub enum Response { +${variants} +} + +impl Serialize for Response { + fn serialize(&self, serializer: S) -> Result + where S: serde::Serializer { + use serde::ser::SerializeTuple; + let mut tuple = serializer.serialize_tuple(2)?; + match self { +${serializeCases} + } + tuple.end() + } +} + +impl<'de> Deserialize<'de> for Response { + fn deserialize(deserializer: D) -> Result + where D: serde::Deserializer<'de> { + use serde::de::{SeqAccess, Visitor}; + struct ResponseVisitor; + + impl<'de> Visitor<'de> for ResponseVisitor { + type Value = Response; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a 2-element array [name, payload]") + } + fn visit_seq(self, mut seq: A) -> Result + where A: SeqAccess<'de> { + let name: String = seq.next_element()? + .ok_or_else(|| serde::de::Error::invalid_length(0, &self))?; + match name.as_str() { +${deserializeCases} + _ => Err(serde::de::Error::unknown_variant(&name, &[${variantNames}])), + } + } + } + deserializer.deserialize_tuple(2, ResponseVisitor) + } +}`; + } + + // Generate serde helper modules + private generateSerdeHelpers(): string { + return `mod serde_bytes { + use serde::{Deserialize, Deserializer, Serializer}; + pub fn serialize(bytes: &Vec, serializer: S) -> Result + where S: Serializer { serializer.serialize_bytes(bytes) } + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where D: Deserializer<'de> { >::deserialize(deserializer) } +} + +mod serde_vec_bytes { + use serde::{Deserialize, Deserializer, Serializer, Serialize}; + use serde::ser::SerializeSeq; + use serde::de::{SeqAccess, Visitor}; + + #[derive(Serialize, Deserialize)] + struct BytesWrapper(#[serde(with = "super::serde_bytes")] Vec); + + pub fn serialize(vec: &Vec>, serializer: S) -> Result + where S: Serializer { + let mut seq = serializer.serialize_seq(Some(vec.len()))?; + for bytes in vec { + seq.serialize_element(&BytesWrapper(bytes.clone()))?; + } + seq.end() + } + pub fn deserialize<'de, D>(deserializer: D) -> Result>, D::Error> + where D: Deserializer<'de> { + struct VecVecU8Visitor; + impl<'de> Visitor<'de> for VecVecU8Visitor { + type Value = Vec>; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a sequence of byte arrays") + } + fn visit_seq(self, mut seq: A) -> Result + where A: SeqAccess<'de> { + let mut vec = Vec::new(); + while let Some(wrapper) = seq.next_element::()? { + vec.push(wrapper.0); + } + Ok(vec) + } + } + deserializer.deserialize_seq(VecVecU8Visitor) + } +} + +mod serde_array4_bytes { + use serde::{Deserialize, Deserializer, Serialize, Serializer}; + use serde::ser::SerializeTuple; + use serde::de::{SeqAccess, Visitor}; + + #[derive(Serialize, Deserialize)] + struct BytesWrapper(#[serde(with = "super::serde_bytes")] Vec); + + pub fn serialize(arr: &[Vec; 4], serializer: S) -> Result + where S: Serializer { + let mut tup = serializer.serialize_tuple(4)?; + for bytes in arr { + tup.serialize_element(&BytesWrapper(bytes.clone()))?; + } + tup.end() + } + pub fn deserialize<'de, D>(deserializer: D) -> Result<[Vec; 4], D::Error> + where D: Deserializer<'de> { + struct Array4Visitor; + impl<'de> Visitor<'de> for Array4Visitor { + type Value = [Vec; 4]; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("an array of 4 byte arrays") + } + fn visit_seq(self, mut seq: A) -> Result + where A: SeqAccess<'de> { + let mut arr: [Vec; 4] = Default::default(); + for (i, item) in arr.iter_mut().enumerate() { + *item = seq.next_element::()? + .ok_or_else(|| serde::de::Error::invalid_length(i, &self))?.0; + } + Ok(arr) + } + } + deserializer.deserialize_tuple(4, Array4Visitor) + } +}`; + } + + // Generate types file + generateTypes(schema: CompiledSchema, schemaHash?: string): string { + this.errorTypeName = schema.errorTypeName || 'ErrorResponse'; + // Create set of top-level command struct names (only these need __typename) + const commandNames = new Set(schema.commands.map(c => c.name)); + + // Generate all structs (commands first, then responses) + const commandStructs = Array.from(schema.structs.values()) + .map(s => this.generateStruct(s, commandNames.has(s.name))) + .join('\n\n'); + + const responseStructs = Array.from(schema.responses.values()) + .map(s => this.generateStruct(s, false)) + .join('\n\n'); + + const hashLine = schemaHash ? `\n/// Schema version hash for compatibility checking\npub const SCHEMA_HASH: &str = "${schemaHash}";\n` : ''; + + return `//! AUTOGENERATED - DO NOT EDIT +//! ${this.opts.typesDocComment} + +use serde::{Deserialize, Serialize}; +${hashLine} +/// 32-byte field element (Fr/Fq). Fixed-size, stack-allocated, no heap. +/// Serializes as msgpack bin32 on the wire. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fr(pub [u8; 32]); + +impl Fr { + pub fn from_bytes(bytes: [u8; 32]) -> Self { Self(bytes) } + pub fn to_bytes(&self) -> &[u8; 32] { &self.0 } + pub fn as_slice(&self) -> &[u8] { &self.0 } +} + +impl Serialize for Fr { + fn serialize(&self, serializer: S) -> Result + where S: serde::Serializer { + serializer.serialize_bytes(&self.0) + } +} + +impl<'de> Deserialize<'de> for Fr { + fn deserialize(deserializer: D) -> Result + where D: serde::Deserializer<'de> { + let bytes: Vec = >::deserialize(deserializer)?; + let arr: [u8; 32] = bytes.try_into() + .map_err(|v: Vec| serde::de::Error::invalid_length(v.len(), &"32 bytes"))?; + Ok(Fr(arr)) + } +} + +${this.generateSerdeHelpers()} + +${commandStructs} + +${responseStructs} + +${this.generateCommandEnum(schema)} + +${this.generateResponseEnum(schema)} +`; + } + + /** Strip the service prefix from a command name for the method name */ + private methodName(commandName: string): string { + const withoutPrefix = this.opts.prefix && commandName.startsWith(this.opts.prefix) + ? commandName.slice(this.opts.prefix.length) + : commandName; + return toSnakeCase(withoutPrefix); + } + + // Generate API method + private generateApiMethod(command: {name: string, fields: Field[], responseType: string}): string { + const methodName = this.methodName(command.name); + const cmdRustName = toPascalCase(command.name); + const respRustName = toPascalCase(command.responseType); + + const params = command.fields.map(f => { + const rustType = this.mapType(f.type); + // Only convert simple Vec to &[u8], not nested types + const apiType = rustType === 'Vec' ? '&[u8]' : rustType; + return `${toSnakeCase(f.name)}: ${apiType}`; + }).join(', '); + + const paramConversions = command.fields.map(f => { + const name = toSnakeCase(f.name); + const rustType = this.mapType(f.type); + // Only convert slices back to Vec + if (rustType === 'Vec') { + return `${name}.to_vec()`; + } + return name; + }).join(', '); + + // Extract error type name from the error import (e.g., 'IpcError' from 'crate::error::{IpcError, Result}') + const errorType = this.opts.errorImport.match(/\{(\w+),/)?.[1] ?? 'IpcError'; + + return ` /// Execute ${command.name} + pub fn ${methodName}(&mut self, ${params}) -> Result<${respRustName}> { + let cmd = Command::${cmdRustName}(${cmdRustName}::new(${paramConversions})); + match self.execute(cmd)? { + Response::${respRustName}(resp) => Ok(resp), + Response::${toPascalCase(this.errorTypeName)}(err) => Err(${errorType}::Backend( + err.message + )), + _ => Err(${errorType}::InvalidResponse( + "Expected ${command.responseType}".to_string() + )), + } + }`; + } + + // Generate API file + generateApi(schema: CompiledSchema): string { + this.errorTypeName = schema.errorTypeName || 'ErrorResponse'; + const { apiStructName, backendImport, errorImport, typesImport, apiDocComment } = this.opts; + + // Find shutdown command name (may be prefixed, e.g. WsdbShutdown) + const shutdownCmd = schema.commands.find(c => c.name.endsWith('Shutdown')); + const shutdownName = shutdownCmd ? toPascalCase(shutdownCmd.name) : null; + + const apiMethods = schema.commands + .filter(c => !c.name.endsWith('Shutdown')) + .map(c => this.generateApiMethod(c)) + .join('\n\n'); + + const shutdownMethod = shutdownName ? ` + /// Shutdown backend gracefully + pub fn shutdown(&mut self) -> Result<()> { + let cmd = Command::${shutdownName}(${shutdownName}::new()); + let _ = self.execute(cmd)?; + self.backend.destroy() + } +` : ''; + + const errorType = errorImport.match(/\{(\w+),/)?.[1] ?? 'IpcError'; + + return `//! AUTOGENERATED - DO NOT EDIT +//! ${apiDocComment} + +use ${backendImport}; +use ${errorImport}; +use ${typesImport}; + +/// ${apiDocComment} +pub struct ${apiStructName} { + backend: B, +} + +impl ${apiStructName} { + /// Create API with custom backend + pub fn new(backend: B) -> Self { + Self { backend } + } + + fn execute(&mut self, command: Command) -> Result { + let input_buffer = rmp_serde::to_vec_named(&vec![command]) + .map_err(|e| ${errorType}::Serialization(e.to_string()))?; + + let output_buffer = self.backend.call(&input_buffer)?; + + let response: Response = rmp_serde::from_slice(&output_buffer) + .map_err(|e| ${errorType}::Deserialization(e.to_string()))?; + + Ok(response) + } + +${apiMethods} +${shutdownMethod} + /// Destroy backend without shutdown command + pub fn destroy(&mut self) -> Result<()> { + self.backend.destroy() + } +} +`; + } + + // ----------------------------------------------------------------------- + // Server-side code generation + // ----------------------------------------------------------------------- + + /** Generate a Handler trait and serve() function */ + generateServer(schema: CompiledSchema): string { + this.errorTypeName = schema.errorTypeName || 'ErrorResponse'; + const { prefix, errorImport, typesImport } = this.opts; + const errorType = errorImport.match(/\{(\w+),/)?.[1] ?? 'IpcError'; + const errorRespType = toPascalCase(this.errorTypeName); + + const traitMethods = schema.commands + .filter(c => !c.name.endsWith('Shutdown')) + .map(c => { + const methodName = this.methodName(c.name); + const cmdRustName = toPascalCase(c.name); + const respRustName = toPascalCase(c.responseType); + return ` fn ${methodName}(&mut self, cmd: ${cmdRustName}) -> Result<${respRustName}>;`; + }) + .join('\n'); + + const dispatchArms = schema.commands + .filter(c => !c.name.endsWith('Shutdown')) + .map(c => { + const methodName = this.methodName(c.name); + const cmdRustName = toPascalCase(c.name); + const respRustName = toPascalCase(c.responseType); + return ` Command::${cmdRustName}(cmd) => { + match handler.${methodName}(cmd) { + Ok(resp) => Response::${respRustName}(resp), + Err(e) => Response::${errorRespType}(${errorRespType} { message: e.to_string() }), + } + }`; + }) + .join('\n'); + + // Handle shutdown arm + const shutdownCmd = schema.commands.find(c => c.name.endsWith('Shutdown')); + const shutdownArm = shutdownCmd + ? ` Command::${toPascalCase(shutdownCmd.name)}(_) => { + return Err(${errorType}::Backend("shutdown requested".to_string())); + }` + : ''; + + return `//! AUTOGENERATED - DO NOT EDIT +//! Server-side dispatch for ${prefix || 'service'} IPC protocol + +use ${errorImport}; +use ${typesImport}; + +/// Handler trait — implement this to serve ${prefix || 'service'} commands. +pub trait Handler { +${traitMethods} +} + +/// Dispatch a single command to the handler and return the response. +pub fn dispatch(handler: &mut dyn Handler, command: Command) -> Result { + let response = match command { +${dispatchArms} +${shutdownArm} + }; + Ok(response) +} +`; + } + + // ----------------------------------------------------------------------- + // Skeleton generation (one-time handler stubs + main + build files) + // ----------------------------------------------------------------------- + + /** Generate handler stub implementations that return unimplemented errors */ + generateHandlerStubs(schema: CompiledSchema): string { + const { prefix } = this.opts; + const typesModule = `${toSnakeCase(prefix)}_types`; + const serverModule = `${toSnakeCase(prefix)}_server`; + const ctxName = `${prefix}Context`; + + const stubs = schema.commands + .filter(c => !c.name.endsWith('Shutdown')) + .map(c => { + const methodName = this.methodName(c.name); + const cmdRustName = toPascalCase(c.name); + const respRustName = toPascalCase(c.responseType); + return ` fn ${methodName}(&mut self, _cmd: ${typesModule}::${cmdRustName}) -> Result<${typesModule}::${respRustName}> { + unimplemented!("${c.name}") + }`; + }).join('\n\n'); + + return `// Handler stubs — implement your service logic here. +// This file is generated ONCE. Edit freely — it will not be overwritten. + +mod generated { + pub mod ${typesModule}; + pub mod ${serverModule}; + pub mod ipc_server; +} + +use generated::${typesModule}; +use generated::${serverModule}; + +/// Shared context for your service — add database connections, state, etc. +pub struct ${ctxName} { + // Add your shared state here +} + +/// Handler implementation +pub struct ${prefix}Handler { + pub ctx: ${ctxName}, +} + +impl ${serverModule}::Handler for ${prefix}Handler { +${stubs} +} +`; + } + + /** Generate a main.rs entry point for a standalone service */ + generateMain(schema: CompiledSchema): string { + const { prefix } = this.opts; + const ctxName = `${prefix}Context`; + const serverModule = `${toSnakeCase(prefix)}_server`; + + return `// Entry point for ${prefix} service. +// This file is generated ONCE. Edit freely — it will not be overwritten. + +mod ${toSnakeCase(prefix)}_handlers; + +use ${toSnakeCase(prefix)}_handlers::{${ctxName}, ${prefix}Handler}; + +fn main() { + let socket_path = std::env::args().nth(1).expect("Usage: ${toSnakeCase(prefix)} "); + + let ctx = ${ctxName} {}; + let mut handler = ${prefix}Handler { ctx }; + + eprintln!("${prefix} server starting on {}", socket_path); + generated::ipc_server::serve(&socket_path, &mut handler); +} +`; + } + + /** Generate Cargo.toml for a standalone service */ + generateBuildFile(schema: CompiledSchema): string { + const { prefix } = this.opts; + const pkgName = toSnakeCase(prefix).replace(/_/g, '-'); + + return `[package] +name = "${pkgName}-service" +version = "0.1.0" +edition = "2021" + +[[bin]] +name = "${pkgName}" +path = "main.rs" + +[dependencies] +rmp-serde = "1" +serde = { version = "1", features = ["derive"] } +`; + } + + /** Generate .gitignore for the skeleton project */ + generateGitignore(): string { + return `# Generated IPC code — do not edit, re-run generate.sh instead +generated/ +target/ +`; + } + + /** Generate a shell script to re-run codegen */ + generateGenerateScript(schemaPath: string): string { + const { prefix } = this.opts; + return `#!/usr/bin/env bash +# Re-generate IPC types, server, and client from schema. +# Run from the project root directory. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)" +SCHEMA="${schemaPath}" + +node --experimental-strip-types "$(dirname "$SCRIPT_DIR")/codegen/src/generate.ts" \\ + --schema "$SCHEMA" \\ + --lang rust \\ + --out "$SCRIPT_DIR/generated" \\ + --prefix ${prefix} \\ + --server +`; + } +} diff --git a/ipc-codegen/src/schema_visitor.ts b/ipc-codegen/src/schema_visitor.ts new file mode 100644 index 000000000000..572c0d6330a4 --- /dev/null +++ b/ipc-codegen/src/schema_visitor.ts @@ -0,0 +1,234 @@ +/** + * Schema Visitor - Minimal abstraction over raw msgpack schema + * + * Philosophy: + * - Keep raw schema structure + * - Resolve type references into a graph + * - No normalization - languages handle their own conventions + * - Output is "compiled schema" with resolved types + */ + +export type PrimitiveType = 'bool' | 'u8' | 'u16' | 'u32' | 'u64' | 'f64' | 'string' | 'bytes' | 'fr' | 'field2' | 'enum_u32' | 'map_u32_pair'; + +export interface Type { + kind: 'primitive' | 'vector' | 'array' | 'optional' | 'struct'; + primitive?: PrimitiveType; + element?: Type; // For vector, array, optional + size?: number; // For array + struct?: Struct; // For struct types + originalName?: string; // Original type name from schema (e.g. 'MerkleTreeId', 'unordered_map') +} + +export interface Field { + name: string; + type: Type; +} + +export interface Struct { + name: string; + fields: Field[]; +} + +export interface Command { + name: string; + fields: Field[]; + responseType: string; +} + +export interface CompiledSchema { + // All unique struct types discovered + structs: Map; + + // Command -> Response mappings + commands: Command[]; + + // Response types + responses: Map; + + // Error response type name (e.g. 'WsdbErrorResponse') + errorTypeName?: string; +} + +/** + * SchemaVisitor - Walks raw msgpack schema and resolves references + */ +export class SchemaVisitor { + private structs = new Map(); + private responses = new Map(); + + visit(commandsSchema: any, responsesSchema: any): CompiledSchema { + // Reset state + this.structs.clear(); + this.responses.clear(); + + const commands: Command[] = []; + + // Schema format: ["named_union", [[name, schema], ...]] + const commandPairs = commandsSchema[1] as Array<[string, any]>; + const responsePairs = responsesSchema[1] as Array<[string, any]>; + + // First, visit all response types (including ErrorResponse) + for (const [respName, respSchema] of responsePairs) { + if (typeof respSchema !== 'string') { + const respStruct = this.visitStruct(respName, respSchema); + this.responses.set(respName, respStruct); + } + } + + // Find the error response type name (e.g. 'WsdbErrorResponse') + const errorResponses = responsePairs.filter(([name]: [string, any]) => name.endsWith('ErrorResponse')); + const errorTypeName = errorResponses.length > 0 ? errorResponses[0][0] : undefined; + + // Visit all commands and pair with responses + const normalResponses = responsePairs.filter(([name]: [string, any]) => !name.endsWith('ErrorResponse')); + for (let i = 0; i < commandPairs.length; i++) { + const [cmdName, cmdSchema] = commandPairs[i]; + const [respName] = normalResponses[i]; + + // Discover command structure + const cmdStruct = this.visitStruct(cmdName, cmdSchema); + this.structs.set(cmdName, cmdStruct); + + // Create command mapping + commands.push({ + name: cmdName, + fields: cmdStruct.fields, + responseType: respName, + }); + } + + return { + structs: this.structs, + commands, + responses: this.responses, + errorTypeName, + }; + } + + private visitStruct(name: string, schema: any): Struct { + const fields: Field[] = []; + + // Schema is an object with __typename and fields + for (const [key, value] of Object.entries(schema)) { + if (key === '__typename') continue; + + fields.push({ + name: key, + type: this.visitType(value), + }); + } + + return { name, fields }; + } + + private visitType(schema: any): Type { + // Primitive string type + if (typeof schema === 'string') { + return this.resolvePrimitive(schema); + } + + // Array type descriptor: ['vector', [elementType]] + if (Array.isArray(schema)) { + const [kind, args] = schema; + + switch (kind) { + case 'vector': { + const [elemType] = args as [any]; + // Special case: vector = bytes + if (elemType === 'unsigned char') { + return { kind: 'primitive', primitive: 'bytes' }; + } + return { + kind: 'vector', + element: this.visitType(elemType), + }; + } + + case 'array': { + const [elemType, size] = args as [any, number]; + // Special case: array = field element (Fr/Fq) + if (elemType === 'unsigned char' && size === 32) { + return { kind: 'primitive', primitive: 'fr' }; + } + // Special case: array (other sizes) = bytes + if (elemType === 'unsigned char') { + return { kind: 'primitive', primitive: 'bytes' }; + } + return { + kind: 'array', + element: this.visitType(elemType), + size, + }; + } + + case 'optional': { + const [elemType] = args as [any]; + return { + kind: 'optional', + element: this.visitType(elemType), + }; + } + + case 'shared_ptr': { + // Dereference shared_ptr - just use inner type + const [innerType] = args as [any]; + return this.visitType(innerType); + } + + case 'alias': { + // Alias types (like uint256_t) are treated as bytes + return { kind: 'primitive', primitive: 'bytes' }; + } + + default: + throw new Error(`Unknown type kind: ${kind}`); + } + } + + // Inline struct definition + if (typeof schema === 'object' && schema.__typename) { + const structName = schema.__typename as string; + // Check if already visited + if (!this.structs.has(structName)) { + const struct = this.visitStruct(structName, schema); + this.structs.set(structName, struct); + } + return { + kind: 'struct', + struct: this.structs.get(structName)!, + }; + } + + throw new Error(`Cannot resolve type: ${JSON.stringify(schema)}`); + } + + private resolvePrimitive(name: string): Type { + const primitiveMap: Record = { + 'bool': 'bool', + 'int': 'u32', + 'unsigned int': 'u32', + 'unsigned short': 'u16', + 'unsigned long': 'u64', + 'unsigned long long': 'u64', + 'unsigned char': 'u8', + 'double': 'f64', + 'string': 'string', + 'bin32': 'bytes', + 'field2': 'field2', // Extension field (Fq2) - pair of field elements + 'MerkleTreeId': 'enum_u32', // C++ enum serialized as uint32 + 'unordered_map': 'map_u32_pair', // StateReference: map> + }; + + const primitive = primitiveMap[name]; + if (primitive) { + return { kind: 'primitive', primitive, originalName: name }; + } + + // Unknown primitive - treat as struct reference + // This will be resolved later if it's a real struct + return { + kind: 'struct', + struct: { name, fields: [] }, // Placeholder + }; + } +} diff --git a/ipc-codegen/src/typescript_codegen.ts b/ipc-codegen/src/typescript_codegen.ts new file mode 100644 index 000000000000..8fe9ddd29b1b --- /dev/null +++ b/ipc-codegen/src/typescript_codegen.ts @@ -0,0 +1,723 @@ +/** + * TypeScript Code Generator - String template based + * + * Philosophy: + * - String templates for file structure + * - Simple type mapping + * - Idiomatic TypeScript conventions + * - No complex abstraction + */ + +import type { + CompiledSchema, + Type, + Struct, + Field, + Command, +} from "./schema_visitor.ts"; +import { toPascalCase, toSnakeCase } from "./naming.ts"; + +function toCamelCase(name: string): string { + // If no underscores, assume already camelCase (e.g. forkId, classId) + if (!name.includes("_")) { + return name.charAt(0).toLowerCase() + name.slice(1); + } + const pascal = toPascalCase(name); + return pascal.charAt(0).toLowerCase() + pascal.slice(1); +} + +export class TypeScriptCodegen { + private errorTypeName: string = "ErrorResponse"; + /** Prefix to strip from command names when generating method names (e.g. "Bb" -> BbCircuitProve becomes circuitProve) */ + private methodPrefix: string = ""; + + constructor(options?: { stripMethodPrefix?: string }) { + if (options?.stripMethodPrefix) { + this.methodPrefix = options.stripMethodPrefix; + } + } + + /** Strip the method prefix and convert to camelCase for API method names */ + private toMethodName(commandName: string): string { + let name = commandName; + if (this.methodPrefix && name.startsWith(this.methodPrefix)) { + name = name.slice(this.methodPrefix.length); + } + return toCamelCase(name); + } + + // Type mapping: Schema type -> TypeScript type + private mapType(type: Type): string { + switch (type.kind) { + case "primitive": + switch (type.primitive) { + case "bool": + return "boolean"; + case "u8": + return "number"; + case "u16": + return "number"; + case "u32": + return "number"; + case "u64": + return "number"; + case "f64": + return "number"; + case "string": + return "string"; + case "bytes": + return "Uint8Array"; + case "fr": + return "Fr"; // 32-byte field element + case "field2": + return "[Fr, Fr]"; // Extension field (Fq2) + case "enum_u32": + return "number"; // C++ enum as integer + case "map_u32_pair": + return "Record"; // map> + } + break; + + case "vector": { + const inner = this.mapType(type.element!); + // Wrap union types in parens to avoid precedence issues: (Foo | undefined)[] + return type.element!.kind === "optional" + ? `(${inner})[]` + : `${inner}[]`; + } + + case "array": { + const inner = this.mapType(type.element!); + return type.element!.kind === "optional" + ? `(${inner})[]` + : `${inner}[]`; + } + + case "optional": + return `${this.mapType(type.element!)} | null`; + + case "struct": + return toPascalCase(type.struct!.name); + } + + return "unknown"; + } + + // Type mapping for msgpack interfaces (uses Msgpack* prefix for structs) + private mapMsgpackType(type: Type): string { + switch (type.kind) { + case "primitive": + switch (type.primitive) { + case "bool": + return "boolean"; + case "u8": + return "number"; + case "u16": + return "number"; + case "u32": + return "number"; + case "u64": + return "number"; + case "f64": + return "number"; + case "string": + return "string"; + case "bytes": + return "Uint8Array"; + case "fr": + return "Uint8Array"; // Fr on the wire is still 32 bytes + case "field2": + return "[Uint8Array, Uint8Array]"; + case "enum_u32": + return "number"; + case "map_u32_pair": + return "Record"; + } + break; + + case "vector": { + const inner = this.mapMsgpackType(type.element!); + return type.element!.kind === "optional" + ? `(${inner})[]` + : `${inner}[]`; + } + + case "array": { + const inner = this.mapMsgpackType(type.element!); + return type.element!.kind === "optional" + ? `(${inner})[]` + : `${inner}[]`; + } + + case "optional": + return `${this.mapMsgpackType(type.element!)} | null`; + + case "struct": + return `Msgpack${toPascalCase(type.struct!.name)}`; + } + + return "unknown"; + } + + // Check if type needs conversion (has nested structs) + private needsConversion(type: Type): boolean { + switch (type.kind) { + case "primitive": + return false; + case "vector": + case "array": + case "optional": + return this.needsConversion(type.element!); + case "struct": + return true; + } + return false; + } + + // Generate field + private generateField(field: Field): string { + const tsName = toCamelCase(field.name); + const tsType = this.mapType(field.type); + return ` ${tsName}: ${tsType};`; + } + + // Generate msgpack field (original names, uses Msgpack* types for structs) + private generateMsgpackField(field: Field): string { + const tsType = this.mapMsgpackType(field.type); + return ` ${field.name}: ${tsType};`; + } + + // Generate public interface + private generateInterface(struct: Struct): string { + const tsName = toPascalCase(struct.name); + const fields = struct.fields.map((f) => this.generateField(f)).join("\n"); + + return `export interface ${tsName} { +${fields} +}`; + } + + // Generate msgpack interface (internal) + private generateMsgpackInterface(struct: Struct): string { + const tsName = toPascalCase(struct.name); + const fields = struct.fields + .map((f) => this.generateMsgpackField(f)) + .join("\n"); + + return `interface Msgpack${tsName} { +${fields} +}`; + } + + // Generate to* conversion function + private generateToFunction(struct: Struct): string { + const tsName = toPascalCase(struct.name); + + if (struct.fields.length === 0) { + return `function to${tsName}(o: Msgpack${tsName}): ${tsName} { + return {}; +}`; + } + + const checks = struct.fields + .map( + (f) => + ` if (o.${f.name} === undefined) { throw new Error("Expected ${f.name} in ${tsName} deserialization"); }`, + ) + .join("\n"); + + const conversions = struct.fields + .map((f) => { + const tsFieldName = toCamelCase(f.name); + const converter = this.generateToConverter(f.type, `o.${f.name}`); + return ` ${tsFieldName}: ${converter},`; + }) + .join("\n"); + + return `function to${tsName}(o: Msgpack${tsName}): ${tsName} { +${checks}; + return { +${conversions} + }; +}`; + } + + // Generate from* conversion function + private generateFromFunction(struct: Struct): string { + const tsName = toPascalCase(struct.name); + + if (struct.fields.length === 0) { + return `function from${tsName}(o: ${tsName}): Msgpack${tsName} { + return {}; +}`; + } + + const checks = struct.fields + .map((f) => { + const tsFieldName = toCamelCase(f.name); + return ` if (o.${tsFieldName} === undefined) { throw new Error("Expected ${tsFieldName} in ${tsName} serialization"); }`; + }) + .join("\n"); + + const conversions = struct.fields + .map((f) => { + const tsFieldName = toCamelCase(f.name); + const converter = this.generateFromConverter( + f.type, + `o.${tsFieldName}`, + ); + return ` ${f.name}: ${converter},`; + }) + .join("\n"); + + return `function from${tsName}(o: ${tsName}): Msgpack${tsName} { +${checks}; + return { +${conversions} + }; +}`; + } + + // Generate converter for to* function + private generateToConverter(type: Type, value: string): string { + if (!this.needsConversion(type)) { + return value; + } + + switch (type.kind) { + case "vector": + case "array": + if (this.needsConversion(type.element!)) { + return `${value}.map((v: any) => ${this.generateToConverter(type.element!, "v")})`; + } + return value; + case "optional": + if (this.needsConversion(type.element!)) { + return `${value} != null ? ${this.generateToConverter(type.element!, value)} : null`; + } + return value; + case "struct": + return `to${toPascalCase(type.struct!.name)}(${value})`; + } + return value; + } + + // Generate converter for from* function + private generateFromConverter(type: Type, value: string): string { + if (!this.needsConversion(type)) { + return value; + } + + switch (type.kind) { + case "vector": + case "array": + if (this.needsConversion(type.element!)) { + return `${value}.map((v: any) => ${this.generateFromConverter(type.element!, "v")})`; + } + return value; + case "optional": + if (this.needsConversion(type.element!)) { + return `${value} != null ? ${this.generateFromConverter(type.element!, value)} : null`; + } + return value; + case "struct": + return `from${toPascalCase(type.struct!.name)}(${value})`; + } + return value; + } + + // Generate types file (api_types.ts) + generateTypes(schema: CompiledSchema, schemaHash?: string): string { + const allStructs = [ + ...schema.structs.values(), + ...schema.responses.values(), + ]; + + // Public interfaces + const publicInterfaces = allStructs + .map((s) => this.generateInterface(s)) + .join("\n\n"); + + // Msgpack interfaces + const msgpackInterfaces = allStructs + .map((s) => this.generateMsgpackInterface(s)) + .join("\n\n"); + + // Conversion functions + const toFunctions = allStructs + .map((s) => "export " + this.generateToFunction(s)) + .join("\n\n"); + + const fromFunctions = allStructs + .map((s) => "export " + this.generateFromFunction(s)) + .join("\n\n"); + + // BbApiBase interface + const apiMethods = schema.commands + .map( + (c) => + ` ${this.toMethodName(c.name)}(command: ${toPascalCase(c.name)}): Promise<${toPascalCase(c.responseType)}>;`, + ) + .join("\n"); + + const hashLine = schemaHash + ? `\n/** Schema version hash for compatibility checking */\nexport const SCHEMA_HASH = '${schemaHash}';\n` + : ""; + + return `// AUTOGENERATED FILE - DO NOT EDIT +${hashLine} +// Type aliases for primitive types +/** 32-byte field element (Fr/Fq). Branded Uint8Array — no arithmetic, just type safety. */ +export type Fr = Uint8Array; +export type Field2 = [Fr, Fr]; + +// Public interfaces (exported) +${publicInterfaces} + +// Private Msgpack interfaces (not exported) +${msgpackInterfaces} + +// Conversion functions (exported) +${toFunctions} + +${fromFunctions} + +// Base API interface +export interface BbApiBase { +${apiMethods} + destroy(): Promise; +} +`; + } + + // Generate API method + private generateAsyncApiMethod(command: Command): string { + const methodName = this.toMethodName(command.name); + const cmdType = toPascalCase(command.name); + const respType = toPascalCase(command.responseType); + + return ` ${methodName}(command: ${cmdType}): Promise<${respType}> { + const msgpackCommand = from${cmdType}(command); + return msgpackCall(this.backend, [["${command.name}", msgpackCommand]]).then(([variantName, result]: [string, any]) => { + if (variantName === '${this.errorTypeName}') { + throw new BBApiException(result.message || 'Unknown error from server'); + } + if (variantName !== '${command.responseType}') { + throw new BBApiException(\`Expected variant name '${command.responseType}' but got '\${variantName}'\`); + } + return to${respType}(result); + }); + }`; + } + + private generateSyncApiMethod(command: Command): string { + const methodName = this.toMethodName(command.name); + const cmdType = toPascalCase(command.name); + const respType = toPascalCase(command.responseType); + + return ` ${methodName}(command: ${cmdType}): ${respType} { + const msgpackCommand = from${cmdType}(command); + const [variantName, result] = msgpackCall(this.backend, [["${command.name}", msgpackCommand]]); + if (variantName === '${this.errorTypeName}') { + throw new BBApiException(result.message || 'Unknown error from server'); + } + if (variantName !== '${command.responseType}') { + throw new BBApiException(\`Expected variant name '${command.responseType}' but got '\${variantName}'\`); + } + return to${respType}(result); + }`; + } + + // Generate async API file + generateAsyncApi(schema: CompiledSchema): string { + this.errorTypeName = schema.errorTypeName || "ErrorResponse"; + const imports = this.generateApiImports(schema); + const methods = schema.commands + .map((c) => this.generateAsyncApiMethod(c)) + .join("\n\n"); + + return `// AUTOGENERATED FILE - DO NOT EDIT + +import { IMsgpackBackendAsync } from '../../bb_backends/interface.js'; +import { Decoder, Encoder } from 'msgpackr'; +import { BBApiException } from '../../bbapi_exception.js'; +${imports} + +async function msgpackCall(backend: IMsgpackBackendAsync, input: any[]) { + const inputBuffer = new Encoder({ useRecords: false, variableMapSize: true }).pack(input); + const encodedResult = await backend.call(inputBuffer); + return new Decoder({ useRecords: false }).unpack(encodedResult); +} + +export class AsyncApi implements BbApiBase { + constructor(protected backend: IMsgpackBackendAsync) {} + +${methods} + + destroy(): Promise { + return this.backend.destroy ? this.backend.destroy() : Promise.resolve(); + } +} +`; + } + + // Generate sync API file + generateSyncApi(schema: CompiledSchema): string { + this.errorTypeName = schema.errorTypeName || "ErrorResponse"; + const imports = this.generateApiImports(schema); + const methods = schema.commands + .map((c) => this.generateSyncApiMethod(c)) + .join("\n\n"); + + return `// AUTOGENERATED FILE - DO NOT EDIT + +import { IMsgpackBackendSync } from '../../bb_backends/interface.js'; +import { Decoder, Encoder } from 'msgpackr'; +import { BBApiException } from '../../bbapi_exception.js'; +${imports} + +function msgpackCall(backend: IMsgpackBackendSync, input: any[]) { + const inputBuffer = new Encoder({ useRecords: false, variableMapSize: true }).pack(input); + const encodedResult = backend.call(inputBuffer); + return new Decoder({ useRecords: false }).unpack(encodedResult); +} + +export class SyncApi { + constructor(protected backend: IMsgpackBackendSync) {} + +${methods} + + destroy(): void { + if (this.backend.destroy) this.backend.destroy(); + } +} +`; + } + + // Generate import statement for API files + private generateApiImports(schema: CompiledSchema): string { + const types = new Set(); + + // Add command types and their conversion functions + for (const cmd of schema.commands) { + const cmdType = toPascalCase(cmd.name); + const respType = toPascalCase(cmd.responseType); + types.add(cmdType); + types.add(respType); + types.add(`from${cmdType}`); + types.add(`to${respType}`); + } + + // Add BbApiBase + types.add("BbApiBase"); + + const sortedTypes = Array.from(types).sort(); + return `import { ${sortedTypes.join(", ")} } from './api_types.js';`; + } + + // ----------------------------------------------------------------------- + // Server-side code generation + // ----------------------------------------------------------------------- + + /** Generate a server handler interface and dispatch function */ + generateServerApi(schema: CompiledSchema): string { + this.errorTypeName = schema.errorTypeName || "ErrorResponse"; + const errorType = toPascalCase(this.errorTypeName); + + // Generate handler interface + const handlerMethods = schema.commands + .filter((c) => !c.name.endsWith("Shutdown")) + .map((c) => { + const methodName = this.toMethodName(c.name); + const cmdType = toPascalCase(c.name); + const respType = toPascalCase(c.responseType); + return ` ${methodName}(command: ${cmdType}): Promise<${respType}>;`; + }) + .join("\n"); + + // Generate dispatch switch cases + const dispatchCases = schema.commands + .filter((c) => !c.name.endsWith("Shutdown")) + .map((c) => { + const methodName = this.toMethodName(c.name); + const cmdType = toPascalCase(c.name); + const respType = toPascalCase(c.responseType); + return ` case '${c.name}': { + const cmd = to${cmdType}(payload); + const result = await handler.${methodName}(cmd); + return ['${c.responseType}', from${respType}(result)]; + }`; + }) + .join("\n"); + + // Collect imports + const importTypes = new Set(); + for (const cmd of schema.commands) { + if (cmd.name.endsWith("Shutdown")) continue; + const cmdType = toPascalCase(cmd.name); + const respType = toPascalCase(cmd.responseType); + importTypes.add(cmdType); + importTypes.add(respType); + importTypes.add(`to${cmdType}`); + importTypes.add(`from${respType}`); + } + const sortedImports = Array.from(importTypes).sort(); + + return `// AUTOGENERATED FILE - DO NOT EDIT +// Server-side dispatch for IPC protocol + +import { ${sortedImports.join(", ")} } from './api_types.js'; + +/** Handler interface — implement this to serve commands. */ +export interface Handler { +${handlerMethods} +} + +/** + * Dispatch a [commandName, payload] pair to the handler. + * Returns [responseName, responsePayload] for serialization. + */ +export async function dispatch( + handler: Handler, + commandName: string, + payload: any, +): Promise<[string, any]> { + switch (commandName) { +${dispatchCases} + default: + throw new Error(\`Unknown command: \${commandName}\`); + } +} +`; + } + + // ----------------------------------------------------------------------- + // Skeleton generation (one-time handler stubs + main + build files) + // ----------------------------------------------------------------------- + + /** Generate handler stub implementations that throw "not implemented" */ + generateHandlerStubs(schema: CompiledSchema, prefix: string): string { + const serverModule = `${toSnakeCase(prefix)}_server`; + + // Collect import types + const importTypes = new Set(); + for (const cmd of schema.commands) { + if (cmd.name.endsWith("Shutdown")) continue; + importTypes.add(toPascalCase(cmd.name)); + importTypes.add(toPascalCase(cmd.responseType)); + } + importTypes.add("Handler"); + const sortedImports = Array.from(importTypes).sort(); + + const stubs = schema.commands + .filter((c) => !c.name.endsWith("Shutdown")) + .map((c) => { + const methodName = this.toMethodName(c.name); + const cmdType = toPascalCase(c.name); + const respType = toPascalCase(c.responseType); + return ` async ${methodName}(command: ${cmdType}): Promise<${respType}> { + throw new Error('not implemented: ${c.name}'); + }`; + }) + .join("\n\n"); + + return `// Handler stubs — implement your service logic here. +// This file is generated ONCE. Edit freely — it will not be overwritten. + +import { ${sortedImports.join(", ")} } from './generated/${serverModule}.js'; + +/** Shared context for your service — add database connections, state, etc. */ +export interface ${prefix}Context { + // Add your shared state here +} + +/** Handler implementation */ +export class ${prefix}Handler implements Handler { + constructor(public ctx: ${prefix}Context) {} + +${stubs} +} +`; + } + + /** Generate a main.ts entry point for a standalone service */ + generateMain(schema: CompiledSchema, prefix: string): string { + const serverModule = `${toSnakeCase(prefix)}_server`; + + return `// Entry point for ${prefix} service. +// This file is generated ONCE. Edit freely — it will not be overwritten. + +import { serve } from './generated/ipc_server.js'; +import { dispatch } from './generated/${serverModule}.js'; +import { ${prefix}Handler } from './${toSnakeCase(prefix)}_handlers.js'; + +const socketPath = process.argv[2]; +if (!socketPath) { + console.error('Usage: ${toSnakeCase(prefix)} '); + process.exit(1); +} + +const ctx = {}; +const handler = new ${prefix}Handler(ctx); + +console.error(\`${prefix} server starting on \${socketPath}\`); +serve(socketPath, (commandName: string, payload: any) => dispatch(handler, commandName, payload)); +`; + } + + /** Generate package.json for a standalone service */ + generateBuildFile(prefix: string): string { + const pkgName = toSnakeCase(prefix).replace(/_/g, "-"); + + return ( + JSON.stringify( + { + name: `${pkgName}-service`, + version: "0.1.0", + type: "module", + scripts: { + build: "tsc", + start: "node --experimental-strip-types main.ts", + generate: "bash generate.sh", + }, + dependencies: { + msgpackr: "^1.10.0", + }, + devDependencies: { + typescript: "^5.4.0", + }, + }, + null, + 2, + ) + "\n" + ); + } + + /** Generate .gitignore for the skeleton project */ + generateGitignore(): string { + return `# Generated IPC code — do not edit, re-run generate.sh instead +generated/ +node_modules/ +dist/ +`; + } + + /** Generate a shell script to re-run codegen */ + generateGenerateScript(schemaPath: string, prefix: string): string { + return `#!/usr/bin/env bash +# Re-generate IPC types, server, and client from schema. +# Run from the project root directory. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)" +SCHEMA="${schemaPath}" + +node --experimental-strip-types "$(dirname "$SCRIPT_DIR")/codegen/src/generate.ts" \\ + --schema "$SCHEMA" \\ + --lang ts \\ + --out "$SCRIPT_DIR/generated" \\ + --prefix ${prefix} \\ + --server +`; + } +} diff --git a/ipc-codegen/src/zig_codegen.ts b/ipc-codegen/src/zig_codegen.ts new file mode 100644 index 000000000000..459237cc340f --- /dev/null +++ b/ipc-codegen/src/zig_codegen.ts @@ -0,0 +1,651 @@ +/** + * Zig Code Generator + * + * Generates Zig structs, serialization/deserialization functions, and IPC client + * from a CompiledSchema. Uses zig-msgpack's Payload API for wire encoding. + * + * Since Zig has no reflection-based serde, all serialization code is generated + * explicitly per struct. + */ + +import type { CompiledSchema, Type, Struct, Field, Command } from './schema_visitor.ts'; +import { toSnakeCase, toPascalCase } from './naming.ts'; + +export interface ZigCodegenOptions { + /** Service prefix to strip from method names (e.g., 'Wsdb') */ + prefix?: string; + /** Client struct name (e.g., 'WsdbClient') */ + clientName?: string; +} + +export class ZigCodegen { + private errorTypeName: string = 'ErrorResponse'; + private opts: Required; + + constructor(options?: ZigCodegenOptions) { + this.opts = { + prefix: options?.prefix ?? '', + clientName: options?.clientName ?? 'Client', + }; + } + + /** Map schema type to Zig type */ + private mapType(type: Type): string { + switch (type.kind) { + case 'primitive': + switch (type.primitive) { + case 'bool': return 'bool'; + case 'u8': return 'u8'; + case 'u16': return 'u16'; + case 'u32': return 'u32'; + case 'u64': return 'u64'; + case 'f64': return 'f64'; + case 'string': return '[]const u8'; + case 'bytes': return '[]const u8'; + case 'fr': return 'Fr'; // [32]u8 + case 'field2': return '[2]Fr'; + case 'enum_u32': return 'u32'; + case 'map_u32_pair': return 'void'; // TODO: proper map support + } + break; + case 'vector': + return `[]const ${this.mapType(type.element!)}`; + case 'array': + return `[${type.size}]${this.mapType(type.element!)}`; + case 'optional': + return `?${this.mapType(type.element!)}`; + case 'struct': + return toPascalCase(type.struct!.name); + } + return 'void'; + } + + /** Generate a Zig field-to-payload conversion expression */ + private fieldToPayload(fieldExpr: string, type: import('./schema_visitor.ts').Type): string { + switch (type.kind) { + case 'primitive': + switch (type.primitive) { + case 'bool': return `Payload{ .bool = ${fieldExpr} }`; + case 'u8': case 'u16': case 'u32': case 'u64': + return `Payload{ .uint = @intCast(${fieldExpr}) }`; + case 'f64': return `Payload{ .float = ${fieldExpr} }`; + case 'string': return `try Payload.strToPayload(${fieldExpr}, allocator)`; + case 'bytes': return `try Payload.binToPayload(${fieldExpr}, allocator)`; + case 'fr': return `try Payload.binToPayload(&${fieldExpr}, allocator)`; + case 'enum_u32': return `Payload{ .uint = @intCast(${fieldExpr}) }`; + default: return `Payload{ .nil = {} }`; + } + case 'optional': + return `if (${fieldExpr}) |v| ${this.fieldToPayload('v', type.element!)} else Payload{ .nil = {} }`; + case 'vector': { + // For vectors, build an array payload + return `blk: { + var arr = try Payload.arrPayload(${fieldExpr}.len, allocator); + for (${fieldExpr}, 0..) |item, i| { + try arr.setArrElement(i, ${this.fieldToPayload('item', type.element!)}); + } + break :blk arr; + }`; + } + case 'struct': + return `try ${fieldExpr}.toPayload(allocator)`; + default: return `Payload{ .nil = {} }`; + } + } + + /** Generate a Zig payload-to-field conversion expression */ + private fieldFromPayload(payloadExpr: string, type: import('./schema_visitor.ts').Type): string { + switch (type.kind) { + case 'primitive': + switch (type.primitive) { + case 'bool': return `try ${payloadExpr}.asBool()`; + case 'u8': return `@intCast(try ${payloadExpr}.asUint())`; + case 'u16': return `@intCast(try ${payloadExpr}.asUint())`; + case 'u32': return `@intCast(try ${payloadExpr}.asUint())`; + case 'u64': return `try ${payloadExpr}.asUint()`; + case 'f64': return `try ${payloadExpr}.asFloat()`; + case 'string': return `try ${payloadExpr}.asStr()`; + case 'bytes': return `${payloadExpr}.bin.value()`; + case 'fr': return `${payloadExpr}.bin.value()[0..32].*`; + case 'enum_u32': return `@intCast(try ${payloadExpr}.asUint())`; + default: return `undefined`; + } + case 'vector': { + const elemConv = this.fieldFromPayload('elem', type.element!); + return `blk: { + const arr_len = try ${payloadExpr}.getArrLen(); + var result = try std.heap.page_allocator.alloc(${this.mapType(type.element!)}, arr_len); + for (0..arr_len) |i| { + const elem = try ${payloadExpr}.getArrElement(i); + result[i] = ${elemConv}; + } + break :blk result; + }`; + } + case 'optional': + return `if (${payloadExpr} == .nil) null else ${this.fieldFromPayload(payloadExpr, type.element!)}`; + case 'struct': + return `try ${toPascalCase(type.struct!.name)}.fromPayload(${payloadExpr})`; + default: return `undefined`; + } + } + + /** Generate a Zig struct definition with toPayload/fromPayload methods */ + private generateStruct(struct: Struct): string { + const zigName = toPascalCase(struct.name); + const fields = struct.fields.map(f => { + const zigFieldName = toSnakeCase(f.name); + const zigType = this.mapType(f.type); + return ` ${zigFieldName}: ${zigType},`; + }).join('\n'); + + // Treat structs with only void fields as empty (void comes from unmapped types) + const hasFields = struct.fields.length > 0 && struct.fields.some(f => this.mapType(f.type) !== 'void'); + + // toPayload method + const toPayloadFields = struct.fields.map(f => { + const zigFieldName = toSnakeCase(f.name); + return ` try map.mapPut("${f.name}", ${this.fieldToPayload(`self.${zigFieldName}`, f.type)});`; + }).join('\n'); + + // fromPayload method + const fromPayloadFields = struct.fields.map(f => { + const zigFieldName = toSnakeCase(f.name); + return ` .${zigFieldName} = ${this.fieldFromPayload(`(try payload.mapGet("${f.name}")).?`, f.type)},`; + }).join('\n'); + + // Empty structs: suppress unused parameter warnings + if (!hasFields) { + return `/// ${struct.name} +pub const ${zigName} = struct { + + pub fn toPayload(_: ${zigName}, allocator: std.mem.Allocator) !Payload { + return Payload.mapPayload(allocator); + } + + pub fn fromPayload(_: Payload) !${zigName} { + return ${zigName}{}; + } +};`; + } + + return `/// ${struct.name} +pub const ${zigName} = struct { +${fields} + + pub fn toPayload(self: ${zigName}, allocator: std.mem.Allocator) !Payload { + var map = Payload.mapPayload(allocator); +${toPayloadFields} + return map; + } + + pub fn fromPayload(payload: Payload) !${zigName} { + return ${zigName}{ +${fromPayloadFields} + }; + } +};`; + } + + /** Generate serialize function for a struct */ + private generateSerializeFn(struct: Struct): string { + const zigName = toPascalCase(struct.name); + const fieldCount = struct.fields.length; + + const fieldPacks = struct.fields.map(f => { + const zigFieldName = toSnakeCase(f.name); + return ` try packField(packer, "${f.name}", self.${zigFieldName});`; + }).join('\n'); + + return `pub fn serialize${zigName}(self: ${zigName}, packer: anytype) !void { + try packer.writeMapHeader(${fieldCount}); +${fieldPacks} +}`; + } + + /** Generate deserialize function for a struct */ + private generateDeserializeFn(struct: Struct): string { + const zigName = toPascalCase(struct.name); + + const fieldReads = struct.fields.map(f => { + const zigFieldName = toSnakeCase(f.name); + const zigType = this.mapType(f.type); + return ` .${zigFieldName} = try readField(${zigType}, unpacker, "${f.name}"),`; + }).join('\n'); + + return `pub fn deserialize${zigName}(unpacker: anytype, allocator: std.mem.Allocator) !${zigName} { + _ = allocator; + const map_len = try unpacker.readMapHeader(); + _ = map_len; + return ${zigName}{ +${fieldReads} + }; +}`; + } + + /** Generate the Command tagged union */ + private generateCommandUnion(schema: CompiledSchema): string { + const variants = schema.commands.map(c => { + const zigName = toPascalCase(c.name); + return ` ${toSnakeCase(c.name)}: ${zigName},`; + }).join('\n'); + + const nameMap = schema.commands.map(c => { + return ` .${toSnakeCase(c.name)} => "${c.name}",`; + }).join('\n'); + + return `/// Tagged union of all commands +pub const Command = union(enum) { +${variants} + + pub fn schemaName(self: Command) []const u8 { + return switch (self) { +${nameMap} + }; + } +};`; + } + + /** Generate the Response tagged union */ + private generateResponseUnion(schema: CompiledSchema): string { + const commandResponseTypes = Array.from(new Set(schema.commands.map(c => c.responseType))); + const errorName = schema.errorTypeName || 'ErrorResponse'; + const responseTypes = schema.responses.has(errorName) + ? [...commandResponseTypes, errorName] + : commandResponseTypes; + + const variants = responseTypes.map(name => { + const zigName = toPascalCase(name); + return ` ${toSnakeCase(name)}: ${zigName},`; + }).join('\n'); + + return `/// Tagged union of all responses +pub const Response = union(enum) { +${variants} +};`; + } + + /** Generate the types file */ + generateTypes(schema: CompiledSchema, schemaHash?: string): string { + this.errorTypeName = schema.errorTypeName || 'ErrorResponse'; + + const allStructs = [...schema.structs.values(), ...schema.responses.values()]; + + const structDefs = allStructs.map(s => this.generateStruct(s)).join('\n\n'); + + const hashLine = schemaHash + ? `\n/// Schema version hash for compatibility checking\npub const SCHEMA_HASH = "${schemaHash}";\n` + : ''; + + return `//! AUTOGENERATED - DO NOT EDIT +//! Generated from Aztec IPC msgpack schema +//! +//! Each struct has toPayload() and fromPayload() methods that convert +//! to/from zig-msgpack Payload objects for serialization. + +const std = @import("std"); +const msgpack = @import("msgpack"); +const Payload = msgpack.Payload; +const PackerIO = msgpack.PackerIO; +${hashLine} +/// 32-byte field element (Fr/Fq). Fixed-size, stack-allocated. +pub const Fr = [32]u8; + +// --------------------------------------------------------------------------- +// Type definitions +// --------------------------------------------------------------------------- + +${structDefs} + +// --------------------------------------------------------------------------- +// Command / Response unions +// --------------------------------------------------------------------------- + +${this.generateCommandUnion(schema)} + +${this.generateResponseUnion(schema)} +`; + } + + /** Strip service prefix from command name for method naming */ + private methodName(commandName: string): string { + const withoutPrefix = this.opts.prefix && commandName.startsWith(this.opts.prefix) + ? commandName.slice(this.opts.prefix.length) + : commandName; + return toSnakeCase(withoutPrefix); + } + + /** Generate the client wrapper — typed methods parameterized on backend type */ + generateClient(schema: CompiledSchema): string { + this.errorTypeName = schema.errorTypeName || 'ErrorResponse'; + const { prefix } = this.opts; + const errorRespName = toPascalCase(this.errorTypeName); + const typesFile = `${toSnakeCase(prefix)}_types.zig`; + + const methods = schema.commands + .filter(c => !c.name.endsWith('Shutdown')) + .map(c => { + const methodName = this.methodName(c.name); + const zigCmdName = toPascalCase(c.name); + const zigRespName = toPascalCase(c.responseType); + return ` pub fn ${methodName}(self: *Self, cmd: types.${zigCmdName}) !types.${zigRespName} { + const request_bytes = try Self.encode("${c.name}", try cmd.toPayload(alloc)); + defer alloc.free(request_bytes); + const response_bytes = try self.backend.call(request_bytes); + defer alloc.free(response_bytes); + const resp_name, const resp_payload = try Self.decode(response_bytes); + if (std.mem.eql(u8, resp_name, "${this.errorTypeName}")) return error.ServerError; + return try types.${zigRespName}.fromPayload(resp_payload); + }`; + }).join('\n\n'); + + return `//! AUTOGENERATED - DO NOT EDIT +//! ${prefix} client — typed methods parameterized on a backend type. +//! +//! The backend must satisfy: call(self, request: []const u8) ![]u8 and destroy(self) void. +//! See backend.zig for the interface contract. +//! Implementations: UdsBackend (uds_backend.zig), FfiBackend (ffi_backend.zig). + +const std = @import("std"); +const msgpack = @import("msgpack"); +const Payload = msgpack.Payload; +const types = @import("${typesFile}"); +const backend_mod = @import("backend.zig"); + +const alloc = std.heap.page_allocator; + +pub fn Client(comptime BackendType: type) type { + comptime backend_mod.assertBackend(BackendType); + + return struct { + const Self = @This(); + backend: *BackendType, + + pub fn init(backend: *BackendType) Self { + return .{ .backend = backend }; + } + + pub fn destroy(self: *Self) void { + self.backend.destroy(); + } + + pub fn shutdown(self: *Self) !void { + const request_bytes = try Self.encode("${prefix}Shutdown", Payload.mapPayload(alloc)); + defer alloc.free(request_bytes); + const response_bytes = try self.backend.call(request_bytes); + alloc.free(response_bytes); + } + +${methods} + + // --- internal helpers --- + + fn encode(cmd_name: []const u8, cmd_fields: Payload) ![]u8 { + var inner = try Payload.arrPayload(2, alloc); + try inner.setArrElement(0, try Payload.strToPayload(cmd_name, alloc)); + try inner.setArrElement(1, cmd_fields); + var outer = try Payload.arrPayload(1, alloc); + try outer.setArrElement(0, inner); + + var allocating_writer = std.Io.Writer.Allocating.init(alloc); + var packer = msgpack.PackerIO.init(undefined, &allocating_writer.writer); + try packer.write(outer); + return try allocating_writer.toOwnedSlice(); + } + + fn decode(response_bytes: []const u8) !struct { []const u8, Payload } { + var reader = std.Io.Reader.fixed(response_bytes); + var unpacker = msgpack.PackerIO.init(&reader, undefined); + const resp = try unpacker.read(alloc); + const resp_len = try resp.getArrLen(); + if (resp_len != 2) return error.InvalidResponse; + const name = try (try resp.getArrElement(0)).asStr(); + const payload = try resp.getArrElement(1); + return .{ name, payload }; + } + }; +} +`; + } + + /** Generate the server wrapper — dispatch + stub handlers over generic IPC server */ + generateServer(schema: CompiledSchema): string { + this.errorTypeName = schema.errorTypeName || 'ErrorResponse'; + const { prefix } = this.opts; + const errorRespName = toPascalCase(this.errorTypeName); + const typesFile = `${toSnakeCase(prefix)}_types.zig`; + + // Dispatch cases: match command name → deserialize → call handler → serialize response + const dispatchCases = schema.commands + .filter(c => !c.name.endsWith('Shutdown')) + .map(c => { + const methodName = this.methodName(c.name); + const zigCmdName = toPascalCase(c.name); + const zigRespName = toPascalCase(c.responseType); + return ` if (std.mem.eql(u8, cmd_name, "${c.name}")) { + const cmd = types.${zigCmdName}.fromPayload(cmd_fields) catch return makeError("deser failed"); + const resp = ${methodName}(cmd) catch return makeError("not implemented: ${c.name}"); + return .{ .resp_name = "${c.responseType}", .resp_payload = resp.toPayload(alloc) }; + }`; + }).join('\n'); + + // Stub handler functions + const stubs = schema.commands + .filter(c => !c.name.endsWith('Shutdown')) + .map(c => { + const methodName = this.methodName(c.name); + const zigCmdName = toPascalCase(c.name); + const zigRespName = toPascalCase(c.responseType); + return `/// TODO: implement ${c.name} +fn ${methodName}(cmd: types.${zigCmdName}) !types.${zigRespName} { + _ = cmd; + return error.NotImplemented; +}`; + }).join('\n\n'); + + return `//! AUTOGENERATED - DO NOT EDIT +//! ${prefix} IPC server — dispatch + stub handlers over generic IPC transport. +//! +//! Implement the handler functions below to build a working ${prefix} service. +//! Then call: serve("socket_path") + +const std = @import("std"); +const msgpack = @import("msgpack"); +const Payload = msgpack.Payload; +const types = @import("${typesFile}"); +const ipc_server = @import("ipc_server.zig"); + +const alloc = std.heap.page_allocator; + +/// Start the ${prefix} server on the given socket path. +pub fn serve(socket_path: []const u8) !void { + try ipc_server.serve(socket_path, dispatch); +} + +fn dispatch(cmd_name: []const u8, cmd_fields: Payload) ipc_server.DispatchResult { + // Shutdown + if (std.mem.eql(u8, cmd_name, "${prefix}Shutdown")) { + return .{ .resp_name = "${prefix}ShutdownResponse", .resp_payload = Payload.mapPayload(alloc) }; + } + + // Command dispatch +${dispatchCases} + + return makeError("unknown command"); +} + +fn makeError(message: []const u8) ipc_server.DispatchResult { + var err_map = Payload.mapPayload(alloc); + err_map.mapPut("message", Payload.strToPayload(message, alloc) catch return .{ .resp_name = "${errorRespName}", .resp_payload = Payload.mapPayload(alloc) }) catch {}; + return .{ .resp_name = "${errorRespName}", .resp_payload = err_map }; +} + +// --------------------------------------------------------------------------- +// Handler stubs — implement these to build your ${prefix} service. +// --------------------------------------------------------------------------- + +${stubs} +`; + } + + // ----------------------------------------------------------------------- + // Skeleton generation (one-time handler stubs + main + build files) + // ----------------------------------------------------------------------- + + /** Generate handler stub implementations that return error.NotImplemented */ + generateHandlerStubs(schema: CompiledSchema): string { + const { prefix } = this.opts; + const typesFile = `${toSnakeCase(prefix)}_types.zig`; + const serverFile = `${toSnakeCase(prefix)}_server.zig`; + const ctxName = `${prefix}Context`; + + const stubs = schema.commands + .filter(c => !c.name.endsWith('Shutdown')) + .map(c => { + const methodName = this.methodName(c.name); + const zigCmdName = toPascalCase(c.name); + const zigRespName = toPascalCase(c.responseType); + return `pub fn ${methodName}(ctx: *${ctxName}, cmd: types.${zigCmdName}) !types.${zigRespName} { + _ = ctx; + _ = cmd; + return error.NotImplemented; +}`; + }).join('\n\n'); + + return `// Handler stubs — implement your service logic here. +// This file is generated ONCE. Edit freely — it will not be overwritten. + +const std = @import("std"); +const types = @import("generated/${typesFile}"); + +/// Shared context for your service — add database connections, state, etc. +pub const ${ctxName} = struct { + // Add your shared state here +}; + +// --------------------------------------------------------------------------- +// Handler implementations — fill these in with your service logic. +// --------------------------------------------------------------------------- + +${stubs} +`; + } + + /** Generate a main.zig entry point for a standalone service */ + generateMain(schema: CompiledSchema): string { + const { prefix } = this.opts; + const serverFile = `${toSnakeCase(prefix)}_server`; + const handlersFile = `${toSnakeCase(prefix)}_handlers`; + + return `// Entry point for ${prefix} service. +// This file is generated ONCE. Edit freely — it will not be overwritten. + +const std = @import("std"); +const server = @import("generated/${serverFile}.zig"); + +pub fn main() !void { + const args = try std.process.argsAlloc(std.heap.page_allocator); + defer std.process.argsFree(std.heap.page_allocator, args); + + if (args.len < 2) { + std.debug.print("Usage: ${toSnakeCase(prefix)} \\n", .{}); + std.process.exit(1); + } + + const socket_path = args[1]; + std.debug.print("${prefix} server starting on {s}\\n", .{socket_path}); + try server.serve(socket_path); +} +`; + } + + /** Generate build.zig for a standalone service */ + generateBuildFile(schema: CompiledSchema): string { + const { prefix } = this.opts; + const binName = toSnakeCase(prefix); + + return `const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const msgpack_dep = b.dependency("zig-msgpack", .{ + .target = target, + .optimize = optimize, + }); + + const exe = b.addExecutable(.{ + .name = "${binName}", + .root_source_file = b.path("main.zig"), + .target = target, + .optimize = optimize, + }); + exe.root_module.addImport("msgpack", msgpack_dep.module("msgpack")); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the ${prefix} service"); + run_step.dependOn(&run_cmd.step); +} +`; + } + + /** Generate build.zig.zon for dependency management */ + generateBuildZon(schema: CompiledSchema): string { + const { prefix } = this.opts; + const binName = toSnakeCase(prefix); + + return `.{ + .name = "${binName}-service", + .version = "0.1.0", + .dependencies = .{ + .@"zig-msgpack" = .{ + .url = "https://github.com/zig-msgpack/zig-msgpack/archive/refs/heads/main.tar.gz", + }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "main.zig", + "generated", + }, +} +`; + } + + /** Generate .gitignore for the skeleton project */ + generateGitignore(): string { + return `# Generated IPC code — do not edit, re-run generate.sh instead +generated/ +zig-out/ +zig-cache/ +.zig-cache/ +`; + } + + /** Generate a shell script to re-run codegen */ + generateGenerateScript(schemaPath: string): string { + const { prefix } = this.opts; + return `#!/usr/bin/env bash +# Re-generate IPC types, server, and client from schema. +# Run from the project root directory. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "\${BASH_SOURCE[0]}")" && pwd)" +SCHEMA="${schemaPath}" + +node --experimental-strip-types "$(dirname "$SCRIPT_DIR")/codegen/src/generate.ts" \\ + --schema "$SCHEMA" \\ + --lang zig \\ + --out "$SCRIPT_DIR/generated" \\ + --prefix ${prefix} \\ + --server +`; + } +} diff --git a/ipc-codegen/templates/cpp/ipc_client.hpp b/ipc-codegen/templates/cpp/ipc_client.hpp new file mode 100644 index 000000000000..4e3db6c204c6 --- /dev/null +++ b/ipc-codegen/templates/cpp/ipc_client.hpp @@ -0,0 +1,112 @@ +/** + * Generic IPC client over Unix Domain Sockets. + * Handles: socket connect, length-prefixed framing, send/receive raw bytes. + * Header-only. + */ +#pragma once +#ifndef IPC_CLIENT_HPP_INCLUDED +#define IPC_CLIENT_HPP_INCLUDED + +#ifndef THROW +#define THROW throw +#endif +#ifndef RETHROW +#define RETHROW throw +#endif + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace ipc { + +class IpcClient { + public: + explicit IpcClient(const char* socket_path) + { + fd_ = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (fd_ < 0) { + throw std::runtime_error(std::string("socket() failed: ") + strerror(errno)); + } + struct sockaddr_un addr {}; + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, socket_path, sizeof(addr.sun_path) - 1); + if (::connect(fd_, reinterpret_cast(&addr), sizeof(addr)) < 0) { + ::close(fd_); + fd_ = -1; + throw std::runtime_error(std::string("connect() failed: ") + strerror(errno)); + } + } + + ~IpcClient() + { + if (fd_ >= 0) { + ::close(fd_); + } + } + + IpcClient(const IpcClient&) = delete; + IpcClient& operator=(const IpcClient&) = delete; + + std::vector call(const std::vector& request) + { + // Send length-prefixed request + uint32_t len = static_cast(request.size()); + uint8_t header[4] = { + static_cast(len & 0xFF), + static_cast((len >> 8) & 0xFF), + static_cast((len >> 16) & 0xFF), + static_cast((len >> 24) & 0xFF), + }; + write_all(header, 4); + write_all(request.data(), request.size()); + + // Receive length-prefixed response + uint8_t resp_hdr[4]; + read_all(resp_hdr, 4); + uint32_t resp_len = static_cast(resp_hdr[0]) | (static_cast(resp_hdr[1]) << 8) | + (static_cast(resp_hdr[2]) << 16) | (static_cast(resp_hdr[3]) << 24); + std::vector resp(resp_len); + read_all(resp.data(), resp_len); + return resp; + } + + private: + void write_all(const void* data, size_t len) + { + const auto* ptr = static_cast(data); + size_t written = 0; + while (written < len) { + auto n = ::write(fd_, ptr + written, len - written); + if (n <= 0) { + throw std::runtime_error("write failed"); + } + written += static_cast(n); + } + } + + void read_all(void* data, size_t len) + { + auto* ptr = static_cast(data); + size_t got = 0; + while (got < len) { + auto n = ::read(fd_, ptr + got, len - got); + if (n <= 0) { + throw std::runtime_error("read failed"); + } + got += static_cast(n); + } + } + + int fd_ = -1; +}; + +} // namespace ipc +#endif // IPC_CLIENT_HPP_INCLUDED diff --git a/ipc-codegen/templates/cpp/ipc_server.hpp b/ipc-codegen/templates/cpp/ipc_server.hpp new file mode 100644 index 000000000000..d57ad50e7b64 --- /dev/null +++ b/ipc-codegen/templates/cpp/ipc_server.hpp @@ -0,0 +1,252 @@ +/** + * Generic IPC server over Unix Domain Sockets. + * Handles: socket setup, multi-client accept via poll(), length-prefixed framing. + * Service-specific dispatch is injected via the handler function parameter. + * + * Does NOT handle signal handling or parent death monitoring — those are + * the responsibility of the binary that calls serve(). + * + * Header-only — no separate .cpp needed. + */ +#pragma once +#ifndef IPC_SERVER_HPP_INCLUDED +#define IPC_SERVER_HPP_INCLUDED + +#include +#include +#include +#include +#include +#include + +#if defined(__wasm__) +// UDS not available in WASM — provide stub types only +namespace ipc { +struct ShutdownRequested : std::exception { + std::vector final_response; + explicit ShutdownRequested(std::vector resp) : final_response(std::move(resp)) {} + const char* what() const noexcept override { return "shutdown requested"; } +}; +using Handler = std::function(const std::vector&)>; +inline void serve(const char*, Handler, std::atomic* = nullptr, int = 5) {} +} // namespace ipc +#else + +#ifndef THROW +#define THROW throw +#endif +#ifndef RETHROW +#define RETHROW throw +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace ipc { + +/// Exception thrown by handlers to trigger graceful shutdown. +/// Carries the final response to send before closing. +struct ShutdownRequested : std::exception { + std::vector final_response; + explicit ShutdownRequested(std::vector resp) + : final_response(std::move(resp)) + {} + const char* what() const noexcept override { return "shutdown requested"; } +}; + +using Handler = std::function(const std::vector&)>; + +// --------------------------------------------------------------------------- +// Framing: 4-byte little-endian length prefix +// --------------------------------------------------------------------------- + +inline bool send_frame(int fd, const std::vector& data) +{ + uint32_t len = static_cast(data.size()); + uint8_t header[4] = { + static_cast(len & 0xFF), + static_cast((len >> 8) & 0xFF), + static_cast((len >> 16) & 0xFF), + static_cast((len >> 24) & 0xFF), + }; + // Write header + size_t written = 0; + while (written < 4) { + auto n = ::write(fd, header + written, 4 - written); + if (n <= 0) { + return false; + } + written += static_cast(n); + } + // Write payload + written = 0; + while (written < data.size()) { + auto n = ::write(fd, data.data() + written, data.size() - written); + if (n <= 0) { + return false; + } + written += static_cast(n); + } + return true; +} + +/// Returns empty vector on EOF/error. +inline std::vector recv_frame(int fd) +{ + uint8_t header[4]; + size_t got = 0; + while (got < 4) { + auto n = ::read(fd, header + got, 4 - got); + if (n <= 0) { + return {}; + } + got += static_cast(n); + } + uint32_t len = static_cast(header[0]) | (static_cast(header[1]) << 8) | + (static_cast(header[2]) << 16) | (static_cast(header[3]) << 24); + std::vector buf(len); + got = 0; + while (got < len) { + auto n = ::read(fd, buf.data() + got, len - got); + if (n <= 0) { + return {}; + } + got += static_cast(n); + } + return buf; +} + +// --------------------------------------------------------------------------- +// Multi-client UDS server +// --------------------------------------------------------------------------- + +/** + * @brief Run a multi-client UDS server. + * + * Accepts multiple client connections via poll(). Handles one request at a time + * (sequential, not concurrent). When a handler throws ShutdownRequested, the + * final response is sent and the server exits cleanly. + * + * The caller should set up signal handlers and parent death monitoring before + * calling this function. To request external shutdown, store true into the + * provided shutdown_flag (or pass nullptr to disable external shutdown). + * + * @param socket_path Path for the Unix domain socket + * @param handler Function that processes a request and returns a response + * @param shutdown_flag Atomic flag checked each poll cycle; serve() exits when true. + * May be nullptr if only ShutdownRequested is used. + * @param backlog listen() backlog (max pending connections) + */ +inline void serve(const char* socket_path, + Handler handler, + std::atomic* shutdown_flag = nullptr, + int backlog = 5) +{ + unlink(socket_path); + int server_fd = ::socket(AF_UNIX, SOCK_STREAM, 0); + if (server_fd < 0) { + throw std::runtime_error(std::string("socket() failed: ") + strerror(errno)); + } + + struct sockaddr_un addr {}; + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, socket_path, sizeof(addr.sun_path) - 1); + + if (::bind(server_fd, reinterpret_cast(&addr), sizeof(addr)) < 0) { + ::close(server_fd); + throw std::runtime_error(std::string("bind() failed: ") + strerror(errno)); + } + if (::listen(server_fd, backlog) < 0) { + ::close(server_fd); + throw std::runtime_error(std::string("listen() failed: ") + strerror(errno)); + } + + // Poll set: [0] = server_fd, [1..N] = client fds + std::vector fds; + fds.push_back({ server_fd, POLLIN, 0 }); + + auto remove_client = [&](size_t idx) { + ::close(fds[idx].fd); + fds.erase(fds.begin() + static_cast(idx)); + }; + + auto should_shutdown = [&]() { + return shutdown_flag != nullptr && shutdown_flag->load(std::memory_order_acquire); + }; + + while (!should_shutdown()) { + int ready = ::poll(fds.data(), static_cast(fds.size()), 100 /* ms */); + if (ready < 0) { + if (errno == EINTR) { + continue; + } + break; + } + if (ready == 0) { + continue; + } + + // Check server fd for new connections + if (fds[0].revents & POLLIN) { + int client_fd = ::accept(server_fd, nullptr, nullptr); + if (client_fd >= 0) { + fds.push_back({ client_fd, POLLIN, 0 }); + } + } + + // Check client fds for data + for (size_t i = 1; i < fds.size(); /* incremented below */) { + if (!(fds[i].revents & POLLIN)) { + ++i; + continue; + } + + auto payload = recv_frame(fds[i].fd); + if (payload.empty()) { + // Client disconnected + remove_client(i); + continue; + } + + try { + auto response = handler(payload); + if (!send_frame(fds[i].fd, response)) { + remove_client(i); + continue; + } + } catch (const ShutdownRequested& shutdown) { + send_frame(fds[i].fd, shutdown.final_response); + goto done; + } catch (const std::exception& e) { + std::cerr << "ipc-server: handler error: " << e.what() << "\n"; + remove_client(i); + continue; + } + ++i; + } + } + +done: + // Close all client connections + for (size_t i = 1; i < fds.size(); ++i) { + ::close(fds[i].fd); + } + ::close(server_fd); + unlink(socket_path); +} + +} // namespace ipc +#endif // !defined(__wasm__) +#endif // IPC_SERVER_HPP_INCLUDED diff --git a/ipc-codegen/templates/cpp/msgpack_struct_map_impl.hpp b/ipc-codegen/templates/cpp/msgpack_struct_map_impl.hpp new file mode 100644 index 000000000000..c770b835248c --- /dev/null +++ b/ipc-codegen/templates/cpp/msgpack_struct_map_impl.hpp @@ -0,0 +1,94 @@ +#pragma once +// +// msgpack adaptor: pack/unpack types that declare their fields via the +// SERIALIZATION_FIELDS macro into a JSON-like map. Bundled with ipc-codegen +// so generated IPC clients/servers don't pull in any framework-specific +// msgpack headers. +// +// Self-contained struct-map msgpack adaptor — only depends on msgpack-c. + +#include +#include +#include +#include + +// --- concepts ------------------------------------------------------------- + +struct IpcCodegenDoNothing { + void operator()(auto...) {} +}; + +namespace ipc_codegen::msgpack_concepts { +template +concept HasMsgPack = requires(T t, IpcCodegenDoNothing nop) { t.msgpack(nop); }; + +template +concept MsgpackConstructible = requires(T object, Args... args) { T{args...}; }; +} // namespace ipc_codegen::msgpack_concepts + +// --- drop_keys ------------------------------------------------------------ +// SERIALIZATION_FIELDS' msgpack() callback receives args interleaved as +// (key0, val0, key1, val1, …). drop_keys strips the keys so we can check +// that the type is constructible from the values. + +namespace ipc_codegen::msgpack_detail { +template +auto drop_keys_impl(Tuple &&tuple, std::index_sequence) { + return std::tie(std::get(std::forward(tuple))...); +} + +template auto drop_keys(std::tuple &&tuple) { + static_assert(sizeof...(Args) % 2 == 0, + "Tuple must contain an even number of elements"); + return drop_keys_impl(tuple, std::make_index_sequence{}); +} +} // namespace ipc_codegen::msgpack_detail + +// --- adaptors ------------------------------------------------------------- + +namespace msgpack::adaptor { + +template struct convert { + msgpack::object const &operator()(msgpack::object const &o, T &v) const { + static_assert(std::is_default_constructible_v, + "SERIALIZATION_FIELDS requires default-constructible types"); + v.msgpack([&](auto &...args) { + auto static_checker = [&](auto &...value_args) { + static_assert(ipc_codegen::msgpack_concepts::MsgpackConstructible< + T, decltype(value_args)...>, + "SERIALIZATION_FIELDS requires a constructor that can " + "take the listed field types"); + }; + if constexpr (!requires { typename T::MSGPACK_NO_STATIC_CHECK; }) { + std::apply(static_checker, + ipc_codegen::msgpack_detail::drop_keys(std::tie(args...))); + } + msgpack::type::define_map{args...}.msgpack_unpack(o); + }); + return o; + } +}; + +template struct pack { + template + packer &operator()(msgpack::packer &o, T const &v) const { + static_assert(std::is_default_constructible_v, + "SERIALIZATION_FIELDS requires default-constructible types"); + const_cast(v).msgpack([&](auto &...args) { + auto static_checker = [&](auto &...value_args) { + static_assert(ipc_codegen::msgpack_concepts::MsgpackConstructible< + T, decltype(value_args)...>, + "SERIALIZATION_FIELDS requires a constructor that can " + "take the listed field types"); + }; + if constexpr (!requires { typename T::MSGPACK_NO_STATIC_CHECK; }) { + std::apply(static_checker, + ipc_codegen::msgpack_detail::drop_keys(std::tie(args...))); + } + msgpack::type::define_map{args...}.msgpack_pack(o); + }); + return o; + } +}; + +} // namespace msgpack::adaptor diff --git a/ipc-codegen/templates/rust/backend.rs b/ipc-codegen/templates/rust/backend.rs new file mode 100644 index 000000000000..d35f58dfd3e0 --- /dev/null +++ b/ipc-codegen/templates/rust/backend.rs @@ -0,0 +1,44 @@ +//! Backend trait for msgpack communication +//! +//! This module defines a simple, pluggable interface for Barretenberg backends. +//! Users can easily implement custom backends (FFI, WASM, IPC, etc.). + +use super::error::Result; + +/// Simple interface for msgpack backend implementations. +/// +/// Implement this trait to create a custom backend for Barretenberg. +/// The backend handles msgpack-encoded command/response communication. +/// +/// # Example +/// +/// ```ignore +/// struct MyCustomBackend { +/// // your FFI handle, connection, etc. +/// } +/// +/// impl Backend for MyCustomBackend { +/// fn call(&mut self, input: &[u8]) -> Result> { +/// // Send input to your backend +/// // Return the response +/// } +/// +/// fn destroy(&mut self) -> Result<()> { +/// // Clean up resources +/// Ok(()) +/// } +/// } +/// ``` +pub trait Backend { + /// Execute a msgpack command and return the msgpack response. + /// + /// # Arguments + /// * `input` - Msgpack-encoded command + /// + /// # Returns + /// Msgpack-encoded response + fn call(&mut self, input: &[u8]) -> Result>; + + /// Clean up resources and shutdown the backend. + fn destroy(&mut self) -> Result<()>; +} diff --git a/ipc-codegen/templates/rust/error.rs b/ipc-codegen/templates/rust/error.rs new file mode 100644 index 000000000000..37691d6210be --- /dev/null +++ b/ipc-codegen/templates/rust/error.rs @@ -0,0 +1,32 @@ +//! Error types for Barretenberg operations + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum IpcError { + #[error("Serialization error: {0}")] + Serialization(String), + + #[error("Deserialization error: {0}")] + Deserialization(String), + + #[error("Backend error: {0}")] + Backend(String), + + #[error("IPC error: {0}")] + Ipc(String), + + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + + #[error("Invalid response: {0}")] + InvalidResponse(String), + + #[error("Connection error: {0}")] + Connection(String), + + #[error("WASM error: {0}")] + Wasm(String), +} + +pub type Result = std::result::Result; diff --git a/ipc-codegen/templates/rust/ffi_backend.rs b/ipc-codegen/templates/rust/ffi_backend.rs new file mode 100644 index 000000000000..a1c19269c6d8 --- /dev/null +++ b/ipc-codegen/templates/rust/ffi_backend.rs @@ -0,0 +1,163 @@ +//! FFI backend for Barretenberg +//! +//! This backend calls the Barretenberg C API directly via FFI, +//! eliminating process spawn overhead. Ideal for mobile and embedded use cases. +//! +//! # Requirements +//! +//! This backend requires linking against `libbarretenberg`. You must: +//! 1. Build Barretenberg as a static library (`libbarretenberg.a`) +//! 2. Configure the library search path, either via: +//! - `.cargo/config.toml`: `[build] rustflags = ["-L", "/path/to/lib"]` +//! - Environment: `RUSTFLAGS="-L /path/to/lib"` +//! +//! # Example +//! +//! ```ignore +//! use barretenberg_rs::{BbApi, FfiBackend}; +//! +//! let backend = FfiBackend::new()?; +//! let mut api = BbApi::new(backend); +//! +//! let response = api.blake2s(b"hello world")?; +//! println!("Hash: {:?}", response.hash); +//! ``` + +use super::backend::Backend; +use super::error::{IpcError, Result}; +use std::ptr; + +// C API exported by Barretenberg +// See: barretenberg/cpp/src/barretenberg/bbapi/c_bind.hpp +// Link directives are in build.rs to control link order (barretenberg depends on env) +extern "C" { + /// Execute a msgpack-encoded command and return msgpack-encoded response. + /// + /// # Safety + /// - `input_in` must point to valid memory of `input_len_in` bytes + /// - `output_out` and `output_len_out` must be valid pointers + /// - Caller must free `*output_out` using `libc::free` + fn bbapi( + input_in: *const u8, + input_len_in: usize, + output_out: *mut *mut u8, + output_len_out: *mut usize, + ); +} + +/// FFI backend that calls Barretenberg directly via C API. +/// +/// This is the most performant backend option as it avoids process spawning +/// and IPC overhead. However, it requires linking against `libbarretenberg`. +/// +/// # Thread Safety +/// +/// This backend is **not** thread-safe. Each thread should have its own +/// `FfiBackend` instance, or access should be synchronized externally. +pub struct FfiBackend { + _initialized: bool, +} + +impl FfiBackend { + /// Create a new FFI backend. + /// + /// # Errors + /// + /// Returns an error if Barretenberg initialization fails. + pub fn new() -> Result { + // Future: Could add SRS initialization here if needed + // For now, Barretenberg initializes lazily on first use + Ok(Self { _initialized: true }) + } +} + +impl Backend for FfiBackend { + fn call(&mut self, input: &[u8]) -> Result> { + let mut output_ptr: *mut u8 = ptr::null_mut(); + let mut output_len: usize = 0; + + // SAFETY: + // - input.as_ptr() is valid for input.len() bytes + // - output_ptr and output_len are valid stack pointers + // - bbapi allocates output using malloc, which we free below + unsafe { + bbapi( + input.as_ptr(), + input.len(), + &mut output_ptr, + &mut output_len, + ); + } + + if output_ptr.is_null() { + return Err(IpcError::Backend( + "bbapi returned null pointer".to_string(), + )); + } + + if output_len == 0 { + // Free the pointer even if length is 0 + unsafe { + libc::free(output_ptr as *mut libc::c_void); + } + return Err(IpcError::Backend( + "bbapi returned empty response".to_string(), + )); + } + + // SAFETY: output_ptr is valid for output_len bytes, allocated by malloc + let output = unsafe { std::slice::from_raw_parts(output_ptr, output_len).to_vec() }; + + // Free the C-allocated memory + // SAFETY: output_ptr was allocated by bbapi using malloc + unsafe { + libc::free(output_ptr as *mut libc::c_void); + } + + Ok(output) + } + + fn destroy(&mut self) -> Result<()> { + // No cleanup needed - Barretenberg manages its own state + // Future: Could send Shutdown command here if needed + self._initialized = false; + Ok(()) + } +} + +impl Drop for FfiBackend { + fn drop(&mut self) { + let _ = self.destroy(); + } +} + +impl Default for FfiBackend { + fn default() -> Self { + Self::new().expect("Failed to initialize FfiBackend") + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::generated::bb_client::BbApi; + + #[test] + fn test_ffi_backend_creation() { + let backend = FfiBackend::new(); + assert!(backend.is_ok()); + } + + #[test] + fn test_ffi_blake2s() { + let backend = FfiBackend::new().unwrap(); + let mut api = BbApi::new(backend); + + let response = api.blake2s(b"hello world").unwrap(); + assert_eq!(response.hash.as_slice().len(), 32); + + // Verify deterministic output + let response2 = api.blake2s(b"hello world").unwrap(); + assert_eq!(response.hash, response2.hash); + } +} diff --git a/ipc-codegen/templates/rust/ipc_client.rs b/ipc-codegen/templates/rust/ipc_client.rs new file mode 100644 index 000000000000..35f7094ddef6 --- /dev/null +++ b/ipc-codegen/templates/rust/ipc_client.rs @@ -0,0 +1,42 @@ +//! Generic IPC client over Unix Domain Sockets. +//! Handles: socket connect, length-prefixed framing, send/receive raw bytes. +//! Service-specific typed methods are in the generated wrapper. + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; + +pub struct IpcClient { + stream: UnixStream, +} + +impl IpcClient { + pub fn connect(socket_path: &str) -> std::io::Result { + let stream = UnixStream::connect(socket_path)?; + Ok(Self { stream }) + } + + /// Send a msgpack request and receive the raw response bytes. + pub fn call(&mut self, request: &[u8]) -> std::io::Result> { + // Send length-prefixed + let len = (request.len() as u32).to_le_bytes(); + self.stream.write_all(&len)?; + self.stream.write_all(request)?; + self.stream.flush()?; + + // Read response length + let mut len_buf = [0u8; 4]; + self.stream.read_exact(&mut len_buf)?; + let resp_len = u32::from_le_bytes(len_buf) as usize; + + // Read response payload + let mut resp = vec![0u8; resp_len]; + self.stream.read_exact(&mut resp)?; + Ok(resp) + } +} + +impl Drop for IpcClient { + fn drop(&mut self) { + let _ = self.stream.shutdown(std::net::Shutdown::Both); + } +} diff --git a/ipc-codegen/templates/rust/ipc_server.rs b/ipc-codegen/templates/rust/ipc_server.rs new file mode 100644 index 000000000000..cfce19cbc26a --- /dev/null +++ b/ipc-codegen/templates/rust/ipc_server.rs @@ -0,0 +1,51 @@ +//! Generic IPC server over Unix Domain Sockets. +//! Handles: socket setup, accept, length-prefixed framing, msgpack decode/encode. +//! Service-specific dispatch is injected via the dispatch function parameter. + +use std::io::{Read, Write}; +use std::os::unix::net::UnixListener; + +/// Dispatch function signature: takes raw command name + msgpack bytes, returns response name + bytes +pub type DispatchFn = Box Vec>; + +/// Run an IPC server. Accepts one connection, serves until disconnect or shutdown. +pub fn serve(socket_path: &str, handler: impl Fn(&[u8]) -> Vec) -> std::io::Result<()> { + let _ = std::fs::remove_file(socket_path); + let listener = UnixListener::bind(socket_path)?; + eprintln!("ipc-server(rust): listening on {}", socket_path); + + let (mut stream, _) = listener.accept()?; + + loop { + // Read 4-byte LE length + let mut len_buf = [0u8; 4]; + if stream.read_exact(&mut len_buf).is_err() { + break; + } + let len = u32::from_le_bytes(len_buf) as usize; + + // Read payload + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload)?; + + // Check for shutdown + let is_shutdown = payload.windows(8).any(|w| w == b"Shutdown"); + + // Dispatch + let response = handler(&payload); + + // Send length-prefixed response + let resp_len = (response.len() as u32).to_le_bytes(); + stream.write_all(&resp_len)?; + stream.write_all(&response)?; + stream.flush()?; + + if is_shutdown { + break; + } + } + + let _ = std::fs::remove_file(socket_path); + eprintln!("ipc-server(rust): shutdown"); + Ok(()) +} diff --git a/ipc-codegen/templates/rust/uds_backend.rs b/ipc-codegen/templates/rust/uds_backend.rs new file mode 100644 index 000000000000..5d25363ee697 --- /dev/null +++ b/ipc-codegen/templates/rust/uds_backend.rs @@ -0,0 +1,78 @@ +//! UDS (Unix Domain Socket) backend for Barretenberg +//! +//! Connects to a running BB server over a Unix domain socket, +//! using the standard 4-byte LE length-prefixed msgpack protocol. +//! Same wire format as C++/TS/Zig IPC clients. + +use super::backend::Backend; +use super::error::{IpcError, Result}; +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::path::Path; + +/// UDS backend — connects to a BB server over Unix domain socket. +pub struct UdsBackend { + stream: UnixStream, +} + +impl UdsBackend { + /// Connect to a BB server at the given socket path. + /// + /// # Arguments + /// * `socket_path` - Path to the Unix domain socket (e.g. "/tmp/bb.sock") + pub fn connect(socket_path: impl AsRef) -> Result { + let stream = UnixStream::connect(socket_path.as_ref()).map_err(|e| { + IpcError::Ipc(format!( + "Failed to connect to {}: {}", + socket_path.as_ref().display(), + e + )) + })?; + Ok(Self { stream }) + } + + fn send_with_prefix(&mut self, data: &[u8]) -> Result<()> { + let len = data.len() as u32; + self.stream + .write_all(&len.to_le_bytes()) + .map_err(|e| IpcError::Ipc(format!("Failed to write length: {}", e)))?; + self.stream + .write_all(data) + .map_err(|e| IpcError::Ipc(format!("Failed to write data: {}", e)))?; + self.stream + .flush() + .map_err(|e| IpcError::Ipc(format!("Failed to flush: {}", e)))?; + Ok(()) + } + + fn receive_with_prefix(&mut self) -> Result> { + let mut len_buf = [0u8; 4]; + self.stream + .read_exact(&mut len_buf) + .map_err(|e| IpcError::Ipc(format!("Failed to read length: {}", e)))?; + let len = u32::from_le_bytes(len_buf) as usize; + let mut data = vec![0u8; len]; + self.stream + .read_exact(&mut data) + .map_err(|e| IpcError::Ipc(format!("Failed to read data: {}", e)))?; + Ok(data) + } +} + +impl Backend for UdsBackend { + fn call(&mut self, input: &[u8]) -> Result> { + self.send_with_prefix(input)?; + self.receive_with_prefix() + } + + fn destroy(&mut self) -> Result<()> { + let _ = self.stream.shutdown(std::net::Shutdown::Both); + Ok(()) + } +} + +impl Drop for UdsBackend { + fn drop(&mut self) { + let _ = self.destroy(); + } +} diff --git a/ipc-codegen/templates/ts/ipc_client.ts b/ipc-codegen/templates/ts/ipc_client.ts new file mode 100644 index 000000000000..4d303747a578 --- /dev/null +++ b/ipc-codegen/templates/ts/ipc_client.ts @@ -0,0 +1,58 @@ +/** + * Generic IPC client over Unix Domain Sockets. + * Handles: socket connect, length-prefixed framing, msgpack encode/decode. + * Service-specific typed methods are in the generated wrapper. + */ +import * as net from 'node:net'; +import { Decoder, Encoder } from 'msgpackr'; + +const encoder = new Encoder({ useRecords: false, variableMapSize: true }); +const decoder = new Decoder({ useRecords: false }); + +export class IpcClient { + private conn: net.Socket; + private buffer = Buffer.alloc(0); + + private constructor(conn: net.Socket) { + this.conn = conn; + } + + static async connect(socketPath: string): Promise { + const conn = net.createConnection(socketPath); + await new Promise((resolve, reject) => { + conn.on('connect', resolve); + conn.on('error', reject); + }); + return new IpcClient(conn); + } + + close() { + this.conn.destroy(); + } + + /** Send a command and receive the response. */ + async call(commandName: string, fields: any): Promise<[string, any]> { + const packed = encoder.pack([[commandName, fields]]); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32LE(packed.length, 0); + this.conn.write(lenBuf); + this.conn.write(packed); + + return new Promise((resolve, reject) => { + const onData = (data: Buffer) => { + this.buffer = Buffer.concat([this.buffer, data]); + if (this.buffer.length >= 4) { + const len = this.buffer.readUInt32LE(0); + if (this.buffer.length >= 4 + len) { + this.conn.removeListener('data', onData); + const payload = this.buffer.subarray(4, 4 + len); + this.buffer = this.buffer.subarray(4 + len); + resolve(decoder.unpack(payload) as [string, any]); + } + } + }; + this.conn.on('data', onData); + this.conn.on('error', reject); + }); + } +} diff --git a/ipc-codegen/templates/ts/ipc_server.ts b/ipc-codegen/templates/ts/ipc_server.ts new file mode 100644 index 000000000000..440b27480bd1 --- /dev/null +++ b/ipc-codegen/templates/ts/ipc_server.ts @@ -0,0 +1,82 @@ +/** + * Generic IPC server over Unix Domain Sockets. + * Handles: socket setup, accept, length-prefixed framing, msgpack decode/encode. + * Service-specific dispatch is injected via the dispatchFn parameter. + */ +import * as net from 'node:net'; +import * as fs from 'node:fs'; +import { Decoder, Encoder } from 'msgpackr'; + +const encoder = new Encoder({ useRecords: false, variableMapSize: true }); +const decoder = new Decoder({ useRecords: false }); + +export type DispatchFn = (commandName: string, payload: any) => Promise<[string, any]>; + +export function createServer(socketPath: string, dispatchFn: DispatchFn): { close: () => Promise } { + try { fs.unlinkSync(socketPath); } catch {} + + const server = net.createServer((conn) => { + let buffer = Buffer.alloc(0); + let responseChain: Promise = Promise.resolve(); + + conn.on('data', (data: Buffer) => { + buffer = Buffer.concat([buffer, data]); + + while (buffer.length >= 4) { + const len = buffer.readUInt32LE(0); + if (buffer.length < 4 + len) break; + + const payload = buffer.subarray(4, 4 + len); + buffer = buffer.subarray(4 + len); + + const request = decoder.unpack(payload) as any[]; + const [commandName, fields] = request[0] as [string, any]; + + if (commandName.endsWith('Shutdown')) { + sendResponse(conn, [commandName.replace(/^(.*)$/, '$1Response'), {}]); + setTimeout(() => { + server.close(); + try { fs.unlinkSync(socketPath); } catch {} + }, 50); + return; + } + + const prev = responseChain; + const result = dispatchFn(commandName, fields ?? {}); + responseChain = (async () => { + await prev; + try { + const [name, resp] = await result; + sendResponse(conn, [name, resp]); + } catch (err: any) { + sendResponse(conn, ['ErrorResponse', { message: err.message ?? 'Unknown error' }]); + } + })(); + void responseChain.catch(() => {}); + } + }); + + conn.on('error', () => {}); + }); + + server.listen(socketPath, () => { + console.error(`ipc-server(ts): listening on ${socketPath}`); + }); + + return { + close: () => new Promise(resolve => { + server.close(() => { + try { fs.unlinkSync(socketPath); } catch {} + resolve(); + }); + }), + }; +} + +function sendResponse(conn: net.Socket, response: [string, any]) { + const packed = encoder.pack(response); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32LE(packed.length, 0); + conn.write(lenBuf); + conn.write(packed); +} diff --git a/ipc-codegen/templates/zig/backend.zig b/ipc-codegen/templates/zig/backend.zig new file mode 100644 index 000000000000..97a65bdbfa07 --- /dev/null +++ b/ipc-codegen/templates/zig/backend.zig @@ -0,0 +1,27 @@ +/// Backend abstraction — comptime interface for transport. +/// +/// A valid backend type must provide: +/// fn call(self: *T, request: []const u8) ![]u8 +/// fn destroy(self: *T) void +/// +/// Implementations: +/// UdsBackend (uds_backend.zig) — Unix Domain Socket IPC +/// FfiBackend (ffi_backend.zig) — Direct C FFI linking +/// +/// Usage with the generated client: +/// const Client = @import("wsdb_client.zig").Client; +/// const UdsBackend = @import("uds_backend.zig").UdsBackend; +/// var backend = try UdsBackend.connect("/tmp/wsdb.sock"); +/// var client = Client(UdsBackend){ .backend = &backend }; + +/// Compile-time check that a type satisfies the backend interface. +pub fn assertBackend(comptime T: type) void { + // Must have: fn call(self: *T, request: []const u8) ![]u8 + if (!@hasDecl(T, "call")) { + @compileError("Backend type " ++ @typeName(T) ++ " missing 'call' method"); + } + // Must have: fn destroy(self: *T) void + if (!@hasDecl(T, "destroy")) { + @compileError("Backend type " ++ @typeName(T) ++ " missing 'destroy' method"); + } +} diff --git a/ipc-codegen/templates/zig/ffi_backend.zig b/ipc-codegen/templates/zig/ffi_backend.zig new file mode 100644 index 000000000000..0963dfb0589e --- /dev/null +++ b/ipc-codegen/templates/zig/ffi_backend.zig @@ -0,0 +1,22 @@ +/// FFI backend for direct library linking. +/// Calls the C function bbapi() with msgpack bytes — no IPC overhead. +/// Link against libbarretenberg.a to use this backend. +/// Satisfies the backend interface: call(request) -> response, destroy(). +const std = @import("std"); + +extern fn bbapi(input: [*]const u8, input_len: usize, output: *[*]u8, output_len: *usize) void; + +pub const FfiBackend = struct { + /// Send a msgpack command and receive the response via FFI. + pub fn call(self: *FfiBackend, request: []const u8) ![]u8 { + _ = self; + var out_ptr: [*]u8 = undefined; + var out_len: usize = 0; + bbapi(request.ptr, request.len, &out_ptr, &out_len); + return out_ptr[0..out_len]; + } + + pub fn destroy(self: *FfiBackend) void { + _ = self; + } +}; diff --git a/ipc-codegen/templates/zig/ffi_client.zig b/ipc-codegen/templates/zig/ffi_client.zig new file mode 100644 index 000000000000..960a4eee9b21 --- /dev/null +++ b/ipc-codegen/templates/zig/ffi_client.zig @@ -0,0 +1,21 @@ +/// FFI client for direct library linking. +/// Calls the C function bbapi() with msgpack bytes — no IPC overhead. +/// Link against libbarretenberg.a to use this backend. +const std = @import("std"); + +extern fn bbapi(input: [*]const u8, input_len: usize, output: *[*]u8, output_len: *usize) void; + +pub const FfiClient = struct { + /// Send a msgpack command and receive the response via FFI. + pub fn call(self: *FfiClient, request: []const u8) ![]u8 { + _ = self; + var out_ptr: [*]u8 = undefined; + var out_len: usize = 0; + bbapi(request.ptr, request.len, &out_ptr, &out_len); + return out_ptr[0..out_len]; + } + + pub fn destroy(self: *FfiClient) void { + _ = self; + } +}; diff --git a/ipc-codegen/templates/zig/ipc_client.zig b/ipc-codegen/templates/zig/ipc_client.zig new file mode 100644 index 000000000000..db53693cf57c --- /dev/null +++ b/ipc-codegen/templates/zig/ipc_client.zig @@ -0,0 +1,93 @@ +/// Generic IPC client over Unix Domain Sockets. +/// Handles: socket connect, length-prefixed framing, msgpack encode/decode. +/// Service-specific typed methods are in the generated wrapper. +const std = @import("std"); +const posix = std.posix; +const msgpack = @import("msgpack"); +const Payload = msgpack.Payload; + +const alloc = std.heap.page_allocator; + +pub const IpcClient = struct { + fd: posix.socket_t, + + /// Connect to a service at the given UDS path. + pub fn connect(socket_path: []const u8) !IpcClient { + const address = try std.net.Address.initUnix(socket_path); + const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0); + try posix.connect(fd, &address.any, address.getOsSockLen()); + return .{ .fd = fd }; + } + + /// Close the connection. + pub fn close(self: *IpcClient) void { + posix.close(self.fd); + } + + /// Send a command and receive a response. + /// Returns [responseName, responsePayload]. + pub fn call(self: *IpcClient, cmd_name: []const u8, fields: Payload) !struct { []const u8, Payload } { + // Encode: [[cmdName, fields]] + var inner = try Payload.arrPayload(2, alloc); + try inner.setArrElement(0, try Payload.strToPayload(cmd_name, alloc)); + try inner.setArrElement(1, fields); + var outer = try Payload.arrPayload(1, alloc); + try outer.setArrElement(0, inner); + + // Serialize + var allocating_writer = std.Io.Writer.Allocating.init(alloc); + var packer = msgpack.PackerIO.init(undefined, &allocating_writer.writer); + try packer.write(outer); + const request_bytes = try allocating_writer.toOwnedSlice(); + defer alloc.free(request_bytes); + + // Send + try sendFrame(self.fd, request_bytes); + + // Receive + const response_bytes = try recvFrame(self.fd); + defer alloc.free(response_bytes); + + // Decode: [responseName, payload] + var reader = std.Io.Reader.fixed(response_bytes); + var unpacker = msgpack.PackerIO.init(&reader, undefined); + const resp = try unpacker.read(alloc); + const resp_len = try resp.getArrLen(); + if (resp_len != 2) return error.InvalidResponse; + + const name = try (try resp.getArrElement(0)).asStr(); + const payload = try resp.getArrElement(1); + return .{ name, payload }; + } + + fn sendFrame(fd: posix.socket_t, data: []const u8) !void { + const len: u32 = @intCast(data.len); + const header = [4]u8{ + @intCast(len & 0xFF), + @intCast((len >> 8) & 0xFF), + @intCast((len >> 16) & 0xFF), + @intCast((len >> 24) & 0xFF), + }; + _ = try posix.write(fd, &header); + _ = try posix.write(fd, data); + } + + fn recvFrame(fd: posix.socket_t) ![]u8 { + var hdr: [4]u8 = undefined; + var got: usize = 0; + while (got < 4) { + const n = try posix.read(fd, hdr[got..]); + if (n == 0) return error.ConnectionClosed; + got += n; + } + const len: u32 = @as(u32, hdr[0]) | (@as(u32, hdr[1]) << 8) | (@as(u32, hdr[2]) << 16) | (@as(u32, hdr[3]) << 24); + const data = try alloc.alloc(u8, len); + got = 0; + while (got < len) { + const n = try posix.read(fd, data[got..]); + if (n == 0) return error.ConnectionClosed; + got += n; + } + return data; + } +}; diff --git a/ipc-codegen/templates/zig/ipc_server.zig b/ipc-codegen/templates/zig/ipc_server.zig new file mode 100644 index 000000000000..ba58a09b5a61 --- /dev/null +++ b/ipc-codegen/templates/zig/ipc_server.zig @@ -0,0 +1,112 @@ +/// Generic IPC server over Unix Domain Sockets. +/// Handles: socket setup, accept, length-prefixed framing, msgpack decode/encode. +/// Service-specific dispatch is injected via the DispatchFn parameter. +const std = @import("std"); +const posix = std.posix; +const msgpack = @import("msgpack"); +const Payload = msgpack.Payload; + +const alloc = std.heap.page_allocator; + +pub const DispatchFn = *const fn (cmd_name: []const u8, fields: Payload) DispatchResult; +pub const DispatchResult = struct { resp_name: []const u8, resp_payload: anyerror!Payload }; + +/// Run an IPC server on the given UDS path. +/// Accepts one connection, serves requests until shutdown or disconnect. +pub fn serve(socket_path: []const u8, dispatch: DispatchFn) !void { + std.fs.cwd().deleteFile(socket_path) catch {}; + + const address = try std.net.Address.initUnix(socket_path); + const server_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0); + defer posix.close(server_fd); + try posix.bind(server_fd, &address.any, address.getOsSockLen()); + try posix.listen(server_fd, 1); + + std.debug.print("ipc-server: listening on {s}\n", .{socket_path}); + + const client_fd = try posix.accept(server_fd, null, null, 0); + defer posix.close(client_fd); + + while (true) { + const frame = recvFrame(client_fd) catch break; + defer alloc.free(frame); + + // Decode msgpack: [[commandName, {fields}]] + var reader = std.Io.Reader.fixed(frame); + var packer = msgpack.PackerIO.init(&reader, undefined); + const request = packer.read(alloc) catch break; + + const outer_len = request.getArrLen() catch break; + if (outer_len != 1) break; + + const inner = request.getArrElement(0) catch break; + const inner_len = inner.getArrLen() catch break; + if (inner_len != 2) break; + + const cmd_name = (inner.getArrElement(0) catch break).asStr() catch break; + const fields = inner.getArrElement(1) catch break; + + // Dispatch + const result = dispatch(cmd_name, fields); + const resp_payload = result.resp_payload catch blk: { + var err_map = Payload.mapPayload(alloc); + const msg = std.fmt.allocPrint(alloc, "error: {s}", .{cmd_name}) catch "error"; + err_map.mapPut("message", Payload.strToPayload(msg, alloc) catch break) catch break; + break :blk err_map; + }; + const is_error = if (result.resp_payload) |_| false else |_| true; + const resp_name = if (is_error) "ErrorResponse" else result.resp_name; + + const response = encodeResponse(resp_name, resp_payload) catch break; + defer alloc.free(response); + sendFrame(client_fd, response) catch break; + + // Check for shutdown + if (std.mem.indexOf(u8, cmd_name, "Shutdown") != null) break; + } + + std.fs.cwd().deleteFile(socket_path) catch {}; + std.debug.print("ipc-server: shutdown\n", .{}); +} + +fn encodeResponse(name: []const u8, payload: Payload) ![]u8 { + var resp_arr = try Payload.arrPayload(2, alloc); + try resp_arr.setArrElement(0, try Payload.strToPayload(name, alloc)); + try resp_arr.setArrElement(1, payload); + + var allocating_writer = std.Io.Writer.Allocating.init(alloc); + var packer = msgpack.PackerIO.init(undefined, &allocating_writer.writer); + try packer.write(resp_arr); + return try allocating_writer.toOwnedSlice(); +} + +fn recvFrame(fd: posix.socket_t) ![]u8 { + var hdr: [4]u8 = undefined; + var got: usize = 0; + while (got < 4) { + const n = try posix.read(fd, hdr[got..]); + if (n == 0) return error.ConnectionClosed; + got += n; + } + const len: u32 = @as(u32, hdr[0]) | (@as(u32, hdr[1]) << 8) | (@as(u32, hdr[2]) << 16) | (@as(u32, hdr[3]) << 24); + const data = try alloc.alloc(u8, len); + got = 0; + while (got < len) { + const n = try posix.read(fd, data[got..]); + if (n == 0) return error.ConnectionClosed; + got += n; + } + return data; +} + +fn sendFrame(fd: posix.socket_t, data: []const u8) !void { + const len: u32 = @intCast(data.len); + const header = [4]u8{ + @intCast(len & 0xFF), + @intCast((len >> 8) & 0xFF), + @intCast((len >> 16) & 0xFF), + @intCast((len >> 24) & 0xFF), + }; + _ = try posix.write(fd, &header); + _ = try posix.write(fd, data); +} diff --git a/ipc-codegen/templates/zig/uds_backend.zig b/ipc-codegen/templates/zig/uds_backend.zig new file mode 100644 index 000000000000..af701e2d05c9 --- /dev/null +++ b/ipc-codegen/templates/zig/uds_backend.zig @@ -0,0 +1,62 @@ +/// UDS (Unix Domain Socket) backend for IPC communication. +/// Handles: socket connect, length-prefixed framing, raw byte send/receive. +/// Satisfies the backend interface: call(request) -> response, destroy(). +const std = @import("std"); +const posix = std.posix; + +const alloc = std.heap.page_allocator; + +pub const UdsBackend = struct { + fd: posix.socket_t, + + /// Connect to a service at the given UDS path. + pub fn connect(socket_path: []const u8) !UdsBackend { + const address = try std.net.Address.initUnix(socket_path); + const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0); + try posix.connect(fd, &address.any, address.getOsSockLen()); + return .{ .fd = fd }; + } + + /// Send a raw msgpack request and receive a raw msgpack response. + /// Framing: 4-byte LE length prefix + payload. + pub fn call(self: *UdsBackend, request: []const u8) ![]u8 { + try sendFrame(self.fd, request); + return try recvFrame(self.fd); + } + + /// Close the connection. + pub fn destroy(self: *UdsBackend) void { + posix.close(self.fd); + } + + fn sendFrame(fd: posix.socket_t, data: []const u8) !void { + const len: u32 = @intCast(data.len); + const header = [4]u8{ + @intCast(len & 0xFF), + @intCast((len >> 8) & 0xFF), + @intCast((len >> 16) & 0xFF), + @intCast((len >> 24) & 0xFF), + }; + _ = try posix.write(fd, &header); + _ = try posix.write(fd, data); + } + + fn recvFrame(fd: posix.socket_t) ![]u8 { + var hdr: [4]u8 = undefined; + var got: usize = 0; + while (got < 4) { + const n = try posix.read(fd, hdr[got..]); + if (n == 0) return error.ConnectionClosed; + got += n; + } + const len: u32 = @as(u32, hdr[0]) | (@as(u32, hdr[1]) << 8) | (@as(u32, hdr[2]) << 16) | (@as(u32, hdr[3]) << 24); + const data = try alloc.alloc(u8, len); + got = 0; + while (got < len) { + const n = try posix.read(fd, data[got..]); + if (n == 0) return error.ConnectionClosed; + got += n; + } + return data; + } +};