Wire protocol for @kyneta/transport message transport. Defines the universal Frame<T> abstraction, two encoding pipelines (binary and text), framing, fragmentation, and reassembly for the exchange's seven-message protocol.
Every message sent over a transport is wrapped in a frame. The frame is the universal delivery unit — there is no unframed path. A frame carries a protocol version, a per-direction sequence number (seq), an optional content hash, and content that is either complete (the full payload) or a fragment (one piece of a larger payload). The seq stamps every frame so each exchanged message is referenceable at the protocol level (debugging/tracing); for fragments it also serves as the reassembly group key.
Two encoding pipelines share this frame abstraction:
| Pipeline | Payload type T |
Wire format | Encoding | Use case |
|---|---|---|---|---|
| Binary | Uint8Array |
Binary bytes | encodeWireMessage (CBOR) |
WebSocket, WebRTC, Unix socket |
| Text | string |
JSON string | encodeTextWireMessage (JSON) |
SSE, HTTP |
Batching is orthogonal to framing. The frame layer does not distinguish single messages from batches. The payload's own structure (CBOR array vs map, JSON array vs object) determines singular vs plural. The WireMessage layer handles encode/decode; the frame just carries the payload.
Seven message types form the exchange protocol:
| Discriminator (CBOR) | Type | Direction | Purpose |
|---|---|---|---|
0x01 |
establish |
Bidirectional | Announce peer identity |
0x02 |
depart |
Bidirectional | Signal peer departure |
0x10 |
present |
Bidirectional | Announce document IDs and metadata |
0x11 |
interest |
Bidirectional | Request a specific document's state |
0x12 |
offer |
Bidirectional | Deliver document state (snapshot or delta) |
0x13 |
dismiss |
Bidirectional | Retract interest in a document |
0x14 |
vacant |
Point-to-point | Negative ack to interest: "I don't have this doc and won't serve it" |
Discriminator ranges:
0x01–0x0F— Lifecycle messages (establish, depart)0x10–0x1F— Sync messages (present, interest, offer, dismiss, vacant)
Discriminators are allocated sequentially from the next free value and classified by exact-value set-membership — numbering carries no semantics (there is no range/mask dispatch anywhere; classification is Set membership in validate-wire-message.ts and an exact-value decode switch). A future datagram-only message type takes the next free value (0x15+). Introduce a reserved range only alongside range dispatch.
The text pipeline uses human-readable type strings ("establish", "present", etc.) instead of integer discriminators.
type Frame<T> = {
version: number
seq: number // per-direction monotonic message id (uint32, wraps)
hash: string | null // null today; hex SHA-256 digest in the future
content: Complete<T> | Fragment<T>
}
type Complete<T> = {
kind: "complete"
payload: T
}
type Fragment<T> = {
kind: "fragment"
index: number // 0-based position
total: number // total fragment count
totalSize: number // total payload size (bytes or characters)
payload: T // this fragment's chunk
}Binary pipeline: Frame<Uint8Array>. Text pipeline: Frame<string>.
seq is advanced once per logical message (one Pipeline.send), shared by all fragments of that message, and stamped on every frame — complete or fragment. Two peers can name the same exchange by seq (the sender's send-seq equals the receiver's receive-seq for that message).
Fragments are fully self-describing. Every fragment carries index, total, and totalSize, and is grouped for reassembly by the enclosing frame's seq. There is no separate "fragment header" message — the receiver auto-creates collection state on first contact with a new seq.
encodeWireMessage/decodeWireMessage (binary) and encodeTextWireMessage/decodeTextWireMessage (text) handle the ChannelMsg ⇄ WireMessage conversion. The alias transformer (applyOutboundAliasing / applyInboundAliasing) applied before encode and after decode is the single ChannelMsg ⇄ WireMessage step.
encodeWireMessage(msg: ChannelMsg): WireMessage and decodeWireMessage(wire: WireMessage): ChannelMsg. CBOR-encoded Uint8Array in/out at the frame layer.
Implementation in src/cbor.ts — compact CBOR with integer discriminators and short field names. Uint8Array data in SubstratePayload is encoded natively as CBOR byte strings (no base64).
encodeTextWireMessage(msg: ChannelMsg): unknown and decodeTextWireMessage(obj: unknown): ChannelMsg. JSON-safe object in/out at the frame layer.
Implementation in src/json.ts — human-readable JSON with full type strings. Uint8Array data in SubstratePayload is transparently base64-encoded on write and base64-decoded on read. JSON SubstratePayload data passes through as-is.
Offset Size Field
────── ───── ──────────────────
0 1 Version (WIRE_VERSION = 3 — frame encoding axis)
1 1 Type (0x00 = complete, 0x01 = fragment)
2 4 Payload length (Uint32 big-endian)
6 4 Seq (Uint32 big-endian) — per-direction monotonic message id
Note — three distinct version axes. This header byte is the frame encoding version (
WIRE_VERSION = 3). It is unrelated to the per-docSyncModeand to the establishprotocolVersion(pv, the sync wire-contract revision; see "Protocol Version Compatibility"). Do not conflate them.
payloadLength stays at offset 2; seq is appended at offset 6. A stream framer that delimits frames by reading the type byte and payload length therefore needs no offset changes when this field was added — only the header size constant.
[6-byte header]
[payload: codec-encoded bytes]
The type byte is 0x00. Payload length covers the codec-encoded bytes.
[10-byte header] (the header's seq field is the fragment group key)
[index: 2 bytes uint16 big-endian]
[total: 2 bytes uint16 big-endian]
[totalSize: 4 bytes uint32 big-endian]
[payload: chunk bytes]
The type byte is 0x01. Payload length covers the chunk data only (not the 8 bytes of fragment metadata). Total frame size = 10 (header) + 8 (metadata) + payload length.
Fragments are grouped for reassembly by the header seq — there is no separate per-fragment id. seq is a per-channel-direction monotonic uint32 advanced by the pure nextFrameSeq helper in @kyneta/wire; the counter state lives in the pipeline (PipelineState.nextSeq). It wraps at 2³², which is effectively never within a channel-direction lifetime.
v1 has no transport-prefix layer. The frame type byte (offset 1 of the 6-byte header) distinguishes complete vs fragment frames. The receiver decodes the header to decide whether fragment collection is needed.
encodeWireMessage encodes ChannelMsg objects as compact wire objects with short field names:
| Wire field | Full name | Type | Used by |
|---|---|---|---|
t |
type | integer discriminator | All messages |
id |
peerId | string |
establish |
n |
name | string (optional) |
establish |
y |
type | "user" | "bot" | "service" |
establish |
f |
features | WireFeatures (compact map) |
establish (optional) |
pv |
protocolVersion | [major, minor] (two integers) |
establish (optional; absent ⇒ [1,0]; emitted only when non-default) |
docs |
docs | Array<{d, a?, rt, ms, sh?, sa?, shx?, shs?}> |
present |
doc |
docId | string (one of doc/dx required) |
interest, offer, dismiss, vacant |
dx |
docId alias | non-negative integer | interest, offer, dismiss, vacant (one of doc/dx required) |
sh |
schemaHash | string (one of sh/shx required on present-doc) |
present (doc entry) |
sa |
schemaHash alias | non-negative integer (alias assignment) | present (doc entry, optional) |
shx |
schemaHash alias | non-negative integer (alias reference) | present (doc entry, alternative to sh) |
a |
docId alias | non-negative integer (alias assignment) | present (doc entry, optional) |
d |
docId / data | string (present doc entry) or string | Uint8Array (offer) |
present, offer |
rt |
replicaType | [string, number, number] |
present (doc entry) |
ms |
syncMode | SyncModeWireValue (0x00 collaborative, 0x01 authoritative, 0x02 ephemeral) |
present (doc entry) |
v |
version | string |
interest (optional), offer |
r |
reciprocate | boolean (optional) |
interest, offer |
pk |
payload kind | 0x00 (entirety) or 0x01 (since) |
offer |
pe |
payload encoding | 0x00 (json) or 0x01 (binary) |
offer |
Decoder invariants (Phase 3):
- Interest, offer, dismiss, vacant: exactly one of
{doc, dx}must be present. Both →doc-id-form-conflict. Neither → same code. - Present doc entries: exactly one of
{sh, shx}must be present. Both →schema-hash-form-conflict.
Decoders MUST tolerate absent optional fields by applying these defaults:
| Field | Default | Notes |
|---|---|---|
r (reciprocate) |
false |
Most messages don't reciprocate |
pe (payload encoding) |
0 (json) |
When omitted on offer |
v (version) |
undefined |
Absent means LWW initial-sync |
n (name) |
undefined |
Optional display name |
f (features) |
undefined |
Treated as no features advertised — no alias/streamed/datagram |
shs (supported hashes) |
undefined |
Absent means just the primary hash |
pv (protocolVersion) |
[1, 0] |
Absent ⇒ peer supports only the 2.0 sync wire-contract |
Encode:
ChannelMsg → applyOutboundAliasing → WireMessage
→ encodeWireMessage(wire) → Uint8Array
→ encodeBinaryFrame(complete(WIRE_VERSION, seq, payload)) → framed bytes
→ wrapCompleteMessage(framed) → transport payload
Decode:
transport payload → parseTransportPayload → { kind: "complete", data }
→ decodeBinaryFrame(data) → Frame<Uint8Array> { content: Complete }
→ decodeWireMessage(payload) → WireMessage
→ applyInboundAliasing → ChannelMsg
The first element of the JSON array is a 2-character string:
Position 0: version character ('1' = version 1, '2' = version 2, ...)
Position 1: type + hash (case-encoded)
'c' = complete, no hash
'C' = complete, with SHA-256 hash (digest in next element)
'f' = fragment, no hash
'F' = fragment, with SHA-256 hash (digest in next element)
TEXT_WIRE_VERSION is currently 2, so live prefixes are "2c" / "2f".
["2c", seq, <payload>]seq (a JSON number) follows the prefix. The payload is a native JSON value — an object for a single message, an array for a batch — embedded directly (not as a string within a string).
With hash:
["2C", "hexdigest", seq, <payload>]["2f", seq, index, total, totalSize, "chunk"]seq is the fragment group key (it took the slot the v1 per-fragment frameId occupied). The chunk is a JSON substring of the serialized payload. The receiver concatenates chunks sharing a seq in index order and JSON.parse the result.
With hash:
["2F", "hexdigest", seq, index, total, totalSize, "chunk"]Encode:
ChannelMsg → applyOutboundAliasing → WireMessage
→ encodeTextWireMessage(wire) → JSON-safe object
→ JSON.stringify(object) → payload string
→ encodeTextFrame(complete(TEXT_WIRE_VERSION, seq, payload)) → wire string
Decode:
wire string → decodeTextFrame → Frame<string> { content: Complete }
→ JSON.parse(payload) → JSON-safe object
→ decodeTextWireMessage(object) → WireMessage
→ applyInboundAliasing → ChannelMsg
Large payloads are split into JSON substring chunks:
Encode:
payload string → fragmentTextPayload(payload, maxChunkSize) → wire string[]
Each wire string is a complete, self-describing fragment frame (all sharing one seq):
["2f", 42, 0, 3, 1500, "{\"type\":\"offer\",\"docId\":\"doc"]
["2f", 42, 1, 3, 1500, "-1\",\"offerType\":\"snapshot\",\"pa"]
["2f", 42, 2, 3, 1500, "yload\":{\"encoding\":\"binary\"}}"]
Every fragment — binary or text — carries its full metadata. The group key (seq) lives in the frame header (binary offset 6 / text array element 1), not in this block:
| Field | Binary (v3) | Text |
|---|---|---|
| Index | 2 bytes uint16 big-endian | JSON number |
| Total | 2 bytes uint16 big-endian | JSON number |
| Total Size | 4 bytes uint32 big-endian | JSON number |
| Chunk | Raw bytes | JSON string (substring) |
There is no separate "fragment header" message. The FragmentCollector auto-creates tracking state when it first encounters a new seq.
The FragmentCollector<T> is parameterized on the chunk type:
- Binary:
FragmentCollector<Uint8Array>with byte concatenation - Text:
FragmentCollector<string>withchunks.join("")
Design: Functional Core / Imperative Shell
The pure decideFragment() function takes the current batch state and fragment metadata, returning a decision with zero side effects:
type FragmentDecision =
| { action: "create_and_accept" }
| { action: "accept" }
| { action: "complete" }
| { action: "reject_duplicate" }
| { action: "reject_invalid_index" }
| { action: "reject_total_mismatch" }
| { action: "reject_size_mismatch" }The FragmentCollector class (imperative shell) executes decisions by mutating state, managing timers, and enforcing limits.
| Parameter | Default | Purpose |
|---|---|---|
| Timeout | 10s | Abandon incomplete frames |
| Max concurrent frames | 32 | Limit tracking overhead |
| Max total size | 50MB / 50M chars | Memory cap (oldest frame evicted first) |
FragmentReassembler— binary wrapper. Parses transport prefixes and binary frame headers, delegates toFragmentCollector<Uint8Array>.TextReassembler— text wrapper. Parses JSON text frames, delegates toFragmentCollector<string>.
Both are thin wrappers (~80–100 lines) that handle format-specific parsing and delegate all collection logic to the generic collector.
Sender:
1. Encode message → complete binary frame (6-byte header + payload)
2. If frame size ≤ threshold: wrapCompleteMessage(frame) → send
3. If frame size > threshold:
a. Use the message's seq (the per-direction counter already advanced once for this send)
b. Split payload into chunks of maxChunkSize bytes
c. For each chunk: build fragment frame (header carries seq; metadata + chunk)
d. wrapFragment(fragmentFrame) → send each
Receiver:
1. parseTransportPayload(data)
2. If complete: decodeBinaryFrame(data) → Frame<Uint8Array> → decodeWireMessage → ChannelMsg
3. If fragment: decodeBinaryFrame(data) → Frame<Uint8Array> with Fragment content
→ collector.addFragment(frame.seq, index, total, totalSize, chunk)
→ eventually: complete data → decodeWireMessage → ChannelMsg
| Environment | Frame limit | Recommended threshold |
|---|---|---|
| AWS API Gateway | 128KB | 100KB (default) |
| Cloudflare Workers | 1MB | 500KB |
| Self-hosted (Bun, Node.js) | Unlimited | 0 (disabled) |
DocIds and schema hashes have explicit UTF-8 byte-length caps applied uniformly across binary and text pipelines:
| Identifier | Cap | Constant |
|---|---|---|
DocId (doc / d) |
512 UTF-8 bytes | DOC_ID_MAX_UTF8_BYTES |
Schema hash (sh) |
256 UTF-8 bytes | SCHEMA_HASH_MAX_UTF8_BYTES |
The unit is bytes, not codepoints — multi-byte UTF-8 characters count proportionally. Receivers reject overlong values at decode time with FrameDecodeError (binary) or TextFrameDecodeError (text) using code: "doc-id-too-long" / "schema-hash-too-long".
The establish message carries an optional WireFeatures map advertising what wire-format extensions a peer understands. Distinct from Capabilities in @kyneta/exchange (which describes substrate/schema bindings).
type WireFeaturesCompact = {
a?: boolean // alias — peer understands a/dx/sa/shx alias fields
s?: boolean // streamed — reserved for future QUIC streamed mode
d?: boolean // datagram — reserved for future QUIC datagram mode
}The text pipeline uses long names: { alias?, streamed?, datagram? }.
A peer that omits features (or omits any field) is treated as not advertising that feature. Old peers ignore the unknown f field harmlessly thanks to CBOR's "ignore unknown map fields" semantics.
The current default for v1 is { alias: true } (set in SessionModel.selfFeatures). Override via Synchronizer's selfFeatures parameter to opt out.
establish carries an optional pv: [major, minor] naming the sync wire-contract revision the peer implements — the revision of the message vocabulary, handshake choreography, and negotiation rules themselves. Distinct from WIRE_VERSION (frame encoding) and SyncMode (per-doc sync policy). Absent ⇒ [1, 0] (ratified); emitted only when non-default, so a 2.0 peer's establish is byte-identical to one without the field.
Wire evolution is split across three mechanisms by how peers must react to a difference:
| Tier | Carries | On difference |
|---|---|---|
WireFeatures (f) |
opt-in, negotiable capabilities | silent — mutual-AND; absent ⇒ graceful no-op (the empty set is always a safe common subset = the base contract) |
protocol minor |
non-negotiable backward-compatible refinements (clarifications, deprecations, behavioral tightenings) | warning — purely diagnostic; minor is backward-compatible by definition |
protocol major |
breaking change / base abandonment | error — no shared contract |
WireFeatures expresses any additive change (even a coordinated bundle, used iff mutual, else the base). The one thing it structurally cannot express is abandoning the base (its model is absent ⇒ false ⇒ fall back to base) — that is the sole residual job of pv's major. A single (major, minor) compared by a rule is therefore the correct shape; an array/set would only be needed for speaking two majors at once, which the discipline "graceful change is a feature; a major bump is an intentional partition" removes.
Computed identically on both peers (no extra round-trip), once per channel-establish:
- peer
major !== self.major→ incompatible (error). - same major, peer
minor !== self.minor→ backward-compatible refinement (warning). - equal (or absent) → silent.
At 2.0 a peer's self version is permanently [1, 0], so among 2.0 peers only the silent branch fires; the warning/error branches are reachable only against a future non-[1,0] peer. Detection is warn/error-only — it never gates: an incompatible peer remains observable and enters the sync graph (data simply will not converge). Actually refusing to sync is deferred to the release that ships a real major-2 peer.
The part of establish carrying id, y, and pv is a permanent meta-contract, invariant across all protocol revisions. Future revisions may extend establish or change other messages but may never break a peer's ability to parse another peer's identity + protocolVersion. The establish validator stays tolerant of unknown fields.
Variable-length string identifiers (doc, sh) repeat heavily in steady-state sync. The QUIC connection-ID / HPACK pattern — receiver-meaningful indices replacing globally-meaningful identifiers — applies cleanly: a per-channel-direction alias table assigned via present lets later messages reference docs and schemas by short integers.
presentdoc entries:a?: number— alias assignment for the docId. Always emitted (announcement is forward-compatible).sa?: number— alias assignment for the schema hash. Emitted on first reference.shx?: number— alias reference; replacesshon subsequent references whenmutualAliasis on.
interest/offer/dismiss:dx?: number— alias reference; replacesdocwhenmutualAliasis on.
Aliases are non-negative integers (CBOR major type 0). They have no fixed width: CBOR encodes the smallest fitting form (1 byte for 0–23, 2 bytes for 24–255, 3 bytes for 256–65,535, 5 bytes above). Practical upper bound is Number.MAX_SAFE_INTEGER; unreachable in any realistic channel lifetime.
| Operation | When to emit | Rationale |
|---|---|---|
Announce (a, sa in present) |
Always, regardless of peer's features.alias |
Old peers ignore unknown CBOR map fields; emitting unconditionally captures bytes-back-on-the-wire the moment both ends understand them. |
Use (dx, or shx with sh absent) |
Only if both peers advertised features.alias (mutualAlias) |
Old peers cannot resolve aliases. Emitting dx to a non-alias peer would trigger their decoder's "exactly one of doc/dx" invariant. |
A sender MAY emit dx (or shx-without-sh) only after sending a present containing the corresponding a (or sa) on the same channel and direction. On muxed transports (in-order delivery), this guarantees the receiver has seen the introduction before the use. Streamed-mode delivery (deferred) requires explicit ordering between announcement and use streams; aliases as a feature are scoped to muxed and (future) datagram modes only.
The alias transformer lives in @kyneta/wire's alias-table.ts as two pure functions:
applyOutboundAliasing(state, msg) → { state, wire }
applyInboundAliasing(state, wire) → { state, msg | error }Each transport's Channel holds a per-channel AliasState and calls the transformers adjacent to its codec invocation. The transformer absorbs feature capture from establish messages flowing through it — mutualAlias is derived state (per-feature AND of selfFeatures and peerFeatures), no external parameter.
The protocol reserves three delivery modes. Two are implementation-deferred in v1 but the protocol slots are reserved so future implementation can land without protocol changes.
| Mode | Identification of messages | Aliasing applies? | Implemented in v1? |
|---|---|---|---|
| Muxed | docId/alias inline in each message; one ordered byte stream | Yes (saves repeated bytes) | Yes — current behavior |
| Streamed | Stream identity is the docId context; offers on the stream omit doc identification | No (subsumed by stream-context) | Deferred |
| Datagram | docId/alias inline (each datagram is independent) | Yes (same machinery as muxed) | Deferred |
All current transports (Bridge, WebSocket, SSE, WebRTC, Unix-socket) operate in muxed mode: a single ordered byte stream per channel-direction carries every message, with each message naming its docId (or alias) inline. Fragmentation handles MTU-bounded transports; aliasing handles the repeated-identifier compression.
Reserved for QUIC/WebTransport adapters. Each frame would correspond to a QUIC stream: stream identity is the docId context; offers on the stream omit doc identification. No in-stream framing; FIN ends the frame; the fragmentation layer is inert. The WireFeatures.streamed flag advertises support; aliasing is unnecessary in streamed mode (stream-as-context subsumes it).
Reserved for QUIC datagrams. One MTU-bounded datagram per ephemeral snapshot; aliasing required for compact self-identification. Hand-off rule: a datagram referencing an unknown alias is silently dropped. The WireFeatures.datagram flag advertises support.
Discriminators are allocated sequentially from the next free value and classified by exact-value set-membership; numbering carries no semantics. There is no range/mask dispatch anywhere — VALID_MESSAGE_TYPES is a Set, the decode path is an exact-value switch, and isLifecycleMsg is string equality. vacant took the next free sync value (0x14); future datagram-only message types (e.g. EphemeralSnapshot, EphemeralAck) take the next free values (0x15+). A reserved range should be introduced only alongside range dispatch (define the range and its masking together).
When hash verification is added, it will be a frame trailer, not a header byte. (The v1 framing already removed the v0 hash-algorithm header byte for this reason.) Do not retrofit a header-byte solution.
The wire layer exports nextFrameSeq(prev) — a pure uint32 increment ((prev + 1) >>> 0) that advances the per-channel-direction frame seq at the transport-framing layer; the counter state is a field in PipelineState (nextSeq), advanced functionally by sendStep. The alias counter (per-channel-direction, monotonic, CBOR-major-type-0-encoded, unbounded JS number) is incremented the same way — a pure functional bump threaded through immutable AliasState. So the two counters are now the same shape and the same style (pure increments in threaded state); only the encoding differs: framing uses fixed-width uint32 with wrap; aliasing uses CBOR varint with no wrap.
v1 has no hash byte in the binary header. Hash verification, when added, will be a frame trailer — not a header byte. (See "Future hash rule" above.)
Text: Case-encoded in the prefix character. Lowercase (c, f) = no hash. Uppercase (C, F) = SHA-256 hex digest in the next array element. Reserved; not yet emitted.
Per-frame hashing enables streaming verification: the sender hashes and sends each frame independently. For fragments, this means per-chunk verification without waiting for reassembly.
The client sends a text "ping" frame at a configurable interval (default: 30s). The server responds with a text "pong". These are application-level text messages, not WebSocket protocol-level ping/pong frames.
After the WebSocket connection opens, the server sends a text "ready" frame to indicate it is prepared to receive protocol messages. The client waits for this signal before creating its channel and sending establish-request.
1. Client opens WebSocket → state: connecting
2. WebSocket open event fires → state: connected
3. Server sends text "ready" → state: ready
4. Client sends establish (binary frame)
5. Server sends establish (binary frame)
6. Protocol messages flow freely
Binary pipeline (WebSocket, WebRTC, Unix socket):
applyOutboundAliasing → encodeWireMessage (CBOR) → binary frame (6B header) → binary fragmentation → FragmentReassembler
└→ FragmentCollector<Uint8Array>
Text pipeline (SSE, HTTP):
applyOutboundAliasing → encodeTextWireMessage (JSON) → text frame ("Vx" prefix) → text fragmentation → TextReassembler
└→ FragmentCollector<string>
Shared:
AliasState ← per-channel-direction alias table (docId/schemaHash → integer)
applyOutboundAliasing / applyInboundAliasing ← single ChannelMsg ⇄ WireMessage transformer
Frame<T> type ← universal frame abstraction
FragmentCollector<T> ← generic reassembly (FC/IS design)
CollectorOps<T> ← injected { sizeOf, concatenate }
| File | Purpose |
|---|---|
src/frame-types.ts |
Frame<T>, Complete<T>, Fragment<T> types, constructors, type guards |
src/cbor.ts |
encodeWireMessage/decodeWireMessage — ChannelMsg ↔ CBOR-encoded WireMessage bytes |
src/json.ts |
encodeTextWireMessage/decodeTextWireMessage — ChannelMsg ↔ JSON-safe WireMessage objects |
src/constants.ts |
Wire version, header size, frame types, fragment sizes, identifier length caps |
src/alias-table.ts |
Pure ChannelMsg ⇄ WireMessage transformer with alias state and feature snapshotting |
src/wire-message-helpers.ts |
encodeWireMessage/decodeWireMessage (binary) and encodeTextWireMessage/decodeTextWireMessage (text) — operate on pre-formed WireMessage |
src/validate-identifiers.ts |
UTF-8 byte-length validation for DocIds and schema hashes |
src/wire-types.ts |
CBOR integer discriminators and compact field names |
src/frame.ts |
Binary frame encode/decode (encodeBinaryFrame, decodeBinaryFrame, convenience functions) |
src/text-frame.ts |
Text frame encode/decode (encodeTextFrame, decodeTextFrame, fragmentTextPayload) |
src/fragment-generic.ts |
SubstrateOps<T>/WireCodec<T> interfaces, fragmentGeneric<T>, and the pure nextFrameSeq seq helper |
src/fragment-collector.ts |
Generic FragmentCollector<T>, pure decideFragment, CollectorOps<T>, TimerAPI |
src/reassembler-generic.ts |
Reassembler<T> — substrate-agnostic wrapper around FragmentCollector<T> |
The Version column is the binary WIRE_VERSION byte (the text pipeline carries its own TEXT_WIRE_VERSION, currently 2). All entries are pre-release; the first stable release ships at WIRE_VERSION = 3.
| Version | Changes |
|---|---|
| 0–1 | Pre-release. Unified Frame<T> architecture. 7-byte binary header. Single-byte transport prefixes. 8-byte string frameId, 4-byte index/total. |
| 2 | Compact 6-byte binary header (version, type, payloadLength); no hash-algorithm byte (deferred to frame trailer). Numeric uint16 frameId / index / total; uint32 totalSize. Removed transport-prefix layer. DocId & schemaHash aliasing with a/dx/sa/shx fields. Wire features negotiation in establish (f map; backward-compat). Identifier length caps (DocId 512 UTF-8 bytes; schemaHash 256). Delivery-mode taxonomy (muxed, streamed-deferred, datagram-deferred). vacant message (0x14) — additive negative-ack to interest; old peers reject the unknown discriminator harmlessly (set-membership), so it is wire-backward-compatible. Establish protocolVersion (pv: [major, minor]; absent ⇒ [1,0]; emitted only when non-default) — names the sync wire-contract revision, compared by the three-tier rule (features silent / minor warning / major error); additive and byte-identical for [1,0] peers. |
| 3 | Current. 10-byte binary header — appends a uint32 seq (per-direction monotonic message id) at offset 6; payloadLength stays at offset 2. Text frames gain a seq element after the prefix (TEXT_WIRE_VERSION 1→2). The per-fragment frameId metadata field is removed — the header seq is the reassembly group key (binary fragment meta 10→8 bytes; text fragment arity unchanged). seq stamps every frame so each exchanged message is referenceable for debugging/tracing, surfaced via the opt-in WireOpts.onFrame hook. Breaking vs. v2 — gated by the version byte / text prefix; landed pre-2.0 so no shipped peers are affected. |