- 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
185 lines
7.8 KiB
Markdown
185 lines
7.8 KiB
Markdown
# 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, `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 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
|
|
```protobuf
|
|
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
|
|
```protobuf
|
|
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`.
|
|
|
|
---
|
|
|
|
## 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 `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 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`.
|