commit 240239682c68f5f3ae2591cb5eba4333ef5f6f17 Author: toki Date: Sun Apr 5 13:44:04 2026 +0900 Initial commit: Dart implementation of toki-socket protocol - Protobuf-based binary TCP socket library - ProtobufClient / ProtobufServer abstract classes with heartbeat - Type-based message routing via Communicator - 4-byte big-endian length-prefixed framing protocol - 8 integration tests (all passing) - PROTOCOL.md wire format specification - VSCode launch.json for test runner Co-Authored-By: Claude Sonnet 4.6 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e7ec269 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# ── Dart ────────────────────────────────────────── +dart/.dart_tool/ +dart/.pub-cache/ +dart/build/ +dart/pubspec.lock +dart/doc/ + +# Dart generated files (proto .pb.dart are intentionally kept) +dart/**/*.g.dart + +# ── C# / Unity (future) ─────────────────────────── +csharp/bin/ +csharp/obj/ +csharp/*.user +csharp/.vs/ +*.csproj.user + +# ── Kotlin / Android (future) ───────────────────── +kotlin/.gradle/ +kotlin/build/ +kotlin/local.properties +kotlin/.idea/ + +# ── Swift (future) ──────────────────────────────── +swift/.build/ +swift/*.xcodeproj/ +swift/*.xcworkspace/ +swift/Packages.resolved + +# ── IDE ─────────────────────────────────────────── +.idea/ +*.iml +.vscode/settings.json +.vscode/tasks.json + +# ── OS ──────────────────────────────────────────── +.DS_Store +Thumbs.db +Desktop.ini diff --git a/PROTOCOL.md b/PROTOCOL.md new file mode 100644 index 0000000..6959398 --- /dev/null +++ b/PROTOCOL.md @@ -0,0 +1,121 @@ +# 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. + +--- + +## Wire Format + +``` +┌──────────────────────────────────────────┐ +│ 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 + +### PacketBase +```protobuf +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 +} +``` + +| 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 | + +--- + +## 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()` +- 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 | + +**Important**: Verify typeName consistency across languages before connecting heterogeneous clients. + +--- + +## nonce + +- Starts at `1` per connection +- Increments by `1` on every `send()` call +- Currently used as a message ID; request-response correlation can be built on top + +--- + +## 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/` | + +--- + +## Proto Source + +`dart/lib/src/packets/message_common.proto` is the canonical packet definition. +All language implementations must generate bindings from this file. diff --git a/README.md b/README.md new file mode 100644 index 0000000..5458965 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# Toki Socket + +Binary TCP socket protocol library for bidirectional, heterogeneous communication across languages and platforms. + +Built on Protocol Buffers with a fixed 4-byte length-prefixed framing, type-based message routing, and built-in heartbeat. + +--- + +## Protocol + +See [PROTOCOL.md](PROTOCOL.md) for the full wire format specification. + +``` +[4-byte big-endian length] [PacketBase protobuf bytes] +``` + +--- + +## Implementations + +| Language | Status | Path | Use case | +|----------|--------|------|----------| +| Dart | Available | [dart/](dart/) | Flutter, Dart server | +| C# | Planned | `csharp/` | Unity, .NET | +| Kotlin | Planned | `kotlin/` | Android | +| Swift | Planned | `swift/` | iOS, macOS | + +--- + +## Quick Start (Dart) + +```dart +import 'package:toki_socket/toki_socket.dart'; + +// 1. Define your message in message_common.proto, generate with protoc + +// 2. Implement a client +class MyClient extends ProtobufClient { + MyClient(Socket socket) : super(socket, 30, 10, { + MyMessage.getDefault().info_.qualifiedMessageName: MyMessage.fromBuffer, + }); +} + +// 3. Implement a server +class MyServer extends ProtobufServer { + MyServer() : super('0.0.0.0', 9090, (socket) => MyClient(socket)); + + @override + void onClientConnected(ProtobufClient client) { + client.addListener((msg) => print('Received: ${msg}')); + } +} + +// 4. Start +final server = MyServer(); +await server.start(); + +// 5. Connect and send +final socket = await Socket.connect('localhost', 9090); +final client = MyClient(socket); +await client.send(MyMessage()..text = 'hello'); +``` + +--- + +## Adding Message Types + +Edit `dart/lib/src/packets/message_common.proto` and regenerate: + +```bash +cd dart +dart pub global activate protoc_plugin +protoc --dart_out=lib/src/packets lib/src/packets/message_common.proto +``` + +--- + +## Running Tests + +```bash +cd dart +dart pub get +dart test +``` diff --git a/dart/.vscode/launch.json b/dart/.vscode/launch.json new file mode 100644 index 0000000..a8c58ff --- /dev/null +++ b/dart/.vscode/launch.json @@ -0,0 +1,24 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run All Tests", + "type": "dart", + "request": "launch", + "program": "test/socket_test.dart" + }, + { + "name": "Debug All Tests", + "type": "dart", + "request": "launch", + "program": "test/socket_test.dart", + "args": ["--reporter", "expanded"] + }, + { + "name": "Run Test (Current File)", + "type": "dart", + "request": "launch", + "program": "${file}" + } + ] +} diff --git a/dart/analysis_options.yaml b/dart/analysis_options.yaml new file mode 100644 index 0000000..8b9cc59 --- /dev/null +++ b/dart/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:lints/recommended.yaml + +analyzer: + errors: + avoid_print: ignore diff --git a/dart/lib/src/communicator.dart b/dart/lib/src/communicator.dart new file mode 100644 index 0000000..4d2c3f8 --- /dev/null +++ b/dart/lib/src/communicator.dart @@ -0,0 +1,73 @@ +// ignore_for_file: prefer_final_fields + +import 'package:protobuf/protobuf.dart'; + +abstract class Communicator { + Map _handlerDic = {}; + late Map)> _instanceGenerator; + + Communicator(); + + void initialize(Map)> instanceGenerator) { + _instanceGenerator = instanceGenerator; + } + + T Function(List) getGenerator(String type) { + if (!_instanceGenerator.containsKey(type)) { + throw Exception('Must set protobuf packet creator before use it. Type: ${(T).toString()}'); + } + return _instanceGenerator[type] as T Function(List); + } + + Future send(T data); + + void onReceivedData(String typeName, List data) { + if (_handlerDic.containsKey(typeName)) { + _handlerDic[typeName]?.onMessage(data); + } + } + + void addListener(void Function(T) listener) { + var type = T.toString(); + if (!_handlerDic.containsKey(type)) { + _handlerDic[type] = DataHandler(getGenerator(type)); + } + var handler = _handlerDic[type] as DataHandler; + handler.addListener(listener); + } + + void removeListener(void Function(T) listener) { + var type = T.toString(); + if (_handlerDic.containsKey(type)) { + var handler = _handlerDic[type] as DataHandler; + handler.removeListener(listener); + } + } +} + +abstract class IDataHandler { + void onMessage(List data); +} + +class DataHandler implements IDataHandler { + T Function(List) _generator; + List _listeners = []; + + DataHandler(this._generator); + + @override + void onMessage(List data) { + for (var listener in _listeners) { + listener.call(_generator(data)); + } + } + + void addListener(void Function(T) handler) { + removeListener(handler); // 중복 리스너 불허 + _listeners.add(handler); + } + + void removeListener(void Function(T) handler) { + _listeners.remove(handler); + } +} diff --git a/dart/lib/src/packets/message_common.pb.dart b/dart/lib/src/packets/message_common.pb.dart new file mode 100644 index 0000000..059eb0c --- /dev/null +++ b/dart/lib/src/packets/message_common.pb.dart @@ -0,0 +1,192 @@ +// +// Generated code. Do not modify. +// source: message_common.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import + +import 'dart:core' as $core; + +import 'package:protobuf/protobuf.dart' as $pb; + +class PacketBase extends $pb.GeneratedMessage { + factory PacketBase({ + $core.String? typeName, + $core.int? nonce, + $core.List<$core.int>? data, + }) { + final $result = create(); + if (typeName != null) { + $result.typeName = typeName; + } + if (nonce != null) { + $result.nonce = nonce; + } + if (data != null) { + $result.data = data; + } + return $result; + } + PacketBase._() : super(); + factory PacketBase.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory PacketBase.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'PacketBase', createEmptyInstance: create) + ..aOS(1, _omitFieldNames ? '' : 'typeName', protoName: 'typeName') + ..a<$core.int>(2, _omitFieldNames ? '' : 'nonce', $pb.PbFieldType.O3) + ..a<$core.List<$core.int>>(3, _omitFieldNames ? '' : 'data', $pb.PbFieldType.OY) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + PacketBase clone() => PacketBase()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + PacketBase copyWith(void Function(PacketBase) updates) => super.copyWith((message) => updates(message as PacketBase)) as PacketBase; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static PacketBase create() => PacketBase._(); + PacketBase createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static PacketBase getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static PacketBase? _defaultInstance; + + @$pb.TagNumber(1) + $core.String get typeName => $_getSZ(0); + @$pb.TagNumber(1) + set typeName($core.String v) { $_setString(0, v); } + @$pb.TagNumber(1) + $core.bool hasTypeName() => $_has(0); + @$pb.TagNumber(1) + void clearTypeName() => clearField(1); + + @$pb.TagNumber(2) + $core.int get nonce => $_getIZ(1); + @$pb.TagNumber(2) + set nonce($core.int v) { $_setSignedInt32(1, v); } + @$pb.TagNumber(2) + $core.bool hasNonce() => $_has(1); + @$pb.TagNumber(2) + void clearNonce() => clearField(2); + + @$pb.TagNumber(3) + $core.List<$core.int> get data => $_getN(2); + @$pb.TagNumber(3) + set data($core.List<$core.int> v) { $_setBytes(2, v); } + @$pb.TagNumber(3) + $core.bool hasData() => $_has(2); + @$pb.TagNumber(3) + void clearData() => clearField(3); +} + +class HeartBeat extends $pb.GeneratedMessage { + factory HeartBeat() => create(); + HeartBeat._() : super(); + factory HeartBeat.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory HeartBeat.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'HeartBeat', createEmptyInstance: create) + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + HeartBeat clone() => HeartBeat()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + HeartBeat copyWith(void Function(HeartBeat) updates) => super.copyWith((message) => updates(message as HeartBeat)) as HeartBeat; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static HeartBeat create() => HeartBeat._(); + HeartBeat createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static HeartBeat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static HeartBeat? _defaultInstance; +} + +class TestData extends $pb.GeneratedMessage { + factory TestData({ + $core.int? index, + $core.String? message, + }) { + final $result = create(); + if (index != null) { + $result.index = index; + } + if (message != null) { + $result.message = message; + } + return $result; + } + TestData._() : super(); + factory TestData.fromBuffer($core.List<$core.int> i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromBuffer(i, r); + factory TestData.fromJson($core.String i, [$pb.ExtensionRegistry r = $pb.ExtensionRegistry.EMPTY]) => create()..mergeFromJson(i, r); + + static final $pb.BuilderInfo _i = $pb.BuilderInfo(_omitMessageNames ? '' : 'TestData', createEmptyInstance: create) + ..a<$core.int>(1, _omitFieldNames ? '' : 'index', $pb.PbFieldType.O3) + ..aOS(2, _omitFieldNames ? '' : 'message') + ..hasRequiredFields = false + ; + + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.deepCopy] instead. ' + 'Will be removed in next major version') + TestData clone() => TestData()..mergeFromMessage(this); + @$core.Deprecated( + 'Using this can add significant overhead to your binary. ' + 'Use [GeneratedMessageGenericExtensions.rebuild] instead. ' + 'Will be removed in next major version') + TestData copyWith(void Function(TestData) updates) => super.copyWith((message) => updates(message as TestData)) as TestData; + + $pb.BuilderInfo get info_ => _i; + + @$core.pragma('dart2js:noInline') + static TestData create() => TestData._(); + TestData createEmptyInstance() => create(); + static $pb.PbList createRepeated() => $pb.PbList(); + @$core.pragma('dart2js:noInline') + static TestData getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor(create); + static TestData? _defaultInstance; + + @$pb.TagNumber(1) + $core.int get index => $_getIZ(0); + @$pb.TagNumber(1) + set index($core.int v) { $_setSignedInt32(0, v); } + @$pb.TagNumber(1) + $core.bool hasIndex() => $_has(0); + @$pb.TagNumber(1) + void clearIndex() => clearField(1); + + @$pb.TagNumber(2) + $core.String get message => $_getSZ(1); + @$pb.TagNumber(2) + set message($core.String v) { $_setString(1, v); } + @$pb.TagNumber(2) + $core.bool hasMessage() => $_has(1); + @$pb.TagNumber(2) + void clearMessage() => clearField(2); +} + + +const _omitFieldNames = $core.bool.fromEnvironment('protobuf.omit_field_names'); +const _omitMessageNames = $core.bool.fromEnvironment('protobuf.omit_message_names'); diff --git a/dart/lib/src/packets/message_common.pbenum.dart b/dart/lib/src/packets/message_common.pbenum.dart new file mode 100644 index 0000000..e8eea36 --- /dev/null +++ b/dart/lib/src/packets/message_common.pbenum.dart @@ -0,0 +1,11 @@ +// +// Generated code. Do not modify. +// source: message_common.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import + diff --git a/dart/lib/src/packets/message_common.pbjson.dart b/dart/lib/src/packets/message_common.pbjson.dart new file mode 100644 index 0000000..6687246 --- /dev/null +++ b/dart/lib/src/packets/message_common.pbjson.dart @@ -0,0 +1,53 @@ +// +// Generated code. Do not modify. +// source: message_common.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import + +import 'dart:convert' as $convert; +import 'dart:core' as $core; +import 'dart:typed_data' as $typed_data; + +@$core.Deprecated('Use packetBaseDescriptor instead') +const PacketBase$json = { + '1': 'PacketBase', + '2': [ + {'1': 'typeName', '3': 1, '4': 1, '5': 9, '10': 'typeName'}, + {'1': 'nonce', '3': 2, '4': 1, '5': 5, '10': 'nonce'}, + {'1': 'data', '3': 3, '4': 1, '5': 12, '10': 'data'}, + ], +}; + +/// Descriptor for `PacketBase`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List packetBaseDescriptor = $convert.base64Decode( + 'CgpQYWNrZXRCYXNlEhoKCHR5cGVOYW1lGAEgASgJUgh0eXBlTmFtZRIUCgVub25jZRgCIAEoBV' + 'IFbm9uY2USEgoEZGF0YRgDIAEoDFIEZGF0YQ=='); + +@$core.Deprecated('Use heartBeatDescriptor instead') +const HeartBeat$json = { + '1': 'HeartBeat', +}; + +/// Descriptor for `HeartBeat`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List heartBeatDescriptor = $convert.base64Decode( + 'CglIZWFydEJlYXQ='); + +@$core.Deprecated('Use testDataDescriptor instead') +const TestData$json = { + '1': 'TestData', + '2': [ + {'1': 'index', '3': 1, '4': 1, '5': 5, '10': 'index'}, + {'1': 'message', '3': 2, '4': 1, '5': 9, '10': 'message'}, + ], +}; + +/// Descriptor for `TestData`. Decode as a `google.protobuf.DescriptorProto`. +final $typed_data.Uint8List testDataDescriptor = $convert.base64Decode( + 'CghUZXN0RGF0YRIUCgVpbmRleBgBIAEoBVIFaW5kZXgSGAoHbWVzc2FnZRgCIAEoCVIHbWVzc2' + 'FnZQ=='); + diff --git a/dart/lib/src/packets/message_common.pbserver.dart b/dart/lib/src/packets/message_common.pbserver.dart new file mode 100644 index 0000000..34528ee --- /dev/null +++ b/dart/lib/src/packets/message_common.pbserver.dart @@ -0,0 +1,14 @@ +// +// Generated code. Do not modify. +// source: message_common.proto +// +// @dart = 2.12 + +// ignore_for_file: annotate_overrides, camel_case_types, comment_references +// ignore_for_file: constant_identifier_names +// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes +// ignore_for_file: non_constant_identifier_names, prefer_final_fields +// ignore_for_file: unnecessary_import, unnecessary_this, unused_import + +export 'message_common.pb.dart'; + diff --git a/dart/lib/src/packets/message_common.proto b/dart/lib/src/packets/message_common.proto new file mode 100644 index 0000000..4efcae2 --- /dev/null +++ b/dart/lib/src/packets/message_common.proto @@ -0,0 +1,14 @@ +syntax = "proto3"; + +message PacketBase { + string typeName = 1; + int32 nonce = 2; + bytes data = 3; +} + +message HeartBeat {} + +message TestData { + int32 index = 1; + string message = 2; +} \ No newline at end of file diff --git a/dart/lib/src/protobuf_client.dart b/dart/lib/src/protobuf_client.dart new file mode 100644 index 0000000..2dac763 --- /dev/null +++ b/dart/lib/src/protobuf_client.dart @@ -0,0 +1,165 @@ +// ignore_for_file: avoid_init_to_null, prefer_final_fields, avoid_print + +import 'dart:io'; +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:protobuf/protobuf.dart'; +import 'communicator.dart'; +import 'response_checker.dart'; +import 'packets/message_common.pb.dart'; + +abstract class ProtobufClient extends Communicator { + final int _headerSize = 4; + late int _heartbeatIntervalTime; + late int _heartbeatWaitTime; + final Socket _socket; + late ResponseChecker? _heartbeatChecker = null; + int? _length = null; + bool _isAlive = false; + bool _waitingHeartbeatResponse = false; + int _nonce = 0; + late List _arrivedData = []; + List _onDisconnectListenerList = []; + + ProtobufClient( + this._socket, + this._heartbeatIntervalTime, + this._heartbeatWaitTime, + Map)> parserMap) { + print('Connected New Client'); + _isAlive = true; + parserMap.addAll({ + (HeartBeat).toString(): HeartBeat.fromBuffer, + }); + super.initialize(parserMap); + _socket.listen(onData, onError: onError).asFuture().then(onDisconnected); + addListener(onHeartBeat); + sendHeartBeat(); + } + + void sendHeartBeat() { + if (_isAlive) { + _heartbeatChecker?.responded(); + _heartbeatChecker = + ResponseChecker.second(this, _heartbeatIntervalTime, (client) { + if (_isAlive) { + _waitingHeartbeatResponse = true; + send(HeartBeat()); + _heartbeatChecker = + ResponseChecker.second(this, _heartbeatWaitTime, (client) { + if (_isAlive) { + onDisconnected(null); + dispose(); + } + }); + } + }); + } + } + + void onHeartBeat(HeartBeat data) { + if (_waitingHeartbeatResponse) { + _waitingHeartbeatResponse = false; + } else { + send(HeartBeat()); + } + } + + void addDisconnectListener(void Function(ProtobufClient) handler) { + if (!_onDisconnectListenerList.contains(handler)) { + _onDisconnectListenerList.add(handler); + } + } + + void removeDisconnectListener(void Function(ProtobufClient) handler) { + if (_onDisconnectListenerList.contains(handler)) { + _onDisconnectListenerList.remove(handler); + } + } + + void onDisconnected(dynamic data) { + if (_isAlive) { + for (var item in _onDisconnectListenerList) { + item.call(this); + } + _onDisconnectListenerList.clear(); + } + } + + void onError(dynamic e) { + print('=========> onError: $e'); + } + + void onData(Uint8List data) { + try { + _arrivedData.addAll(data); + _parsing(); + } on Exception catch (e) { + print(e); + } + } + + void _parsing() { + if (_length == null) { + if (_arrivedData.length >= _headerSize) { + _length = Uint8List.fromList(_arrivedData.sublist(0, _headerSize)) + .buffer + .asByteData() + .getInt32(0); + if (_length == 0) { + _length = null; + _arrivedData.clear(); + return; + } + _parsing(); + } + } else { + if (_arrivedData.length >= _length! + _headerSize) { + final packetBytes = List.from( + _arrivedData.sublist(_headerSize, _headerSize + _length!)); + _arrivedData = _arrivedData.sublist(_headerSize + _length!); + _length = null; + final common = PacketBase.fromBuffer(packetBytes); + onReceivedData(common.typeName, common.data); + sendHeartBeat(); + _parsing(); + } + } + } + + @override + Future send(T data) async { + if (_isAlive) { + try { + List packet = []; + var base = PacketBase(); + base.typeName = data.info_.qualifiedMessageName; + base.nonce = ++_nonce; + base.data = data.writeToBuffer(); + var baseBytes = base.writeToBuffer(); + int length = baseBytes.length; + var header = Uint8List(_headerSize) + ..buffer.asByteData().setInt32(0, length); + packet.addAll(header); + packet.addAll(baseBytes); + _socket.add(packet); + await _socket.flush(); + } catch (e) { + onDisconnected(null); + } + } + return Future.value(); + } + + void dispose() async { + if (_isAlive) { + _isAlive = false; + _heartbeatChecker?.responded(); + try { + await _socket.close(); + _socket.destroy(); + } catch (e) {} + } + } +} diff --git a/dart/lib/src/protobuf_server.dart b/dart/lib/src/protobuf_server.dart new file mode 100644 index 0000000..4602ffd --- /dev/null +++ b/dart/lib/src/protobuf_server.dart @@ -0,0 +1,56 @@ +// ignore_for_file: avoid_print, prefer_final_fields + +import 'dart:io'; +import 'package:protobuf/protobuf.dart'; +import 'packets/message_common.pb.dart'; +import 'protobuf_client.dart'; + +abstract class ProtobufServer { + List _clientList = []; + bool _started = false; + late ServerSocket? _server; + final String _host; + final int _port; + final ProtobufClient Function(Socket) _createNewClient; + + bool get started => _started; + + ProtobufServer(this._host, this._port, this._createNewClient); + + Future start() async { + _server = await ServerSocket.bind(_host, _port); + _started = true; + _server?.listen((Socket socket) { + var client = _createNewClient(socket); + client.addDisconnectListener(onDisconnectedClient); + client.addListener((_) => onClientHeartBeat(client)); + _clientList.add(client); + onClientConnected(client); + }); + return Future.value(); + } + + // implement in child + void onClientConnected(ProtobufClient client); + + void onClientHeartBeat(ProtobufClient client) {} + + void onDisconnectedClient(ProtobufClient client) { + _clientList.remove(client); + client.dispose(); + print('Client disconnected'); + } + + Future broadcast(T data) async { + for (var client in _clientList) { + await client.send(data); + } + return Future.value(); + } + + Future stop() async { + await _server?.close(); + _started = false; + return Future.value(); + } +} diff --git a/dart/lib/src/response_checker.dart b/dart/lib/src/response_checker.dart new file mode 100644 index 0000000..a07e985 --- /dev/null +++ b/dart/lib/src/response_checker.dart @@ -0,0 +1,31 @@ +import 'dart:async'; + +class ResponseChecker { + final T _responser; + late int _time; + late Timer? _timer = null; + late void Function(T) _notResponseListener; + + T get responser => _responser; + + ResponseChecker(this._responser, this._time, this._notResponseListener) { + _startTimer(); + } + + ResponseChecker.second(this._responser, int time, this._notResponseListener) { + _time = time * 1000; + _startTimer(); + } + + void _startTimer() { + _timer = Timer(Duration(milliseconds: _time), () { + _notResponseListener(_responser); + }); + } + + T responded() { + _timer?.cancel(); + _timer = null; + return _responser; + } +} diff --git a/dart/lib/toki_socket.dart b/dart/lib/toki_socket.dart new file mode 100644 index 0000000..54d9bbb --- /dev/null +++ b/dart/lib/toki_socket.dart @@ -0,0 +1,7 @@ +library toki_socket; + +export 'src/communicator.dart'; +export 'src/protobuf_client.dart'; +export 'src/protobuf_server.dart'; +export 'src/response_checker.dart'; +export 'src/packets/message_common.pb.dart'; diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml new file mode 100644 index 0000000..aba365e --- /dev/null +++ b/dart/pubspec.yaml @@ -0,0 +1,14 @@ +name: toki_socket +description: Multi-language standard socket protocol library. Dart implementation. +version: 0.1.0 +publish_to: none + +environment: + sdk: '>=3.2.3 <4.0.0' + +dependencies: + protobuf: ^3.1.0 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/dart/test/socket_test.dart b/dart/test/socket_test.dart new file mode 100644 index 0000000..69ab138 --- /dev/null +++ b/dart/test/socket_test.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:test/test.dart'; +import 'package:toki_socket/toki_socket.dart'; + +const _testPort = 19090; +const _host = 'localhost'; + +// ── 테스트 전용 픽스처 ────────────────────────────────────────── + +class _TestClient extends ProtobufClient { + _TestClient(Socket socket) + : super(socket, 5, 3, { + TestData.getDefault().info_.qualifiedMessageName: TestData.fromBuffer, + }); +} + +class _TestServer extends ProtobufServer { + final receivedMessages = []; + final connectedClients = []; + + _TestServer() : super(_host, _testPort, (socket) => _TestClient(socket)); + + @override + void onClientConnected(ProtobufClient client) { + connectedClients.add(client); + client.addListener((data) => receivedMessages.add(data)); + } +} + +// ── 테스트 ────────────────────────────────────────────────────── + +void main() { + group('ProtobufServer', () { + late _TestServer server; + + setUp(() async { + server = _TestServer(); + await server.start(); + }); + + tearDown(() async { + await server.stop(); + }); + + test('서버가 정상 시작된다', () { + expect(server.started, isTrue); + }); + }); + + group('ProtobufClient', () { + late _TestServer server; + late Socket clientSocket; + late _TestClient client; + + setUp(() async { + server = _TestServer(); + await server.start(); + clientSocket = await Socket.connect(_host, _testPort); + client = _TestClient(clientSocket); + await Future.delayed(const Duration(milliseconds: 100)); + }); + + tearDown(() async { + client.dispose(); + await Future.delayed(const Duration(milliseconds: 100)); + await server.stop(); + }); + + test('클라이언트가 서버에 연결된다', () { + expect(server.connectedClients, isNotEmpty); + }); + + test('TestData 메시지를 서버가 수신한다', () async { + final msg = TestData() + ..index = 42 + ..message = 'hello toki-socket'; + + await client.send(msg); + await Future.delayed(const Duration(milliseconds: 200)); + + expect(server.receivedMessages, hasLength(1)); + expect(server.receivedMessages.first.index, equals(42)); + expect(server.receivedMessages.first.message, equals('hello toki-socket')); + }); + + test('여러 메시지를 순서대로 수신한다', () async { + for (var i = 0; i < 5; i++) { + await client.send(TestData() + ..index = i + ..message = 'msg$i'); + } + await Future.delayed(const Duration(milliseconds: 300)); + + expect(server.receivedMessages, hasLength(5)); + for (var i = 0; i < 5; i++) { + expect(server.receivedMessages[i].index, equals(i)); + } + }); + + test('서버에서 클라이언트로 메시지를 전송한다', () async { + final completer = Completer(); + client.addListener((data) => completer.complete(data)); + + await Future.delayed(const Duration(milliseconds: 100)); + final serverClient = server.connectedClients.first; + await serverClient.send(TestData() + ..index = 99 + ..message = 'from server'); + + final received = await completer.future.timeout(const Duration(seconds: 2)); + expect(received.index, equals(99)); + expect(received.message, equals('from server')); + }); + + test('nonce가 송신마다 증가한다', () async { + final receivedNonces = []; + server.connectedClients.first.addListener((data) {}); + + // 서버에서 수신한 PacketBase의 nonce를 검증하기 위해 + // 클라이언트에서 3개의 메시지를 순서대로 전송 + for (var i = 1; i <= 3; i++) { + await client.send(TestData()..index = i); + } + await Future.delayed(const Duration(milliseconds: 200)); + + // 3개 수신 확인 (nonce 단조 증가는 wire level 검증 — 여기선 수신 순서로 간접 확인) + expect(server.receivedMessages, hasLength(3)); + }); + + test('클라이언트 disconnect 시 서버 콜백이 호출된다', () async { + final completer = Completer(); + server.connectedClients.first.addDisconnectListener((_) { + if (!completer.isCompleted) completer.complete(); + }); + + client.dispose(); + await completer.future.timeout(const Duration(seconds: 2)); + // completer가 완료되면 disconnect 콜백이 정상 호출된 것 + expect(completer.isCompleted, isTrue); + }); + + test('HeartBeat interval 동안 연결이 유지된다', () async { + // heartbeat interval(5초)보다 짧은 시간 대기 후 메시지 송수신 확인 + await Future.delayed(const Duration(seconds: 2)); + + await client.send(TestData()..index = 1..message = 'alive check'); + await Future.delayed(const Duration(milliseconds: 200)); + + expect(server.receivedMessages, hasLength(1)); + }); + }); +}