6.9 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,
typeNamerouting,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
PacketBaseprotobuf 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
heartbeatIntervalTimeseconds → sendHeartBeat - If no response within
heartbeatWaitTimeseconds →onDisconnected()→dispose() - Only the initiating side responds to HeartBeat echo (ping-pong prevention via
_waitingHeartbeatResponseflag)
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
1per connection - Increments by
1on 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 toaddListeneroraddRequestListenerresponseNonce > 0: response → matched to the pendingsendRequestbyresponseNonce- A response must also have the expected
typeNamefor the waitingsendRequest; mismatches are protocol errors - Both sides can be requester and responder simultaneously on the same connection
- For a given
typeNameon one connection, register it with eitheraddListeneroraddRequestListener, 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/ |
| 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.