proto-socket/PROTOCOL.md
toki c384516b47 feat: implement new communication patterns and add cross-test support
- Add Transport class for unified communication layer
- Implement new communicator patterns
- Add VERSIONING.md for version management
- Add crosstest files for Dart/Go integration testing
- Update protocol documentation
- Add new skills and templates for AI-assisted development
- Various bug fixes and improvements to Dart implementation
2026-04-12 07:53:31 +09:00

8.4 KiB

Toki Socket Protocol

Binary TCP and WebSocket protocol using Protocol Buffers for message framing and type-based routing. Designed for bidirectional, heterogeneous communication across languages and platforms.

Current protocol version: 0.1


Scope

This protocol intentionally defines only the transport-level contract shared across implementations.

  • In scope: TCP/WebSocket framing, protobuf payload transport, typeName routing, nonce, responseNonce, TLS/WSS transport variants, and heartbeat
  • Out of scope: authentication, session lifecycle, agent semantics, chat semantics, game rules, room logic, and other application-specific behavior
  • Higher-level concerns should be implemented as messages and conventions on top of this protocol, not inside the protocol core

Transport별 Wire Format

TCP / SSL+TCP

┌──────────────────────────────────────────┐
│  Header  (4 bytes, big-endian integer)   │  ← PacketBase payload length
├──────────────────────────────────────────┤
│  PacketBase  (protobuf, N bytes)         │  ← typeName + nonce + data
└──────────────────────────────────────────┘

Header

  • 4 bytes, big-endian positive integer. Dart reads/writes it as int32; Go reads/writes the same 4 bytes as uint32.
  • Value: byte length of the following PacketBase protobuf payload
  • Value of 0: reserved / no-op, receiver clears buffer
  • Implementations may apply safety limits to payload size. The current Dart and Go implementations reject TCP packets larger than 64 MiB.

WebSocket / WSS

┌──────────────────────────────────────────┐
│  PacketBase  (protobuf, N bytes)         │  ← typeName + nonce + data
└──────────────────────────────────────────┘
  • Length 헤더 없음 — WebSocket 프로토콜이 메시지 경계를 보장한다
  • Binary frame 사용 (text frame 아님)
  • 연결: ws://host:port/path (plain) / wss://host:port/path (TLS)

PacketBase

message PacketBase {
  string typeName      = 1;   // protobuf message name used for routing
  int32  nonce         = 2;   // monotonically increasing per-connection counter
  bytes  data          = 3;   // serialized inner message bytes
  int32  responseNonce = 4;   // > 0: this packet is a response to request with that nonce
}
Field Description
typeName Protobuf message name used as the language-agnostic routing key
nonce Starts at 1, increments by 1 per send call per connection
data Protobuf-serialized bytes of the inner message
responseNonce 0 for regular messages. Set to the request's nonce when sending a response

Built-in Messages

HeartBeat

message HeartBeat {}

Automatically handled by the framework. Applications do not need to register or handle this manually.


Heartbeat Lifecycle

Side A                                    Side B
  │                                          │
  │── (no activity for intervalTime) ──────▶│
  │        send(HeartBeat)                   │
  │                                          │── onHeartBeat() called
  │                                          │   send(HeartBeat) [response]
  │◀─────────────────────────────────────────│
  │  onHeartBeat() called                    │
  │  _waitingHeartbeatResponse = false       │
  │  timer reset                             │
  • After any received message → heartbeat interval timer resets
  • If no data received within heartbeatIntervalTime seconds → send HeartBeat
  • If no response within heartbeatWaitTime seconds → onDisconnected()dispose()
  • A side that is already waiting for a heartbeat response consumes the echo and does not send another heartbeat. A side that is not waiting replies with HeartBeat.

Changing heartbeat timing semantics, response behavior, or disconnect rules is a breaking protocol candidate. Document the compatibility plan in VERSIONING.md before changing it.


typeName Convention

Uses the protobuf message name on the send side. On the receive side, use the same value as the registration key.

Language Registration key
Dart T.toString() (equals qualified name for top-level proto messages)
Go string(proto.MessageName(m)) via TypeNameOf(m)
C# typeof(T).Name — verify matches proto qualified name
Kotlin T::class.simpleName — verify matches
Swift String(describing: T.self) — verify matches
Python descriptor.name from MessageClass.DESCRIPTOR — verify matches
Rust M::default().descriptor_dyn().name().to_string() (protobuf crate) — verify matches

The current packet proto has no package declaration, so Dart and Go both use simple names such as TestData and HeartBeat. If a future proto adds a package, proto.MessageName may become fully qualified, and all implementations must use the same value.

Important: Verify typeName consistency across languages before connecting heterogeneous clients.

Changing the typeName derivation rule is a breaking protocol change unless all released implementations keep a compatibility path. See VERSIONING.md.


nonce

  • Starts at 1 per connection
  • Increments by 1 on every send call (including requests and responses)
  • Used for request-response correlation via responseNonce

Changing nonce or responseNonce semantics is a breaking protocol change unless older peers can continue to correlate requests and responses correctly.


Request-Response Pattern

Fire-and-forget (send) and request-response (sendRequest) coexist on the same connection.

Requester                                 Responder
  │                                          │
  │── PacketBase { nonce=5, responseNonce=0, │
  │                typeName="GetUser",       │
  │                data=... } ─────────────▶│
  │                                          │── addRequestListener<GetUser, UserData>
  │                                          │   handler called → returns UserData
  │◀─ PacketBase { nonce=12, responseNonce=5,│
  │                typeName="UserData",      │
  │                data=... } ──────────────│
  │  sendRequest Future completes            │

Rules:

  • responseNonce == 0: regular message or outgoing request → routed to addListener or addRequestListener
  • responseNonce > 0: response → matched to the pending sendRequest by responseNonce
  • A response must also have the expected typeName for the waiting sendRequest; mismatches are protocol errors
  • Both sides can be requester and responder simultaneously on the same connection
  • For a given typeName on one connection, register it with either addListener or addRequestListener, not both. Mixed registration is ambiguous and rejected by the Dart and Go implementations.

Example Packet (hex)

Sending HeartBeat {}:

00 00 00 0B          ← header: PacketBase length = 11 bytes
0A 09 48 65 61 72 74 42 65 61 74  ← PacketBase { typeName: "HeartBeat" }

Multi-language Implementations

Language Status Path
Dart Available dart/
C# (Unity) Planned csharp/
Kotlin Planned kotlin/
Swift Planned swift/
Go Available go/
Python Planned python/
Rust Planned rust/

Proto Source

dart/lib/src/packets/message_common.proto is the canonical packet definition. All language implementations must keep the same message schema and generate bindings from it.

The Go implementation keeps a copy at go/packets/message_common.proto because Go generation needs option go_package. Keep the message fields in sync with the canonical Dart proto before regenerating go/packets/message_common.pb.go.