- Add WebSocket binary frame support to protocol specification - Update README and PROTOCOL.md to reflect dual TCP/WebSocket transport - Add Go implementation with TCP, WebSocket, and heartbeat support - Include .claude settings configuration
7.8 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.
Scope
This protocol intentionally defines only the transport-level contract shared across implementations.
- In scope: TCP/WebSocket framing, protobuf payload transport,
typeNamerouting,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 asuint32. - Value: byte length of the following
PacketBaseprotobuf payload - Value of
0: reserved / no-op, receiver clears buffer - Implementations may apply safety limits to payload size. The current Go implementation rejects 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
heartbeatIntervalTimeseconds → sendHeartBeat - If no response within
heartbeatWaitTimeseconds →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.
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.
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 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.