From 70b617d9fbf9f4c90bce5bc11f406db66a8e2d26 Mon Sep 17 00:00:00 2001 From: Kinflou Date: Wed, 2 Sep 2026 17:00:21 +0800 Subject: [PATCH] feat(ts): generate Client / dispatcher against @comline/runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The TypeScript `code` generator now emits the full RPC shape for a `protocol`, not just the method signatures: - `error ` -> `export interface ` (wire payload) + `export class Error extends Error` carrying `.data` and a static `.ordinal` (the throwable a provider raises / a client re-raises). - `Params` interface per function with arguments. - `` provider interface: `Promise`-returning methods, `@throws {Error}` JSDoc. - `_CALLS` const table. - `Dispatcher implements Dispatch` — decode params, run the impl, map a caught `Error` to `reply.err(ordinal, ...)`, one-way calls leave the reply empty. - `Client` + static `connect()` (runs the handshake from `IR_HASH`): one method per function, decoding the `Envelope`, mapping `env.err.id` back to `Error` or `RuntimeError.remote`. - `serve(impl, transport, codec, framing?)` helper. Framing follows `@framing` on the protocol / the package `default_framing` (same recognition set as the Rust generator): `DatagramFraming` default, `JsonRpcFraming` for `jsonrpc`. `runtime/test/generated/chat.ts` is real generator output (blessed by `generate.rs`, `TS_BLESS=1` to refresh); `runtime/test/generated.test.ts` type-checks it and runs a client ⇆ provider round-trip over `duplex()`. --- README.md | 11 +- codegen/src/generator.rs | 356 +++++++++++++++++++++++++++------ codegen/src/lib.rs | 9 +- codegen/tests/generate.rs | 285 +++++++++++++++----------- runtime/test/generated.test.ts | 58 ++++++ runtime/test/generated/chat.ts | 140 +++++++++++++ runtime/tsconfig.json | 6 +- 7 files changed, 682 insertions(+), 183 deletions(-) create mode 100644 runtime/test/generated.test.ts create mode 100644 runtime/test/generated/chat.ts diff --git a/README.md b/README.md index 268dbce..e79f0a5 100644 --- a/README.md +++ b/README.md @@ -12,11 +12,12 @@ follow. ## `codegen/` `comline-codegen-typescript` — frozen IR → `.ts` source: `export interface` per -struct / `error`, `export enum` (string values) per enum, and per `protocol` the -RPC shape (an `IR_HASH`, params interfaces, discriminated-union error types, and -an `export interface` of `Promise`-returning methods). It depends on -`comline-codegen` (the language-neutral contract + `Registry`) and `comline-core` -(the IR), both by git rev. +struct / `error` (plus a `Error` throwable), `export enum` (string values) +per enum, and per `protocol` the full RPC shape against `@comline/runtime` — an +`IR_HASH`, params interfaces, a provider interface, a `Dispatcher`, a +`Client`, and a `serve` helper (framing from `@framing` / the +package default). It depends on `comline-codegen` (the language-neutral contract ++ `Registry`) and `comline-core` (the IR), both by git rev. `register(&mut Registry)` contributes the generator under `typescript` / `ts` at version `5.0`; the Comline CLI composes it into its `Registry` at startup. diff --git a/codegen/src/generator.rs b/codegen/src/generator.rs index 12fa3ca..a5b733e 100644 --- a/codegen/src/generator.rs +++ b/codegen/src/generator.rs @@ -1,17 +1,17 @@ //! TypeScript `code`-mode generation: frozen IR -> `.ts` source. //! //! Per schema file: -//! - `struct` / `error` -> `export interface` -//! - `enum` -> `export enum` with string values (JSON interop) -//! - `protocol` -> the RPC shape: an `IR_HASH` for the connection -//! handshake, an `export interface` of `Promise`-returning methods, a -//! `Params` interface per function that takes arguments, and -//! discriminated-union error types from each `!` (per function and a -//! per-protocol union), keyed by the schema-global error ordinal. +//! - `struct` -> `export interface` +//! - `error` -> `export interface` (the wire payload) + `export class +//! Error` (the throwable, carrying `.data` and a static `.ordinal`) +//! - `enum` -> `export enum` with string values (JSON interop) +//! - `protocol` -> the full RPC shape against `@comline/runtime`: an `IR_HASH` +//! const, a `Params` interface per function, a `` provider +//! interface of `Promise`-returning methods, a `_CALLS` table, a +//! `Dispatcher` (`implements Dispatch`), a `Client`, and a +//! `serve` helper. Framing follows `@framing` / the package default. //! -//! A `Client` / dispatcher and a runtime package to link them against -//! are the next step; `lib` mode (an npm package) is not built yet. See -//! design/generation.md. +//! `lib` mode (an npm package) is not built yet. See design/generation.md. use std::collections::HashMap; use std::path::PathBuf; @@ -29,29 +29,65 @@ pub fn generate_typescript(req: &GenRequest) -> Result> { bail!("typescript lib mode is not implemented yet (de-rot G2)"); } + let default_framing = req.default_framing.as_deref(); Ok(req .schemas .iter() .map(|(namespace, units)| GeneratedFile { path: PathBuf::from(format!("{namespace}.ts")), - contents: schema_source(units), + contents: schema_source(units, default_framing), }) .collect()) } -fn schema_source(units: &[FrozenUnit]) -> String { +fn schema_source(units: &[FrozenUnit], default_framing: Option<&str>) -> String { let has_protocol = units .iter() .any(|u| matches!(u, FrozenUnit::Protocol { .. })); let errors = error_type_names(units); + // Which framings the protocols in this file reach for. + let picks: Vec = units + .iter() + .filter_map(|u| match u { + FrozenUnit::Protocol { parameters, .. } => { + Some(framing_choice(parameters, default_framing)) + } + _ => None, + }) + .collect(); + let mut output = String::new(); output.push_str("// Generated by Comline\n\n"); if has_protocol { + let mut values = vec![ + "Client", + "Server", + "Handshake", + "RuntimeError", + "resolveKind", + ]; + if picks.iter().any(|p| !p.jsonrpc) { + values.push("DatagramFraming"); + } + if picks.iter().any(|p| p.jsonrpc) { + values.push("JsonRpcFraming"); + } + output.push_str("import {\n"); + for v in values { + output.push_str(&format!(" {v},\n")); + } + for t in [ + "Codec", "Dispatch", "Framing", "Kind", "Reply", "Transport", + ] { + output.push_str(&format!(" type {t},\n")); + } + output.push_str("} from \"@comline/runtime\";\n\n"); + output.push_str(&format!( - "// Canonical digest of the frozen IR this file was generated from — the\n\ - // two ends of a connection must agree on it.\n\ + "/** Canonical digest of the frozen IR this file was generated from — the\n\ + * two ends of a connection must agree on it. */\n\ export const IR_HASH = {:#018x}n;\n\n", schema_ir_hash(units) )); @@ -62,16 +98,30 @@ fn schema_source(units: &[FrozenUnit]) -> String { FrozenUnit::Struct { name, fields, .. } => { output.push_str(&interface(name, fields)); } - FrozenUnit::Error { name, fields, .. } => { + FrozenUnit::Error { + name, + fields, + ordinal, + .. + } => { output.push_str(&interface(name, fields)); + output.push_str(&error_class(name, *ordinal)); } FrozenUnit::Enum { name, variants, .. } => { output.push_str(&string_enum(name, variants)); } FrozenUnit::Protocol { - name, functions, .. + name, + functions, + parameters, + .. } => { - output.push_str(&protocol(name, functions, &errors)); + output.push_str(&protocol( + name, + functions, + &framing_choice(parameters, default_framing), + &errors, + )); } _ => {} } @@ -111,6 +161,21 @@ fn interface(name: &str, fields: &[FrozenUnit]) -> String { s } +/// The throwable for an `error`: a provider `throw`s `new Error(data)`; +/// the dispatcher catches it and the client re-raises it, both keyed by +/// `Error.ordinal`. +fn error_class(name: &str, ordinal: u16) -> String { + format!( + "export class {name}Error extends Error {{\n\ + \x20 static readonly ordinal = {ordinal};\n\ + \x20 constructor(readonly data: {name}) {{\n\ + \x20 super(\"{name}\");\n\ + \x20 this.name = \"{name}Error\";\n\ + \x20 }}\n\ + }}\n\n" + ) +} + fn string_enum(name: &str, variants: &[FrozenUnit]) -> String { let mut s = format!("export enum {name} {{\n"); for variant in variants { @@ -126,21 +191,67 @@ fn string_enum(name: &str, variants: &[FrozenUnit]) -> String { s } +// ── framing selection ────────────────────────────────────────────────────── + +struct FramingChoice { + /// The runtime class name — `DatagramFraming` or `JsonRpcFraming`. + ctor: &'static str, + jsonrpc: bool, +} + +fn framing_for(name: &str) -> Option { + match name { + "jsonrpc" | "json-rpc" | "jsonrpc-2.0" => Some(FramingChoice { + ctor: "JsonRpcFraming", + jsonrpc: true, + }), + "datagram" | "comline.datagram" => Some(FramingChoice { + ctor: "DatagramFraming", + jsonrpc: false, + }), + _ => None, + } +} + +/// A protocol's framing, most specific first: its own `@framing`, then the +/// package `default_framing`, then datagram. +fn framing_choice(parameters: &[FrozenUnit], default_framing: Option<&str>) -> FramingChoice { + annotation(parameters, "framing") + .and_then(framing_for) + .or_else(|| default_framing.and_then(framing_for)) + .unwrap_or(FramingChoice { + ctor: "DatagramFraming", + jsonrpc: false, + }) +} + +/// The value of a scalar `@key = value` annotation (a `FrozenUnit::Property`). +fn annotation<'a>(parameters: &'a [FrozenUnit], key: &str) -> Option<&'a str> { + parameters.iter().find_map(|p| match p { + FrozenUnit::Property { name, expression } if name == key => expression.as_deref(), + _ => None, + }) +} + // ── protocol ─────────────────────────────────────────────────────────────── struct FnInfo { name: String, params_ty: Option, args: Vec<(String, String)>, - /// `Promise<...>` payload — `void` for a one-way call or a `()` return. + /// The declared return type; `void` for a one-way call or a `()` return. ret: String, - /// `(ordinal, error interface name)` for each `!` on this function. Empty - /// for a one-way call (a `!` there has nowhere to go). + one_way: bool, + /// `(ordinal, error interface name)` for each `!`. Empty for a one-way call. throws: Vec<(u16, String)>, - err_ty: Option, } -fn protocol(proto: &str, functions: &[FrozenUnit], errors: &HashMap) -> String { +fn protocol( + proto: &str, + functions: &[FrozenUnit], + framing: &FramingChoice, + errors: &HashMap, +) -> String { let fns: Vec = functions .iter() .filter_map(|f| match f { @@ -156,11 +267,7 @@ fn protocol(proto: &str, functions: &[FrozenUnit], errors: &HashMap .iter() .map(|a| (a.name.clone(), ts_type(&a.kind))) .collect(); - let params_ty = if args.is_empty() { - None - } else { - Some(format!("{proto}{}Params", pascal(name))) - }; + let params_ty = (!args.is_empty()).then(|| format!("{proto}{}Params", pascal(name))); let ret = match _return { None | Some(KindValue::Unit) => "void".to_string(), Some(kv) => ts_type(kv), @@ -181,14 +288,13 @@ fn protocol(proto: &str, functions: &[FrozenUnit], errors: &HashMap }) .collect() }; - let err_ty = (!throws.is_empty()).then(|| format!("{proto}{}Error", pascal(name))); Some(FnInfo { name: name.clone(), params_ty, args, ret, + one_way, throws, - err_ty, }) } _ => None, @@ -208,45 +314,169 @@ fn protocol(proto: &str, functions: &[FrozenUnit], errors: &HashMap } } - // 2. a discriminated-union error type per throwing function, each arm - // carrying the wire ordinal, the name, and the typed payload. + if fns.is_empty() { + s.push_str(&format!("export interface {proto} {{}}\n\n")); + return s; + } + + // 2. the provider interface — every call is async + s.push_str(&format!("export interface {proto} {{\n")); for f in &fns { - if let Some(err_ty) = &f.err_ty { - s.push_str(&format!("export type {err_ty} ={};\n\n", union_arms(&f.throws))); + if !f.throws.is_empty() { + let names = f + .throws + .iter() + .map(|(_, n)| format!("{n}Error")) + .collect::>() + .join(" | "); + s.push_str(&format!(" /** @throws {{{names}}} */\n")); } + s.push_str(&format!( + " {}({}): Promise<{}>;\n", + f.name, + f.params_ty.as_ref().map_or(String::new(), |t| format!("params: {t}")), + f.ret + )); } + s.push_str("}\n\n"); - // 3. the per-protocol union of every error it can raise - let mut all: Vec<(u16, String)> = fns.iter().flat_map(|f| f.throws.clone()).collect(); - all.sort_by_key(|(o, _)| *o); - all.dedup_by_key(|(o, _)| *o); - if !all.is_empty() { - s.push_str(&format!("export type {proto}Error ={};\n\n", union_arms(&all))); - } + // 3. the call table + let calls_const = format!("{}_CALLS", screaming_snake(proto)); + let call_names = fns + .iter() + .map(|f| format!("\"{}\"", f.name)) + .collect::>() + .join(", "); + s.push_str(&format!( + "export const {calls_const} = [{call_names}] as const;\n\n" + )); - // 4. the protocol interface — every call is async - s.push_str(&format!("export interface {proto} {{\n")); - for f in &fns { - let params = match &f.params_ty { - Some(ty) => format!("params: {ty}"), - None => String::new(), + // 4. the dispatcher + s.push_str(&format!( + "export class {proto}Dispatcher implements Dispatch {{\n\ + \x20 constructor(private readonly impl: {proto}) {{}}\n\n\ + \x20 calls(): readonly string[] {{\n\ + \x20 return {calls_const};\n\ + \x20 }}\n\n\ + \x20 async dispatch(call: Kind, params: Uint8Array, codec: Codec, reply: Reply): Promise {{\n\ + \x20 switch (resolveKind(call, {calls_const})) {{\n" + )); + for (i, f) in fns.iter().enumerate() { + s.push_str(&format!(" case {i}: {{\n")); + let call_arg = match &f.params_ty { + Some(ty) => { + s.push_str(&format!( + " const p = codec.decode<{ty}>(params);\n" + )); + "p" + } + None => "", }; - if let Some(err_ty) = &f.err_ty { - s.push_str(&format!(" /** @throws {{{err_ty}}} */\n")); + if f.one_way { + s.push_str(&format!( + " await this.impl.{}({call_arg});\n\ + \x20 return;\n", + f.name + )); + } else if f.throws.is_empty() { + s.push_str(&format!( + " reply.ok(codec.encode((await this.impl.{}({call_arg})) ?? null));\n\ + \x20 return;\n", + f.name + )); + } else { + s.push_str(" try {\n"); + s.push_str(&format!( + " reply.ok(codec.encode((await this.impl.{}({call_arg})) ?? null));\n", + f.name + )); + s.push_str(" } catch (e) {\n"); + for (_, err) in &f.throws { + s.push_str(&format!( + " if (e instanceof {err}Error) {{ reply.err({err}Error.ordinal, codec.encode(e.data)); return; }}\n" + )); + } + s.push_str(" throw e;\n"); + s.push_str(" }\n"); + s.push_str(" return;\n"); } - s.push_str(&format!(" {}({params}): Promise<{}>;\n", f.name, f.ret)); + s.push_str(" }\n"); + } + s.push_str( + " default:\n\ + \x20 throw RuntimeError.unknownCall();\n\ + \x20 }\n\ + \x20 }\n\ + }\n\n", + ); + + // 5. the client + s.push_str(&format!( + "export class {proto}Client {{\n\ + \x20 constructor(private readonly client: Client) {{}}\n\n\ + \x20 static async connect(transport: Transport, codec: Codec, framing: Framing = new {ctor}()): Promise<{proto}Client> {{\n\ + \x20 const hs = new Handshake({{ irHash: IR_HASH, wireFormat: codec.name, framing: framing.name }});\n\ + \x20 return new {proto}Client(await Client.connect(transport, codec, hs, framing));\n\ + \x20 }}\n", + ctor = framing.ctor + )); + for (i, f) in fns.iter().enumerate() { + let sig_params = f + .params_ty + .as_ref() + .map_or(String::new(), |t| format!("params: {t}")); + let arg = if f.params_ty.is_some() { "params" } else { "null" }; + s.push('\n'); + if f.one_way { + s.push_str(&format!( + " async {}({sig_params}): Promise {{\n\ + \x20 await this.client.notify({{ id: {i}, name: \"{}\" }}, {arg});\n\ + \x20 }}\n", + f.name, f.name + )); + continue; + } + s.push_str(&format!( + " async {}({sig_params}): Promise<{}> {{\n\ + \x20 const env = await this.client.call({{ id: {i}, name: \"{}\" }}, {arg});\n", + f.name, f.ret, f.name + )); + if f.ret == "void" { + s.push_str(" if (\"ok\" in env) return;\n"); + } else { + s.push_str(&format!( + " if (\"ok\" in env) return this.client.codec.decode<{}>(env.ok);\n", + f.ret + )); + } + if f.throws.is_empty() { + s.push_str(" throw RuntimeError.remote(env.err.id);\n"); + } else { + s.push_str(" switch (env.err.id) {\n"); + for (_, err) in &f.throws { + s.push_str(&format!( + " case {err}Error.ordinal:\n\ + \x20 throw new {err}Error(this.client.codec.decode<{err}>(env.err.body));\n" + )); + } + s.push_str(" default:\n"); + s.push_str(" throw RuntimeError.remote(env.err.id);\n"); + s.push_str(" }\n"); + } + s.push_str(" }\n"); } s.push_str("}\n\n"); - s -} + // 6. the serve helper + s.push_str(&format!( + "export function serve{proto}(impl: {proto}, transport: Transport, codec: Codec, framing: Framing = new {ctor}()): Promise {{\n\ + \x20 const hs = new Handshake({{ irHash: IR_HASH, wireFormat: codec.name, framing: framing.name }});\n\ + \x20 return new Server(new {proto}Dispatcher(impl), codec, framing).serveHandshaked(transport, hs);\n\ + }}\n\n", + ctor = framing.ctor + )); -/// `\n | { code: 0; name: "Rejected"; data: Rejected }` per `(ordinal, name)`. -fn union_arms(throws: &[(u16, String)]) -> String { - throws - .iter() - .map(|(ord, name)| format!("\n | {{ code: {ord}; name: \"{name}\"; data: {name} }}")) - .collect() + s } // ── type mapping ─────────────────────────────────────────────────────────── @@ -291,3 +521,15 @@ fn pascal(s: &str) -> String { }) .collect() } + +/// `UserService` -> `USER_SERVICE`, for the call-table const. +fn screaming_snake(s: &str) -> String { + let mut out = String::new(); + for (i, ch) in s.char_indices() { + if ch.is_uppercase() && i != 0 { + out.push('_'); + } + out.extend(ch.to_uppercase()); + } + out +} diff --git a/codegen/src/lib.rs b/codegen/src/lib.rs index 2603fca..29a91d0 100644 --- a/codegen/src/lib.rs +++ b/codegen/src/lib.rs @@ -1,6 +1,9 @@ -//! TypeScript code generator. `code` mode only: `export interface` per struct, -//! `export enum` (string values) per enum, `export interface` per protocol. -//! `lib` mode is not implemented. See `design/generation.md`. +//! TypeScript code generator. `code` mode only: `export interface` per struct / +//! `error` (+ a `Error` throwable), `export enum` (string values) per +//! enum, and per `protocol` the full RPC shape against `@comline/runtime` — an +//! `IR_HASH`, params interfaces, a provider interface, a `Dispatcher`, a +//! `Client`, and a `serve` helper. `lib` mode is not implemented. +//! See `design/generation.md`. mod generator; diff --git a/codegen/tests/generate.rs b/codegen/tests/generate.rs index 03bf814..376de76 100644 --- a/codegen/tests/generate.rs +++ b/codegen/tests/generate.rs @@ -1,13 +1,16 @@ use comline_codegen::{GenRequest, Mode, PackageMeta}; use comline_codegen_typescript::generate_typescript; -use comline_core::schema::ir::frozen::unit::{FrozenUnit, FrozenArgument}; use comline_core::schema::ir::compiler::interpreted::kind_search::{KindValue, Primitive}; +use comline_core::schema::ir::frozen::unit::{FrozenArgument, FrozenUnit}; fn code_req(schemas: &[(String, Vec)]) -> GenRequest<'_> { GenRequest { mode: Mode::Code, schemas, - package: PackageMeta { name: "test".into(), version: "0.1.0".into() }, + package: PackageMeta { + name: "test".into(), + version: "0.1.0".into(), + }, default_framing: None, } } @@ -16,7 +19,10 @@ fn lib_req(schemas: &[(String, Vec)]) -> GenRequest<'_> { GenRequest { mode: Mode::Lib, schemas, - package: PackageMeta { name: "chat".into(), version: "0.3.0".into() }, + package: PackageMeta { + name: "chat".into(), + version: "0.3.0".into(), + }, default_framing: None, } } @@ -29,6 +35,42 @@ fn one(units: Vec) -> String { files.remove(0).contents } +fn field(name: &str, ty: &str) -> FrozenUnit { + FrozenUnit::Field { + docstring: None, + parameters: vec![], + optional: false, + name: name.into(), + kind_value: KindValue::Namespaced(ty.into(), None), + span: (0, 0), + } +} + +fn arg(name: &str, kind: KindValue) -> FrozenArgument { + FrozenArgument { + name: name.into(), + kind, + span: (0, 0), + } +} + +fn function( + name: &str, + args: Vec, + ret: Option, + throws: Vec, +) -> FrozenUnit { + FrozenUnit::Function { + docstring: String::new(), + parameters: vec![], + name: name.into(), + arguments: args, + _return: ret, + throws, + span: (0, 0), + } +} + #[test] fn interface_from_struct() { let out = one(vec![FrozenUnit::Struct { @@ -36,22 +78,8 @@ fn interface_from_struct() { parameters: vec![], name: "User".to_string(), fields: vec![ - FrozenUnit::Field { - docstring: None, - parameters: vec![], - optional: false, - name: "id".to_string(), - kind_value: KindValue::Namespaced("s32".to_string(), None), - span: (0, 0), - }, - FrozenUnit::Field { - docstring: None, - parameters: vec![], - optional: false, - name: "username".to_string(), - kind_value: KindValue::Namespaced("string".to_string(), None), - span: (0, 0), - }, + field("id", "s32"), + field("username", "string"), FrozenUnit::Field { docstring: None, parameters: vec![], @@ -87,123 +115,121 @@ fn string_enum_from_enum() { assert!(out.contains("Inactive = \"Inactive\",")); } -#[test] -fn interface_from_protocol() { - let out = one(vec![FrozenUnit::Protocol { - docstring: "A user service".to_string(), - name: "UserService".to_string(), - parameters: vec![], - functions: vec![ - FrozenUnit::Function { - docstring: String::new(), - name: "get_user".to_string(), - parameters: vec![], - arguments: vec![FrozenArgument { - name: "id".to_string(), - kind: KindValue::Primitive(Primitive::S32(None)), - span: (0, 0), - }], - _return: Some(KindValue::Namespaced("User".to_string(), None)), - throws: vec![], - span: (0, 0), - }, - FrozenUnit::Function { - docstring: String::new(), - name: "ping".to_string(), - parameters: vec![], - arguments: vec![], - _return: None, - throws: vec![], - span: (0, 0), - }, - ], - span: (0, 0), - }]); - - // a protocol makes the file carry the handshake digest - assert!(out.contains("export const IR_HASH = 0x")); - assert!(out.contains("n;\n")); - // a params interface per function that takes arguments - assert!(out.contains("export interface UserServiceGetUserParams {\n id: number;\n}")); - // every call is async; args arrive as one `params` object - assert!(out.contains("export interface UserService {")); - assert!(out.contains(" get_user(params: UserServiceGetUserParams): Promise;")); - // a one-way / no-arg call: no params, `Promise` - assert!(out.contains(" ping(): Promise;")); -} - -#[test] -fn error_interfaces_and_discriminated_unions_from_throws() { - let out = one(vec![ +/// A struct, an `error`, and a protocol exercising: a throwing call with args, a +/// non-throwing call returning a list, a `()` return, and a one-way call. +fn chat_units() -> Vec { + vec![ + FrozenUnit::Struct { + docstring: None, + parameters: vec![], + name: "Message".into(), + fields: vec![field("body", "string"), field("seq", "u64")], + span: (0, 0), + }, FrozenUnit::Error { docstring: None, parameters: vec![], ordinal: 0, imported_from: None, - name: "Rejected".to_string(), - message: "no".to_string(), - fields: vec![FrozenUnit::Field { - docstring: None, - parameters: vec![], - optional: false, - name: "why".to_string(), - kind_value: KindValue::Namespaced("string".to_string(), None), - span: (0, 0), - }], + name: "Rejected".into(), + message: "rejected: {self.reason}".into(), + fields: vec![field("reason", "string")], }, FrozenUnit::Protocol { - docstring: String::new(), - name: "Chat".to_string(), + docstring: "Chat".into(), + name: "Chat".into(), parameters: vec![], functions: vec![ - FrozenUnit::Function { - docstring: String::new(), - name: "send".to_string(), - parameters: vec![], - arguments: vec![FrozenArgument { - name: "body".to_string(), - kind: KindValue::Namespaced("string".to_string(), None), - span: (0, 0), - }], - _return: Some(KindValue::Unit), - throws: vec![0], - span: (0, 0), - }, - // one-way: a `!` here is dropped - FrozenUnit::Function { - docstring: String::new(), - name: "poke".to_string(), - parameters: vec![], - arguments: vec![], - _return: None, - throws: vec![0], - span: (0, 0), - }, + function( + "send", + vec![arg("text", KindValue::Namespaced("string".into(), None))], + Some(KindValue::Namespaced("Message".into(), None)), + vec![0], + ), + function( + "history", + vec![arg("limit", KindValue::Primitive(Primitive::U32(None)))], + Some(KindValue::Namespaced("Message[]".into(), None)), + vec![], + ), + function("wipe", vec![], Some(KindValue::Unit), vec![]), + function( + "note", + vec![arg("text", KindValue::Namespaced("string".into(), None))], + None, + vec![], + ), ], span: (0, 0), }, - ]); + ] +} - // the `error` becomes an interface - assert!(out.contains("export interface Rejected {\n why: string;\n}")); - // per-function union carries the wire ordinal, the name, and the payload +#[test] +fn protocol_emits_the_rpc_shape() { + let out = one(chat_units()); + + // handshake digest + the runtime import + assert!(out.contains("export const IR_HASH = 0x")); + assert!(out.contains("} from \"@comline/runtime\";")); + + // the wire payload interface + the throwable class, keyed by ordinal + assert!(out.contains("export interface Rejected {\n reason: string;\n}")); + assert!(out.contains("export class RejectedError extends Error {")); + assert!(out.contains("static readonly ordinal = 0;")); + + // params interfaces + provider interface + assert!(out.contains("export interface ChatSendParams {\n text: string;\n}")); + assert!(out.contains(" /** @throws {RejectedError} */")); + assert!(out.contains(" send(params: ChatSendParams): Promise;")); + assert!(out.contains(" wipe(): Promise;")); + assert!(out.contains(" note(params: ChatNoteParams): Promise;")); + + // call table + dispatcher assert!(out.contains( - "export type ChatSendError =\n | { code: 0; name: \"Rejected\"; data: Rejected };" + "export const CHAT_CALLS = [\"send\", \"history\", \"wipe\", \"note\"] as const;" )); - // per-protocol union, and the method advertises what it throws + assert!(out.contains("export class ChatDispatcher implements Dispatch {")); + assert!(out.contains("if (e instanceof RejectedError) { reply.err(RejectedError.ordinal, codec.encode(e.data)); return; }")); + assert!(out.contains("await this.impl.note(p);")); // one-way: no reply + + // client + serve helper, both wired to the datagram framing by default + assert!(out.contains("export class ChatClient {")); + assert!(out.contains("framing: Framing = new DatagramFraming()")); + assert!(out.contains("case RejectedError.ordinal:")); + assert!(out.contains("await this.client.notify({ id: 3, name: \"note\" }, params);")); assert!(out.contains( - "export type ChatError =\n | { code: 0; name: \"Rejected\"; data: Rejected };" + "export function serveChat(impl: Chat, transport: Transport, codec: Codec, framing: Framing = new DatagramFraming()): Promise {" )); - assert!(out.contains(" /** @throws {ChatSendError} */")); - assert!(out.contains(" send(params: ChatSendParams): Promise;")); - // `-> ()` is request/response with an empty ack, not one-way - assert!(out.contains(" poke(): Promise;")); - // a `!` on a one-way call is dropped — no poke error type - assert!(!out.contains("ChatPokeError")); } #[test] -fn a_schema_without_a_protocol_has_no_ir_hash() { +fn framing_annotation_and_package_default_pick_jsonrpc() { + // @framing = "jsonrpc" on the protocol + let mut units = chat_units(); + if let FrozenUnit::Protocol { parameters, .. } = &mut units[2] { + parameters.push(FrozenUnit::Property { + name: "framing".into(), + expression: Some("jsonrpc".into()), + }); + } + let out = one(units); + assert!(out.contains(" JsonRpcFraming,")); + assert!(out.contains("framing: Framing = new JsonRpcFraming()")); + assert!(!out.contains("new DatagramFraming()")); + + // …or the package default reaches an unannotated protocol + let schemas = vec![("account".to_string(), chat_units())]; + let req = GenRequest { + default_framing: Some("jsonrpc".to_string()), + ..code_req(&schemas) + }; + let out = generate_typescript(&req).unwrap().remove(0).contents; + assert!(out.contains("framing: Framing = new JsonRpcFraming()")); +} + +#[test] +fn a_schema_without_a_protocol_has_no_ir_hash_or_runtime_import() { let out = one(vec![FrozenUnit::Enum { docstring: None, name: "Status".to_string(), @@ -214,11 +240,36 @@ fn a_schema_without_a_protocol_has_no_ir_hash() { span: (0, 0), }]); assert!(!out.contains("IR_HASH")); + assert!(!out.contains("@comline/runtime")); +} + +/// The generated `Chat` client / dispatcher, kept in +/// `runtime/test/generated/chat.ts` so the Node job type-checks and runs it. +/// Regenerate with `TS_BLESS=1 cargo test -p comline-codegen-typescript`. +#[test] +fn generated_chat_matches_the_runtime_test_fixture() { + let generated = one(chat_units()); + let path = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../runtime/test/generated/chat.ts" + ); + if std::env::var_os("TS_BLESS").is_some() { + std::fs::write(path, &generated).unwrap(); + return; + } + let committed = std::fs::read_to_string(path).unwrap_or_default(); + assert_eq!( + generated, committed, + "generated Chat drifted from runtime/test/generated/chat.ts — \ + re-bless with TS_BLESS=1 cargo test -p comline-codegen-typescript" + ); } #[test] fn lib_mode_is_not_implemented() { let schemas = vec![("account".to_string(), vec![])]; - let err = generate_typescript(&lib_req(&schemas)).unwrap_err().to_string(); + let err = generate_typescript(&lib_req(&schemas)) + .unwrap_err() + .to_string(); assert!(err.contains("lib mode")); } diff --git a/runtime/test/generated.test.ts b/runtime/test/generated.test.ts new file mode 100644 index 0000000..0d09213 --- /dev/null +++ b/runtime/test/generated.test.ts @@ -0,0 +1,58 @@ +// Exercises real generator output against the runtime. `generated/chat.ts` is +// written by `comline-codegen-typescript`'s `generated_chat_matches_the_ +// runtime_test_fixture` test; this proves it type-checks and runs. + +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { JsonCodec, duplex } from "../src/index.js"; +import { + type Chat, + ChatClient, + RejectedError, + serveChat, + IR_HASH, +} from "./generated/chat.js"; + +test("the generated Chat carries a bigint IR_HASH", () => { + assert.equal(typeof IR_HASH, "bigint"); +}); + +test("a generated client ⇆ provider round-trip", async () => { + const [clientSide, providerSide] = duplex(); + const seen: string[] = []; + + const impl: Chat = { + async send({ text }) { + if (text === "") throw new RejectedError({ reason: "empty" }); + return { body: `echo: ${text}`, seq: 1 }; + }, + async history({ limit }) { + return Array.from({ length: limit }, (_, i) => ({ body: `m${i}`, seq: i })); + }, + async wipe() { + seen.length = 0; + }, + async note({ text }) { + seen.push(text); + }, + }; + + const provider = serveChat(impl, providerSide, new JsonCodec()); + const chat = await ChatClient.connect(clientSide, new JsonCodec()); + + assert.equal((await chat.send({ text: "hi" })).body, "echo: hi"); + assert.equal((await chat.history({ limit: 3 })).length, 3); + + await assert.rejects( + chat.send({ text: "" }), + (e: unknown) => e instanceof RejectedError && e.data.reason === "empty", + ); + + await chat.note({ text: "saved" }); // one-way + await chat.wipe(); // `()` return + assert.deepEqual(seen, []); + + clientSide.close(); + await provider; +}); diff --git a/runtime/test/generated/chat.ts b/runtime/test/generated/chat.ts new file mode 100644 index 0000000..6c5e977 --- /dev/null +++ b/runtime/test/generated/chat.ts @@ -0,0 +1,140 @@ +// Generated by Comline + +import { + Client, + Server, + Handshake, + RuntimeError, + resolveKind, + DatagramFraming, + type Codec, + type Dispatch, + type Framing, + type Kind, + type Reply, + type Transport, +} from "@comline/runtime"; + +/** Canonical digest of the frozen IR this file was generated from — the +* two ends of a connection must agree on it. */ +export const IR_HASH = 0x6b347d4fa800b29fn; + +export interface Message { + body: string; + seq: number; +} + +export interface Rejected { + reason: string; +} + +export class RejectedError extends Error { + static readonly ordinal = 0; + constructor(readonly data: Rejected) { + super("Rejected"); + this.name = "RejectedError"; + } +} + +export interface ChatSendParams { + text: string; +} + +export interface ChatHistoryParams { + limit: number; +} + +export interface ChatNoteParams { + text: string; +} + +export interface Chat { + /** @throws {RejectedError} */ + send(params: ChatSendParams): Promise; + history(params: ChatHistoryParams): Promise; + wipe(): Promise; + note(params: ChatNoteParams): Promise; +} + +export const CHAT_CALLS = ["send", "history", "wipe", "note"] as const; + +export class ChatDispatcher implements Dispatch { + constructor(private readonly impl: Chat) {} + + calls(): readonly string[] { + return CHAT_CALLS; + } + + async dispatch(call: Kind, params: Uint8Array, codec: Codec, reply: Reply): Promise { + switch (resolveKind(call, CHAT_CALLS)) { + case 0: { + const p = codec.decode(params); + try { + reply.ok(codec.encode((await this.impl.send(p)) ?? null)); + } catch (e) { + if (e instanceof RejectedError) { reply.err(RejectedError.ordinal, codec.encode(e.data)); return; } + throw e; + } + return; + } + case 1: { + const p = codec.decode(params); + reply.ok(codec.encode((await this.impl.history(p)) ?? null)); + return; + } + case 2: { + reply.ok(codec.encode((await this.impl.wipe()) ?? null)); + return; + } + case 3: { + const p = codec.decode(params); + await this.impl.note(p); + return; + } + default: + throw RuntimeError.unknownCall(); + } + } +} + +export class ChatClient { + constructor(private readonly client: Client) {} + + static async connect(transport: Transport, codec: Codec, framing: Framing = new DatagramFraming()): Promise { + const hs = new Handshake({ irHash: IR_HASH, wireFormat: codec.name, framing: framing.name }); + return new ChatClient(await Client.connect(transport, codec, hs, framing)); + } + + async send(params: ChatSendParams): Promise { + const env = await this.client.call({ id: 0, name: "send" }, params); + if ("ok" in env) return this.client.codec.decode(env.ok); + switch (env.err.id) { + case RejectedError.ordinal: + throw new RejectedError(this.client.codec.decode(env.err.body)); + default: + throw RuntimeError.remote(env.err.id); + } + } + + async history(params: ChatHistoryParams): Promise { + const env = await this.client.call({ id: 1, name: "history" }, params); + if ("ok" in env) return this.client.codec.decode(env.ok); + throw RuntimeError.remote(env.err.id); + } + + async wipe(): Promise { + const env = await this.client.call({ id: 2, name: "wipe" }, null); + if ("ok" in env) return; + throw RuntimeError.remote(env.err.id); + } + + async note(params: ChatNoteParams): Promise { + await this.client.notify({ id: 3, name: "note" }, params); + } +} + +export function serveChat(impl: Chat, transport: Transport, codec: Codec, framing: Framing = new DatagramFraming()): Promise { + const hs = new Handshake({ irHash: IR_HASH, wireFormat: codec.name, framing: framing.name }); + return new Server(new ChatDispatcher(impl), codec, framing).serveHandshaked(transport, hs); +} + diff --git a/runtime/tsconfig.json b/runtime/tsconfig.json index 81404f5..31041d8 100644 --- a/runtime/tsconfig.json +++ b/runtime/tsconfig.json @@ -14,7 +14,11 @@ "verbatimModuleSyntax": true, "forceConsistentCasingInFileNames": true, "skipLibCheck": true, - "types": ["node"] + "types": ["node"], + "baseUrl": ".", + "paths": { + "@comline/runtime": ["./src/index.ts"] + } }, "include": ["src/**/*.ts", "test/**/*.ts"] }