# Proto 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, Go, and Kotlin 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 ```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`. Changing heartbeat timing semantics, response behavior, or disconnect rules is a breaking protocol candidate. Document the compatibility plan in [VERSIONING.md](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 | `descriptorForType.fullName` via `typeNameOf(m)` | | Swift | `String(describing: T.self)` — verify matches | | TypeScript | local `MessageType.typeName` value — verify matches | | Python | `descriptor.name` from `MessageClass.DESCRIPTOR` — verify matches | The current packet proto has no `package` declaration, so available implementations use simple names such as `TestData` and `HeartBeat`. If a future proto adds a `package`, language runtimes may derive fully qualified names, 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](VERSIONING.md). --- ## nonce - Starts at `1` per connection - Increments by `1` on every send call (including requests and responses) - Maximum nonce is `2,147,483,647` (`int32` max), the lowest common signed integer max across supported protocol fields - After issuing `2,147,483,647`, implementations reset the internal counter to `0`; the next emitted packet nonce is `1` - `0` is reserved and must not be emitted as `PacketBase.nonce`, because `responseNonce == 0` means "not a response" - 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 │ │ 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 | Available | `kotlin/` | | Swift | Planned | `swift/` | | Go | Available | `go/` | | TypeScript | Available | `typescript/` | | Python | Available | `python/` | --- ## Proto Source `proto/message_common.proto` is the canonical packet definition. All language implementations generate bindings from it. Language-specific generation options such as `go_package` and Java/Kotlin package options are kept in each language's own proto copy under `go/packets/` and `kotlin/src/main/proto/`. Keep those copies' message schemas in sync with `proto/message_common.proto` before regenerating language bindings.