Client & server
A protocol on the lockstep datagram framing gets a client and a server; every
other protocol runs on a connection, the default. This page
follows the runtime's own test fixture — a lockstep Chat protocol with a
throwing call, a plain call, a () return, and a one-way call:
struct Message {
body: string
seq: u64
}
error Rejected {
reason: string
}
@framing = "datagram"
protocol Chat {
function send(text: string) -> Message ! Rejected;
function history(limit: u32) -> Message[];
function wipe();
function note(text: string);
}
What codegen emits
export interface Message { body: string; seq: bigint; }
export const Message: Shape = { fields: { seq: "bigint" } };
export class RejectedError extends Error {
static readonly ordinal = 0;
constructor(readonly data: Rejected) { … }
}
export interface Chat {
/** @throws {RejectedError} */
send(params: ChatSendParams): Promise<Message>;
history(params: ChatHistoryParams): Promise<Message[]>;
wipe(): Promise<void>;
note(params: ChatNoteParams): Promise<void>;
}
export class ChatDispatcher implements Dispatch { constructor(private readonly impl: Chat) {} … }
export class ChatClient { … }
export function serveChat(impl: Chat, transport: Transport, codec: Codec, framing?: Framing): Promise<void>;
export const IR_HASH = 0x…n; // schema fingerprint, used in the handshake
seqis au64, so it's abigint.numberholds integers exactly only up to 2^53, so every 64- and 128-bit integer is abigint, and a constant of one is written7n. A type that holds one, anywhere inside, also gets aShapeof the same name. The generated client and dispatcher pass it to the codec, which reads those integers exactly. Types without one are just interfaces.Chatis the interface your implementation fills in — every methodasync/Promise-returning, a thrown*Errorclass per declared!(several, comma-separated, if the function lists several).ChatDispatcherwraps an implementation and implements the runtime'sDispatchinterface.ChatClientwraps aClientand gives you one method per function.serveChatis the generated convenience function: build aChatDispatcher, run the handshake, serve. There's no dispatcher "class method" for this — it's a standalone function alongside the classes.
Transport today
Only one Transport ships: the in-memory duplex() pair (two linked
ends — what one sends, the other receives). A stream/TCP transport is
explicitly a later add, called out in the transport module's own doc
comment. Same-process client/server and tests work today; a real network
transport means implementing the two-method Transport interface
(send / recv, both Promise-returning) yourself for now.
Connecting a client
import { duplex } from "@comline/runtime";
import { ChatClient, serveChat } from "./generated/chat.js";
const [clientSide, serverSide] = duplex();
serveChat(new MyChat(), serverSide);
const client = await ChatClient.connect(clientSide);
const message = await client.send({ text: "hello" });
await client.note({ text: "fire and forget" }); // no response awaited
Both encode with the package's Format unless given another Codec.
ChatClient.connect builds a Handshake from IR_HASH and the codec's
name, sends it, reads the peer's, and rejects with
RuntimeError.handshake() on a mismatch — the same check the Rust runtime
does, byte-compatible with it (see Framing & wire
format).
A thrown schema error comes back as its generated *Error class, so a
normal try/catch (or instanceof) handles it:
try {
await client.send({ text: "" });
} catch (e) {
if (e instanceof RejectedError) {
console.log("rejected:", e.data.reason);
} else {
throw e; // a RuntimeError, or something else entirely
}
}
When the provider fails outside its schema
A handler can throw something the schema never declared — a dead database,
a bug. That reaches the caller as RuntimeError with kind "provider",
and nothing else: no message, no stack, no class. There is no wire
representation for arbitrary provider detail, and inventing one would
publish your internals to everyone who calls you.
try {
await client.history({ limit: 10 });
} catch (e) {
if (e instanceof RuntimeError && e.is("provider")) {
// the call reached the provider and it broke; retrying may help
}
}
This is why a function that declares no ! is still worth calling
defensively — and why it needs no ! to be able to fail. Declare a !
for the failures a caller should handle; leave the rest to this channel.
It also means one bad call no longer costs the connection. Before, a handler that threw something undeclared unwound the server loop: the connection died and the caller, who was never sent a frame, waited out its timeout instead. Now that call is answered and the next one still works.
Serving
import { serveChat } from "./generated/chat.js";
class MyChat implements Chat {
async send(params: ChatSendParams): Promise<Message> {
if (!params.text) throw new RejectedError({ reason: "empty message" });
return { body: params.text, seq: 1n };
}
async history(): Promise<Message[]> { return []; }
async wipe(): Promise<void> {}
async note(): Promise<void> {}
}
await serveChat(new MyChat(), transport);
Like the Rust runtime, there's no listener/accept loop built in —
serveChat (and the lower-level Server class it wraps) serves one
already-connected Transport until it closes. Accepting multiple
connections concurrently is ordinary Promise-based concurrency on your
side: call serveChat once per connection and let them run.
For manual control over the handshake, the Server class underneath
exposes serveOne (one call), serveUnverified (loop, no handshake),
and serve(transport, irHash, handshake?) (the handshake, comline@1
unless given, then the loop) — serveChat is just serve with the
dispatcher and IR_HASH already filled in for you.