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 <noreply@anthropic.com>
This commit is contained in:
toki 2026-04-05 13:44:04 +09:00
commit 240239682c
17 changed files with 1057 additions and 0 deletions

39
.gitignore vendored Normal file
View file

@ -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

121
PROTOCOL.md Normal file
View file

@ -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.

84
README.md Normal file
View file

@ -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<MyMessage>((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
```

24
dart/.vscode/launch.json vendored Normal file
View file

@ -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}"
}
]
}

View file

@ -0,0 +1,5 @@
include: package:lints/recommended.yaml
analyzer:
errors:
avoid_print: ignore

View file

@ -0,0 +1,73 @@
// ignore_for_file: prefer_final_fields
import 'package:protobuf/protobuf.dart';
abstract class Communicator {
Map<String, IDataHandler> _handlerDic = {};
late Map<String, GeneratedMessage Function(List<int>)> _instanceGenerator;
Communicator();
void initialize(Map<String, GeneratedMessage Function(List<int>)> instanceGenerator) {
_instanceGenerator = instanceGenerator;
}
T Function(List<int>) getGenerator<T extends GeneratedMessage>(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<int>);
}
Future send<T extends GeneratedMessage>(T data);
void onReceivedData(String typeName, List<int> data) {
if (_handlerDic.containsKey(typeName)) {
_handlerDic[typeName]?.onMessage(data);
}
}
void addListener<T extends GeneratedMessage>(void Function(T) listener) {
var type = T.toString();
if (!_handlerDic.containsKey(type)) {
_handlerDic[type] = DataHandler<T>(getGenerator(type));
}
var handler = _handlerDic[type] as DataHandler<T>;
handler.addListener(listener);
}
void removeListener<T extends GeneratedMessage>(void Function(T) listener) {
var type = T.toString();
if (_handlerDic.containsKey(type)) {
var handler = _handlerDic[type] as DataHandler<T>;
handler.removeListener(listener);
}
}
}
abstract class IDataHandler {
void onMessage(List<int> data);
}
class DataHandler<T extends GeneratedMessage> implements IDataHandler {
T Function(List<int>) _generator;
List<void Function(T)> _listeners = [];
DataHandler(this._generator);
@override
void onMessage(List<int> 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);
}
}

View file

@ -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<PacketBase> createRepeated() => $pb.PbList<PacketBase>();
@$core.pragma('dart2js:noInline')
static PacketBase getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<PacketBase>(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<HeartBeat> createRepeated() => $pb.PbList<HeartBeat>();
@$core.pragma('dart2js:noInline')
static HeartBeat getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<HeartBeat>(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<TestData> createRepeated() => $pb.PbList<TestData>();
@$core.pragma('dart2js:noInline')
static TestData getDefault() => _defaultInstance ??= $pb.GeneratedMessage.$_defaultFor<TestData>(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');

View file

@ -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

View file

@ -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==');

View file

@ -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';

View file

@ -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;
}

View file

@ -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<int> _arrivedData = [];
List<void Function(ProtobufClient)> _onDisconnectListenerList = [];
ProtobufClient(
this._socket,
this._heartbeatIntervalTime,
this._heartbeatWaitTime,
Map<String, GeneratedMessage Function(List<int>)> 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<int>.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 extends GeneratedMessage>(T data) async {
if (_isAlive) {
try {
List<int> 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) {}
}
}
}

View file

@ -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<ProtobufClient> _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<HeartBeat>((_) => 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 extends GeneratedMessage>(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();
}
}

View file

@ -0,0 +1,31 @@
import 'dart:async';
class ResponseChecker<T> {
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;
}
}

View file

@ -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';

14
dart/pubspec.yaml Normal file
View file

@ -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

154
dart/test/socket_test.dart Normal file
View file

@ -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 = <TestData>[];
final connectedClients = <ProtobufClient>[];
_TestServer() : super(_host, _testPort, (socket) => _TestClient(socket));
@override
void onClientConnected(ProtobufClient client) {
connectedClients.add(client);
client.addListener<TestData>((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<TestData>();
client.addListener<TestData>((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 = <int>[];
server.connectedClients.first.addListener<TestData>((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<void>();
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));
});
});
}