@inkly/protocol
v0.0.0
Published
Shared foundation for inkly: typed contracts, wire protocol, codecs, and adapter interfaces.
Downloads
32
Readme
@inkly/protocol
The shared foundation for inkly — the typed contract system, the wire protocol, codecs, error types, and the adapter/peer interfaces that the server core, the client, and every runtime adapter build on.
This is a low-level building block. Most apps depend on
@inkly/core(server) and@inkly/client(client), which re-export the pieces you need. You only depend on@inkly/protocoldirectly when writing an adapter or a plugin.
Why it exists
Every layer of inkly needs to agree on three things:
- What can be sent — the
contract()type system (actions, events, streams). - How it is framed — the
inkly.v1wire protocol (a small discriminated union of frames). - How a connection looks — the
Peer/ adapter interfaces that normalize Node, Bun, Deno, and Cloudflare sockets into one shape.
Keeping these in a single dependency-free package means the client and server can share exact types with no codegen and no schema duplication, and adapters can be written against a stable surface.
Zero runtime dependencies
The only dependency is @standard-schema/spec,
which is types-only (it ships no runtime code). Validators (Zod, Valibot, ArkType, ...) are
user-supplied and reached through the standard ~standard interface, so inkly never bundles a
validator.
Contract
import { contract } from "@inkly/protocol";
import { z } from "zod";
export const chat = contract({
actions: {
sendMessage: {
input: z.object({ room: z.string(), text: z.string() }),
output: z.object({ id: z.string(), at: z.number() }),
},
},
events: {
message: z.object({ id: z.string(), text: z.string() }),
},
streams: {
assistant: { input: z.object({ prompt: z.string() }), yields: z.object({ token: z.string() }) },
},
});A contract is a plain object brand-tagged with ~inkly. Both the server and the client import
the same chat value and infer their types from typeof chat.
Inference helpers
import type { ActionInput, ActionOutput, EventPayload, StreamYield } from "@inkly/protocol";
type SendInput = ActionInput<typeof chat, "sendMessage">; // { room: string; text: string }
type SendOutput = ActionOutput<typeof chat, "sendMessage">; // { id: string; at: number }
type Msg = EventPayload<typeof chat, "message">; // { id: string; text: string }
type Token = StreamYield<typeof chat, "assistant">; // { token: string }An action with no output infers void.
Validation
import { validate, ValidationError } from "@inkly/protocol";
const parsed = await validate(chat.actions.sendMessage.input, raw); // throws ValidationError on failurevalidate() talks to any Standard Schema validator through ~standard, so the same call works with
Zod, Valibot, or ArkType with no adapters.
Wire protocol
SUBPROTOCOL = "inkly.v1". Frames are a discriminated union on t:
| Direction | t values |
| --- | --- |
| client -> server | hello, rpc, sub, unsub, ping |
| server -> client | ready, ack, err, event, chunk, end, pong |
event and chunk frames carry a monotonic seq, and hello may carry a resume request — this
is what powers reconnect-and-replay. See docs/overview.md and
docs/wire-protocol.md.
Codec
import { jsonCodec } from "@inkly/protocol";
const bytes = jsonCodec.encode({ t: "ping" });
const frame = jsonCodec.decode(bytes);jsonCodec is the zero-dependency default. The Codec interface lets binary codecs (e.g.
MessagePack) plug in without touching the rest of the stack.
Errors
InklyError is the base class; Unauthorized (401), Forbidden (403), NotFound (404),
TimeoutError, ResumeExpired, and ValidationError (400) extend it. toWireError(err, expose?)
normalizes any thrown value into a serializable WireError, masking non-inkly errors by default so
internal details never leak to clients.
License
Dazza Public License 1.0 (LicenseRef-Dazza-1.0).
