proto-socket/PROTOCOL.md
2026-04-08 07:59:52 +09:00

7 KiB

Toki Socket Protocol

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


Scope

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

  • In scope: framing, protobuf payload transport, typeName routing, nonce, responseNonce, 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 int32)     │  ← PacketBase payload length
├──────────────────────────────────────────┤
│  PacketBase  (protobuf, N bytes)         │  ← typeName + nonce + data
└──────────────────────────────────────────┘

Header

  • 4 bytes, big-endian signed int32
  • Value: byte length of the following PacketBase protobuf payload
  • Value of 0: reserved / no-op, receiver clears buffer

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;   // fully-qualified protobuf message name
  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 GeneratedMessage.info_.qualifiedMessageName — 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()
  • Only the initiating side responds to HeartBeat echo (ping-pong prevention via _waitingHeartbeatResponse flag)

typeName Convention

Uses GeneratedMessage.info_.qualifiedMessageName 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)
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

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


nonce

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

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 implementation.

Example Packet (hex)

Sending HeartBeat {}:

00 00 00 05          ← header: PacketBase length = 5 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 Planned 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 generate bindings from this file.