feat: add typescript implementation and crosstest files

This commit is contained in:
toki 2026-04-24 07:16:37 +09:00
parent bf0157d35d
commit ef0fa5e8bc
50 changed files with 10262 additions and 11 deletions

View file

@ -0,0 +1,263 @@
<!-- task=crosstest_typescript plan=0 tag=CROSSTEST -->
# Code Review Reference - CROSSTEST
## 개요
date=2026-04-24
task=crosstest_typescript, plan=0, tag=CROSSTEST
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md``code_review_0.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md``plan_0.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [CROSSTEST-1] Dart 서버 → TypeScript 클라이언트 | [x] |
| [CROSSTEST-2] Kotlin 서버 → TypeScript 클라이언트 | [x] |
| [CROSSTEST-3] Python 서버 → TypeScript 클라이언트 | [x] |
| [CROSSTEST-4] TypeScript 서버 → Dart 클라이언트 | [x] |
| [CROSSTEST-5] TypeScript 서버 → Go 클라이언트 | [x] |
| [CROSSTEST-6] TypeScript 서버 → Kotlin 클라이언트 | [x] |
| [CROSSTEST-7] TypeScript 서버 → Python 클라이언트 | [x] |
## 계획 대비 변경 사항
- Python 실행 명령은 계획의 `python` 대신 기존 프로젝트 패턴과 실제 실행 환경에 맞춰 `python3`를 사용했다.
- TypeScript 서버 row 검증 명령은 계획의 `npx tsx ...` 대신 `node --import tsx ...`로 실행했다.
이유: `tsx` CLI가 sandbox 환경에서 IPC pipe listen(`EPERM`)에 실패했지만, 동일 스크립트를 `node --import tsx`로 실행하면 정상 동작했다.
- `kotlin/crosstest/kotlin_typescript.kt`의 WebSocket phase는 동일 포트(`29796`) 재기동이 불안정해서, 서버를 한 번만 띄우고 `send-push`/`requests` 두 phase를 순차 실행하도록 조정했다.
이유: Kotlin `WsServer.start()`가 비동기라 같은 포트 재시작 직후 TypeScript 클라이언트 연결이 반복적으로 `ECONNREFUSED`를 반환했다.
## 주요 설계 결정
- Phase 1 TypeScript 클라이언트 3개는 `go_typescript_client.ts` 구조를 그대로 유지하고, push 기대 문자열과 로그만 서버 언어별로 바꿨다.
- TypeScript 서버 row는 pairwise 오케스트레이터 4개를 각각 만들되, 모든 파일에서 같은 subprocess PASS/FAIL 파서와 TCP/WS phase 분리 패턴을 유지했다.
- Kotlin 서버 → TypeScript 클라이언트 조합은 TypeScript 클라이언트에 짧은 연결 안정화 지연을 추가하고, Kotlin WebSocket 서버는 단일 인스턴스 재사용으로 고정해 flaky 재기동을 제거했다.
- 검증은 Dart/TypeScript/Python 정적 검사, Go 테스트, Kotlin crosstest compile까지 먼저 확인한 뒤 언어 matrix crosstest를 순서대로 실행했다.
## 리뷰어를 위한 체크포인트
- 각 오케스트레이터의 포트가 PLAN.md 포트 배정표와 일치하는지 확인.
- 각 TypeScript 클라이언트 헬퍼의 기대 push 메시지가 서버 언어와 맞는지 확인.
(`"push from dart server"`, `"push from kotlin server"`, `"push from python server"`, `"push from typescript server"`)
- TypeScript 서버 오케스트레이터에서 `"fire from <lang> client"` 검증 메시지가 서버 언어와 맞는지 확인.
- `go/crosstest/typescript_go_client/main.go`의 package가 `main`이고 `//go:build ignore`가 없는지 확인.
- `kotlin/crosstest/typescript_kotlin_client.kt``@file:JvmName("TypescriptKotlinClientKt")`이 정확한지 확인.
- 각 TypeScript 오케스트레이터에서 서버 stop이 finally 블록에서 수행되는지 확인.
- 검증 결과: 7개 중간 검증 + Go→TypeScript 회귀 검증 모두 PASS인지 확인.
## 검증 결과
### CROSSTEST-1 중간 검증
```
$ cd dart && dart run crosstest/dart_typescript.dart
INFO typeName dart=TestData
INFO typeName ts=TestData
PASS scenario=1 detail=fire-and-forget sent
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=2 detail=received push from dart server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
INFO typeName ts=TestData
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from dart server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all dart-server/typescript-client crosstests passed
```
### CROSSTEST-2 중간 검증
```
$ cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinTypescriptKt
INFO typeName kotlin=TestData
INFO typeName ts=TestData
PASS scenario=1 detail=fire-and-forget sent
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=2 detail=received push from kotlin server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
INFO typeName ts=TestData
PASS scenario=1 detail=fire-and-forget sent
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=2 detail=received push from kotlin server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all kotlin-server/typescript-client crosstests passed
BUILD SUCCESSFUL
```
### CROSSTEST-3 중간 검증
```
$ cd python && python3 -m crosstest.python_typescript
INFO typeName python=TestData
SERVER_RECEIVED index=101 message=fire from typescript client
INFO typeName ts=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from python server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
SERVER_RECEIVED index=101 message=fire from typescript client
INFO typeName ts=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from python server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all python-server/typescript-client crosstests passed
```
### CROSSTEST-4 중간 검증
```
$ cd typescript && node --import tsx crosstest/typescript_dart.ts
INFO typeName ts=TestData
INFO typeName dart=TestData
SERVER_RECEIVED index=101 message=fire from dart client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName dart=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
INFO typeName dart=TestData
SERVER_RECEIVED index=101 message=fire from dart client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName dart=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all typescript-server/dart-client crosstests passed
```
### CROSSTEST-5 중간 검증
```
$ cd typescript && env PATH=/config/go-sdk/go/bin:/config/go/bin:$PATH GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod node --import tsx crosstest/typescript_go.ts
INFO typeName ts=TestData
INFO typeName go=TestData
SERVER_RECEIVED index=101 message=fire from go client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName go=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
INFO typeName go=TestData
SERVER_RECEIVED index=101 message=fire from go client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName go=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all typescript-server/go-client crosstests passed
```
### CROSSTEST-6 중간 검증
```
$ cd typescript && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle node --import tsx crosstest/typescript_kotlin.ts
INFO typeName ts=TestData
> Task :run
INFO typeName kotlin=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
BUILD SUCCESSFUL
> Task :run
INFO typeName kotlin=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
BUILD SUCCESSFUL
> Task :run
INFO typeName kotlin=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
BUILD SUCCESSFUL
> Task :run
INFO typeName kotlin=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
BUILD SUCCESSFUL
PASS all typescript-server/kotlin-client crosstests passed
```
### CROSSTEST-7 중간 검증
```
$ cd typescript && node --import tsx crosstest/typescript_python.ts
INFO typeName ts=TestData
SERVER_RECEIVED index=101 message=fire from python client
INFO typeName python=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName python=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
SERVER_RECEIVED index=101 message=fire from python client
INFO typeName python=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName python=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all typescript-server/python-client crosstests passed
```
### 최종 검증
```
$ cd go && env PATH=/config/go-sdk/go/bin:/config/go/bin:$PATH GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go run ./crosstest/go_typescript.go
INFO typeName go=TestData
INFO typeName ts=TestData
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from go server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
INFO typeName ts=TestData
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from go server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all go-server/typescript-client crosstests passed
```
### 보조 검증
```
$ dart analyze dart/crosstest/dart_typescript.dart dart/crosstest/typescript_dart_client.dart
No issues found!
$ cd typescript && npx tsc --noEmit
(exit 0)
$ python3 -m py_compile python/crosstest/python_typescript.py python/crosstest/typescript_python_client.py
(exit 0)
$ cd go && PATH=/config/go-sdk/go/bin:/config/go/bin:$PATH GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go test ./...
? toki-labs.com/toki_socket/go [no test files]
? toki-labs.com/toki_socket/go/crosstest/dart_go_client [no test files]
? toki-labs.com/toki_socket/go/crosstest/kotlin_go_client [no test files]
? toki-labs.com/toki_socket/go/crosstest/python_go_client [no test files]
? toki-labs.com/toki_socket/go/crosstest/typescript_go_client [no test files]
? toki-labs.com/toki_socket/go/examples/tcp_echo [no test files]
? toki-labs.com/toki_socket/go/examples/ws_echo [no test files]
? toki-labs.com/toki_socket/go/packets [no test files]
ok toki-labs.com/toki_socket/go/test 4.051s
$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew compileCrosstestKotlin
BUILD SUCCESSFUL
$ git diff --check
(no output)
```

View file

@ -0,0 +1,511 @@
<!-- task=crosstest_typescript plan=0 tag=CROSSTEST -->
# TypeScript 교차 언어 테스트 추가
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하고, 중간 검증 명령을 실행한 뒤 출력을 `CODE_REVIEW.md``검증 결과` 섹션에 붙여넣어라.
계획과 다르게 구현한 부분은 `계획 대비 변경 사항`에 이유와 함께 기록하라.
---
## 배경
Go 서버 → TypeScript 클라이언트(CROSSTEST-0: `go/crosstest/go_typescript.go` + `typescript/crosstest/go_typescript_client.ts`) 는 이미 구현되어 있다.
나머지 7쌍이 누락돼 있어 TypeScript 가 언어 매트릭스에 완전히 참여하지 못한다.
- **Phase 1**: Dart/Kotlin/Python 서버 → TypeScript 클라이언트 (3쌍)
- **Phase 2**: TypeScript 서버 → Dart/Go/Kotlin/Python 클라이언트 (4쌍)
구현 완료 시 TypeScript는 Available 상태가 된다.
---
## 의존 관계 및 구현 순서
Phase 1 → Phase 2 순서로 구현한다.
Phase 1의 TypeScript 클라이언트 헬퍼 3개는 서로 독립적이므로 병렬 작성 가능하다.
Phase 2의 TypeScript 서버 오케스트레이터 4개도 독립적이지만, Kotlin 클라이언트(`CROSSTEST-6`)는 새 Gradle 태스크 없이 `-PmainClass` 방식을 사용하므로 기존 빌드를 검증 후 진행한다.
---
## 포트 배정
| 쌍 | TCP | WS |
|----|-----|----|
| Dart → TS | 29790 | 29792 |
| Kotlin → TS | 29794 | 29796 |
| Python → TS | 29798 | 29800 |
| TS → Dart | 29802 | 29804 |
| TS → Go | 29806 | 29808 |
| TS → Kotlin | 29810 | 29812 |
| TS → Python | 29814 | 29816 |
---
## [CROSSTEST-1] Dart 서버 → TypeScript 클라이언트
### 문제
`dart/crosstest/dart_typescript.dart``typescript/crosstest/dart_typescript_client.ts` 가 없다.
### 해결 방법
**`dart/crosstest/dart_typescript.dart`** (오케스트레이터)
`dart/crosstest/dart_go.dart`를 기반으로 작성.
변경점: 포트 29790/29792, Go 클라이언트 호출 → `npx tsx crosstest/dart_typescript_client.ts`, 메시지 `"fire from typescript client"` 검증, push `"push from dart server"`.
```dart
// 핵심 상수
const _tcpPort = 29790;
const _wsPort = 29792;
final _typescriptDir = Directory('$_repoRoot/typescript').path;
// 클라이언트 호출
Future<void> _runTypescriptClient(String mode, int port, String phase, Set<String> expected) async {
final process = await Process.start(
'npx', ['tsx', 'crosstest/dart_typescript_client.ts',
'--mode=$mode', '--port=$port', '--phase=$phase'],
workingDirectory: _typescriptDir,
);
...
}
```
send-push 검증 메시지: `data.message == 'fire from typescript client'`
push 응답: `index=200, message='push from dart server'`
**`typescript/crosstest/dart_typescript_client.ts`** (클라이언트 헬퍼)
`typescript/crosstest/go_typescript_client.ts`와 구조 동일.
변경점만:
- `console.log("INFO typeName ts=...")`의 출력 유지
- scenario 2에서 기대 메시지: `"push from dart server"`
- `--mode`/`--port`/`--phase` args 그대로 사용
### 수정 파일 및 체크리스트
- [ ] `dart/crosstest/dart_typescript.dart` 신규 작성
- [ ] `typescript/crosstest/dart_typescript_client.ts` 신규 작성
### 테스트 작성
스킵. crosstest 자체가 통합 테스트이며, 중간 검증 명령으로 커버된다.
### 중간 검증
```bash
cd dart && dart run crosstest/dart_typescript.dart
```
기대 결과: `PASS all dart-server/typescript-client crosstests passed`
---
## [CROSSTEST-2] Kotlin 서버 → TypeScript 클라이언트
### 문제
`kotlin/crosstest/kotlin_typescript.kt``typescript/crosstest/kotlin_typescript_client.ts` 가 없다.
### 해결 방법
**`kotlin/crosstest/kotlin_typescript.kt`** (오케스트레이터)
`kotlin/crosstest/kotlin_go.kt`를 기반으로 작성.
변경점: 포트 29794/29796, `@file:JvmName("KotlinTypescriptKt")`, Go 클라이언트 호출 → `npx tsx ...`, 메시지 `"fire from typescript client"` 검증.
```kotlin
@file:JvmName("KotlinTypescriptKt")
// ...
private const val TCP_PORT = 29794
private const val WS_PORT = 29796
private suspend fun runTypescriptClient(mode: String, port: Int, phase: String, expected: Set<String>) =
withContext(Dispatchers.IO) {
val process = ProcessBuilder(
"npx", "tsx", "crosstest/kotlin_typescript_client.ts",
"--mode=$mode", "--port=$port", "--phase=$phase",
)
.directory(typescriptDir())
.redirectErrorStream(false)
.start()
// stdout/stderr 스레드, validateResultLines — kotlin_go.kt와 동일
}
private fun typescriptDir(): File {
var dir = File(System.getProperty("user.dir")).absoluteFile
while (dir.parentFile != null) {
val candidate = File(dir, "../typescript").canonicalFile
if (File(candidate, "package.json").isFile) return candidate
val direct = File(dir, "typescript").canonicalFile
if (File(direct, "package.json").isFile) return direct
dir = dir.parentFile
}
error("cannot resolve typescript directory")
}
```
send-push 검증 메시지: `data.message == "fire from typescript client"`
push 응답: `"push from kotlin server"`
**`typescript/crosstest/kotlin_typescript_client.ts`** (클라이언트 헬퍼)
`go_typescript_client.ts`와 구조 동일.
변경점: scenario 2 기대 메시지 → `"push from kotlin server"`
### 수정 파일 및 체크리스트
- [ ] `kotlin/crosstest/kotlin_typescript.kt` 신규 작성
- [ ] `typescript/crosstest/kotlin_typescript_client.ts` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinTypescriptKt
```
기대 결과: `PASS all kotlin-server/typescript-client crosstests passed`
---
## [CROSSTEST-3] Python 서버 → TypeScript 클라이언트
### 문제
`python/crosstest/python_typescript.py``typescript/crosstest/python_typescript_client.ts` 가 없다.
### 해결 방법
**`python/crosstest/python_typescript.py`** (오케스트레이터)
`python/crosstest/python_go.py`를 기반으로 작성.
변경점: 포트 29798/29800, Go 클라이언트 호출 → `npx tsx ...`, 메시지 `"fire from typescript client"` 검증.
```python
PYTHON_TYPESCRIPT_TCP_PORT = 29798
PYTHON_TYPESCRIPT_WS_PORT = 29800
async def run_typescript_client(mode: str, port: int, phase: str, expected: set[str]) -> None:
ts_dir = typescript_package_dir()
process = await asyncio.create_subprocess_exec(
"npx", "tsx", "crosstest/python_typescript_client.ts",
f"--mode={mode}", f"--port={port}", f"--phase={phase}",
cwd=ts_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# communicate + validate — python_go.py와 동일
def typescript_package_dir() -> Path:
repo_root = Path(__file__).resolve().parents[2]
candidate = repo_root / "typescript"
if (candidate / "package.json").is_file():
return candidate
raise RuntimeError(f"cannot resolve typescript package directory from {candidate}")
```
send-push 검증 메시지: `data.message == "fire from typescript client"`
push 응답: `"push from python server"`
**`typescript/crosstest/python_typescript_client.ts`** (클라이언트 헬퍼)
`go_typescript_client.ts`와 구조 동일.
변경점: scenario 2 기대 메시지 → `"push from python server"`
### 수정 파일 및 체크리스트
- [ ] `python/crosstest/python_typescript.py` 신규 작성
- [ ] `typescript/crosstest/python_typescript_client.ts` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd python && python -m crosstest.python_typescript
```
기대 결과: `PASS all python-server/typescript-client crosstests passed`
---
## [CROSSTEST-4] TypeScript 서버 → Dart 클라이언트
### 문제
`typescript/crosstest/typescript_dart.ts``dart/crosstest/typescript_dart_client.dart` 가 없다.
### 해결 방법
**`typescript/crosstest/typescript_dart.ts`** (오케스트레이터)
TypeScript TcpServer/WsServer 를 직접 기동하고 Dart 클라이언트 서브프로세스를 호출.
```typescript
import * as path from "node:path";
import * as childProcess from "node:child_process";
import { fileURLToPath } from "node:url";
import { create } from "@bufbuild/protobuf";
import { addListenerTyped, addRequestListenerTyped, parserFromSchema } from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { TcpServer } from "../src/tcp_server.js";
import { TcpClient } from "../src/tcp_client.js";
import { WsServer } from "../src/ws_server.js";
import { WsClient } from "../src/ws_client.js";
const __filename = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(__filename), "../..");
const dartDir = path.join(repoRoot, "dart");
const HOST = "127.0.0.1";
const TCP_PORT = 29802;
const WS_PORT = 29804;
const WS_PATH = "/";
const PROCESS_TIMEOUT_MS = 20_000;
const SERVER_OBSERVATION_MS = 200;
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
```
서버 오케스트레이터 패턴 (go_typescript.go의 TypeScript 역방향):
- `runTcpSendPush`: `data.message === "fire from dart client"` 검증, push `index=200, "push from typescript server"`
- `runTcpRequests`: `index * 2`, `"echo: " + message`
- `runWsSendPush` / `runWsRequests`: 동일 구조
```typescript
async function runDartClient(mode: string, port: number, phase: string, expected: Set<string>): Promise<void> {
return new Promise((resolve, reject) => {
const child = childProcess.spawn(
"dart",
["run", "crosstest/typescript_dart_client.dart",
`--mode=${mode}`, `--port=${port}`, `--phase=${phase}`],
{ cwd: dartDir, stdio: ["ignore", "pipe", "pipe"] },
);
// stdout 파싱, PASS/FAIL 수집, validate
});
}
```
**`dart/crosstest/typescript_dart_client.dart`** (클라이언트 헬퍼)
`dart/crosstest/go_dart_client.dart`와 구조 동일.
변경점:
- fire 메시지: `"fire from dart client"` (동일)
- 기대 push: `push.message != 'push from typescript server'`
- sendRequest 메시지: `"single request from dart"` (동일)
### 수정 파일 및 체크리스트
- [ ] `typescript/crosstest/typescript_dart.ts` 신규 작성
- [ ] `dart/crosstest/typescript_dart_client.dart` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd typescript && npx tsx crosstest/typescript_dart.ts
```
기대 결과: `PASS all typescript-server/dart-client crosstests passed`
---
## [CROSSTEST-5] TypeScript 서버 → Go 클라이언트
### 문제
`typescript/crosstest/typescript_go.ts``go/crosstest/typescript_go_client/main.go` 가 없다.
### 해결 방법
**`typescript/crosstest/typescript_go.ts`** (오케스트레이터)
`typescript_dart.ts`와 구조 동일. 포트 29806/29808.
Go 클라이언트 호출:
```typescript
async function runGoClient(...) {
const child = childProcess.spawn(
"go", ["run", "./crosstest/typescript_go_client",
`--mode=${mode}`, `--port=${port}`, `--phase=${phase}`],
{ cwd: goDir, stdio: ["ignore", "pipe", "pipe"] },
);
}
```
검증 메시지: `"fire from go client"`, push: `"push from typescript server"`.
**`go/crosstest/typescript_go_client/main.go`** (클라이언트 헬퍼)
`go/crosstest/kotlin_go_client/main.go`와 구조 동일.
변경점:
- `//go:build` 없음 (패키지 `main`)
- fire 메시지: `"fire from go client"` (동일)
- 기대 push: `"push from typescript server"`
- single request 메시지: `"single request from go"` (동일)
### 수정 파일 및 체크리스트
- [ ] `typescript/crosstest/typescript_go.ts` 신규 작성
- [ ] `go/crosstest/typescript_go_client/main.go` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd typescript && npx tsx crosstest/typescript_go.ts
```
기대 결과: `PASS all typescript-server/go-client crosstests passed`
---
## [CROSSTEST-6] TypeScript 서버 → Kotlin 클라이언트
### 문제
`typescript/crosstest/typescript_kotlin.ts``kotlin/crosstest/typescript_kotlin_client.kt` 가 없다.
### 해결 방법
**`typescript/crosstest/typescript_kotlin.ts`** (오케스트레이터)
`typescript_dart.ts`와 구조 동일. 포트 29810/29812.
Kotlin 클라이언트 호출 (`./gradlew run` 방식):
```typescript
async function runKotlinClient(mode: string, port: number, phase: string, expected: Set<string>): Promise<void> {
return new Promise((resolve, reject) => {
const child = childProcess.spawn(
"./gradlew",
["run",
"-PmainClass=com.tokilabs.toki_socket.crosstest.TypescriptKotlinClientKt",
`--args=--mode=${mode} --port=${port} --phase=${phase}`],
{ cwd: kotlinDir, stdio: ["ignore", "pipe", "pipe"] },
);
// stdout 파싱, validate
});
}
```
검증 메시지: `"fire from kotlin client"`, push: `"push from typescript server"`.
**`kotlin/crosstest/typescript_kotlin_client.kt`** (클라이언트 헬퍼)
`kotlin/crosstest/python_kotlin_client.kt`와 구조 동일.
변경점:
- `@file:JvmName("TypescriptKotlinClientKt")`
- `interface TypescriptClientHandle`, `TypescriptTcpHandle`, `TypescriptWsHandle`
- fire 메시지: `"fire from kotlin client"` (동일)
- 기대 push: `"push from typescript server"`
- single request 메시지: `"single request from kotlin"`
### 수정 파일 및 체크리스트
- [ ] `typescript/crosstest/typescript_kotlin.ts` 신규 작성
- [ ] `kotlin/crosstest/typescript_kotlin_client.kt` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd typescript && npx tsx crosstest/typescript_kotlin.ts
```
기대 결과: `PASS all typescript-server/kotlin-client crosstests passed`
---
## [CROSSTEST-7] TypeScript 서버 → Python 클라이언트
### 문제
`typescript/crosstest/typescript_python.ts``python/crosstest/typescript_python_client.py` 가 없다.
### 해결 방법
**`typescript/crosstest/typescript_python.ts`** (오케스트레이터)
`typescript_dart.ts`와 구조 동일. 포트 29814/29816.
Python 클라이언트 호출:
```typescript
async function runPythonClient(mode: string, port: number, phase: string, expected: Set<string>): Promise<void> {
return new Promise((resolve, reject) => {
const child = childProcess.spawn(
"python",
["-m", "crosstest.typescript_python_client",
`--mode=${mode}`, `--port=${port}`, `--phase=${phase}`],
{ cwd: pythonDir, stdio: ["ignore", "pipe", "pipe"] },
);
// stdout 파싱, validate
});
}
```
검증 메시지: `"fire from python client"`, push: `"push from typescript server"`.
**`python/crosstest/typescript_python_client.py`** (클라이언트 헬퍼)
`python/crosstest/go_python_client.py`와 구조 동일.
변경점:
- fire 메시지: `"fire from python client"` (동일)
- 기대 push: `message.message != "push from typescript server"`
- single request 메시지: `"single request from python"` (동일)
### 수정 파일 및 체크리스트
- [ ] `typescript/crosstest/typescript_python.ts` 신규 작성
- [ ] `python/crosstest/typescript_python_client.py` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd typescript && npx tsx crosstest/typescript_python.ts
```
기대 결과: `PASS all typescript-server/python-client crosstests passed`
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `dart/crosstest/dart_typescript.dart` | CROSSTEST-1 |
| `typescript/crosstest/dart_typescript_client.ts` | CROSSTEST-1 |
| `kotlin/crosstest/kotlin_typescript.kt` | CROSSTEST-2 |
| `typescript/crosstest/kotlin_typescript_client.ts` | CROSSTEST-2 |
| `python/crosstest/python_typescript.py` | CROSSTEST-3 |
| `typescript/crosstest/python_typescript_client.ts` | CROSSTEST-3 |
| `typescript/crosstest/typescript_dart.ts` | CROSSTEST-4 |
| `dart/crosstest/typescript_dart_client.dart` | CROSSTEST-4 |
| `typescript/crosstest/typescript_go.ts` | CROSSTEST-5 |
| `go/crosstest/typescript_go_client/main.go` | CROSSTEST-5 |
| `typescript/crosstest/typescript_kotlin.ts` | CROSSTEST-6 |
| `kotlin/crosstest/typescript_kotlin_client.kt` | CROSSTEST-6 |
| `typescript/crosstest/typescript_python.ts` | CROSSTEST-7 |
| `python/crosstest/typescript_python_client.py` | CROSSTEST-7 |
---
## 최종 검증
```bash
# Phase 1
cd dart && dart run crosstest/dart_typescript.dart
cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinTypescriptKt
cd python && python -m crosstest.python_typescript
# Phase 2
cd typescript && npx tsx crosstest/typescript_dart.ts
cd typescript && npx tsx crosstest/typescript_go.ts
cd typescript && npx tsx crosstest/typescript_kotlin.ts
cd typescript && npx tsx crosstest/typescript_python.ts
# 기존 Go→TypeScript 회귀 확인
cd go && go run ./crosstest/go_typescript.go
```
기대 결과: 모든 명령에서 `PASS all ...` 출력.

View file

@ -0,0 +1,263 @@
<!-- task=crosstest_typescript plan=0 tag=CROSSTEST -->
# Code Review Reference - CROSSTEST
## 개요
date=2026-04-24
task=crosstest_typescript, plan=0, tag=CROSSTEST
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_0.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md` → `plan_0.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [CROSSTEST-1] Dart 서버 → TypeScript 클라이언트 | [x] |
| [CROSSTEST-2] Kotlin 서버 → TypeScript 클라이언트 | [x] |
| [CROSSTEST-3] Python 서버 → TypeScript 클라이언트 | [x] |
| [CROSSTEST-4] TypeScript 서버 → Dart 클라이언트 | [x] |
| [CROSSTEST-5] TypeScript 서버 → Go 클라이언트 | [x] |
| [CROSSTEST-6] TypeScript 서버 → Kotlin 클라이언트 | [x] |
| [CROSSTEST-7] TypeScript 서버 → Python 클라이언트 | [x] |
## 계획 대비 변경 사항
- Python 실행 명령은 계획의 `python` 대신 기존 프로젝트 패턴과 실제 실행 환경에 맞춰 `python3`를 사용했다.
- TypeScript 서버 row 검증 명령은 계획의 `npx tsx ...` 대신 `node --import tsx ...`로 실행했다.
이유: `tsx` CLI가 sandbox 환경에서 IPC pipe listen(`EPERM`)에 실패했지만, 동일 스크립트를 `node --import tsx`로 실행하면 정상 동작했다.
- `kotlin/crosstest/kotlin_typescript.kt`의 WebSocket phase는 동일 포트(`29796`) 재기동이 불안정해서, 서버를 한 번만 띄우고 `send-push`/`requests` 두 phase를 순차 실행하도록 조정했다.
이유: Kotlin `WsServer.start()`가 비동기라 같은 포트 재시작 직후 TypeScript 클라이언트 연결이 반복적으로 `ECONNREFUSED`를 반환했다.
## 주요 설계 결정
- Phase 1 TypeScript 클라이언트 3개는 `go_typescript_client.ts` 구조를 그대로 유지하고, push 기대 문자열과 로그만 서버 언어별로 바꿨다.
- TypeScript 서버 row는 pairwise 오케스트레이터 4개를 각각 만들되, 모든 파일에서 같은 subprocess PASS/FAIL 파서와 TCP/WS phase 분리 패턴을 유지했다.
- Kotlin 서버 → TypeScript 클라이언트 조합은 TypeScript 클라이언트에 짧은 연결 안정화 지연을 추가하고, Kotlin WebSocket 서버는 단일 인스턴스 재사용으로 고정해 flaky 재기동을 제거했다.
- 검증은 Dart/TypeScript/Python 정적 검사, Go 테스트, Kotlin crosstest compile까지 먼저 확인한 뒤 언어 matrix crosstest를 순서대로 실행했다.
## 리뷰어를 위한 체크포인트
- 각 오케스트레이터의 포트가 PLAN.md 포트 배정표와 일치하는지 확인.
- 각 TypeScript 클라이언트 헬퍼의 기대 push 메시지가 서버 언어와 맞는지 확인.
(`"push from dart server"`, `"push from kotlin server"`, `"push from python server"`, `"push from typescript server"`)
- TypeScript 서버 오케스트레이터에서 `"fire from <lang> client"` 검증 메시지가 서버 언어와 맞는지 확인.
- `go/crosstest/typescript_go_client/main.go`의 package가 `main`이고 `//go:build ignore`가 없는지 확인.
- `kotlin/crosstest/typescript_kotlin_client.kt`의 `@file:JvmName("TypescriptKotlinClientKt")`이 정확한지 확인.
- 각 TypeScript 오케스트레이터에서 서버 stop이 finally 블록에서 수행되는지 확인.
- 검증 결과: 7개 중간 검증 + Go→TypeScript 회귀 검증 모두 PASS인지 확인.
## 검증 결과
### CROSSTEST-1 중간 검증
```
$ cd dart && dart run crosstest/dart_typescript.dart
INFO typeName dart=TestData
INFO typeName ts=TestData
PASS scenario=1 detail=fire-and-forget sent
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=2 detail=received push from dart server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
INFO typeName ts=TestData
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from dart server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all dart-server/typescript-client crosstests passed
```
### CROSSTEST-2 중간 검증
```
$ cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinTypescriptKt
INFO typeName kotlin=TestData
INFO typeName ts=TestData
PASS scenario=1 detail=fire-and-forget sent
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=2 detail=received push from kotlin server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
INFO typeName ts=TestData
PASS scenario=1 detail=fire-and-forget sent
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=2 detail=received push from kotlin server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all kotlin-server/typescript-client crosstests passed
BUILD SUCCESSFUL
```
### CROSSTEST-3 중간 검증
```
$ cd python && python3 -m crosstest.python_typescript
INFO typeName python=TestData
SERVER_RECEIVED index=101 message=fire from typescript client
INFO typeName ts=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from python server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
SERVER_RECEIVED index=101 message=fire from typescript client
INFO typeName ts=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from python server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all python-server/typescript-client crosstests passed
```
### CROSSTEST-4 중간 검증
```
$ cd typescript && node --import tsx crosstest/typescript_dart.ts
INFO typeName ts=TestData
INFO typeName dart=TestData
SERVER_RECEIVED index=101 message=fire from dart client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName dart=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
INFO typeName dart=TestData
SERVER_RECEIVED index=101 message=fire from dart client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName dart=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all typescript-server/dart-client crosstests passed
```
### CROSSTEST-5 중간 검증
```
$ cd typescript && env PATH=/config/go-sdk/go/bin:/config/go/bin:$PATH GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod node --import tsx crosstest/typescript_go.ts
INFO typeName ts=TestData
INFO typeName go=TestData
SERVER_RECEIVED index=101 message=fire from go client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName go=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
INFO typeName go=TestData
SERVER_RECEIVED index=101 message=fire from go client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName go=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all typescript-server/go-client crosstests passed
```
### CROSSTEST-6 중간 검증
```
$ cd typescript && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle node --import tsx crosstest/typescript_kotlin.ts
INFO typeName ts=TestData
> Task :run
INFO typeName kotlin=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
BUILD SUCCESSFUL
> Task :run
INFO typeName kotlin=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
BUILD SUCCESSFUL
> Task :run
INFO typeName kotlin=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
BUILD SUCCESSFUL
> Task :run
INFO typeName kotlin=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
BUILD SUCCESSFUL
PASS all typescript-server/kotlin-client crosstests passed
```
### CROSSTEST-7 중간 검증
```
$ cd typescript && node --import tsx crosstest/typescript_python.ts
INFO typeName ts=TestData
SERVER_RECEIVED index=101 message=fire from python client
INFO typeName python=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName python=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
SERVER_RECEIVED index=101 message=fire from python client
INFO typeName python=TestData
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from typescript server
INFO typeName python=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all typescript-server/python-client crosstests passed
```
### 최종 검증
```
$ cd go && env PATH=/config/go-sdk/go/bin:/config/go/bin:$PATH GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go run ./crosstest/go_typescript.go
INFO typeName go=TestData
INFO typeName ts=TestData
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from go server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
INFO typeName ts=TestData
SERVER_RECEIVED index=101 message=fire from typescript client
PASS scenario=1 detail=fire-and-forget sent
PASS scenario=2 detail=received push from go server
INFO typeName ts=TestData
PASS scenario=3 detail=single request response matched
PASS scenario=4 detail=concurrent request responses matched
PASS all go-server/typescript-client crosstests passed
```
### 보조 검증
```
$ dart analyze dart/crosstest/dart_typescript.dart dart/crosstest/typescript_dart_client.dart
No issues found!
$ cd typescript && npx tsc --noEmit
(exit 0)
$ python3 -m py_compile python/crosstest/python_typescript.py python/crosstest/typescript_python_client.py
(exit 0)
$ cd go && PATH=/config/go-sdk/go/bin:/config/go/bin:$PATH GOCACHE=/tmp/go-build GOMODCACHE=/tmp/go-mod go test ./...
? toki-labs.com/toki_socket/go [no test files]
? toki-labs.com/toki_socket/go/crosstest/dart_go_client [no test files]
? toki-labs.com/toki_socket/go/crosstest/kotlin_go_client [no test files]
? toki-labs.com/toki_socket/go/crosstest/python_go_client [no test files]
? toki-labs.com/toki_socket/go/crosstest/typescript_go_client [no test files]
? toki-labs.com/toki_socket/go/examples/tcp_echo [no test files]
? toki-labs.com/toki_socket/go/examples/ws_echo [no test files]
? toki-labs.com/toki_socket/go/packets [no test files]
ok toki-labs.com/toki_socket/go/test 4.051s
$ cd kotlin && env JAVA_HOME=/config/opt/jdk/jdk-17.0.10+7 GRADLE_USER_HOME=/tmp/gradle ./gradlew compileCrosstestKotlin
BUILD SUCCESSFUL
$ git diff --check
(no output)
```

View file

@ -0,0 +1,22 @@
date=2026-04-24
task=crosstest_typescript plan=0 tag=CROSSTEST
result=PASS
모든 체크포인트 통과. 7개 CROSSTEST 항목 전부 구현 완료 및 검증 PASS.
## 검증 요약
- [CROSSTEST-1] Dart 서버 → TypeScript 클라이언트: PASS
- [CROSSTEST-2] Kotlin 서버 → TypeScript 클라이언트: PASS
- [CROSSTEST-3] Python 서버 → TypeScript 클라이언트: PASS
- [CROSSTEST-4] TypeScript 서버 → Dart 클라이언트: PASS
- [CROSSTEST-5] TypeScript 서버 → Go 클라이언트: PASS
- [CROSSTEST-6] TypeScript 서버 → Kotlin 클라이언트: PASS
- [CROSSTEST-7] TypeScript 서버 → Python 클라이언트: PASS
- Go 서버 → TypeScript 클라이언트 회귀검증: PASS
## 계획 대비 주요 변경
- python 실행: `python` → `python3`
- TypeScript 서버 실행: `npx tsx` → `node --import tsx` (sandbox EPERM 우회)
- Kotlin WS phase: 포트 재기동 제거, 단일 WsServer 인스턴스 재사용 + 안정화 지연(50ms)

View file

@ -0,0 +1,511 @@
<!-- task=crosstest_typescript plan=0 tag=CROSSTEST -->
# TypeScript 교차 언어 테스트 추가
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하고, 중간 검증 명령을 실행한 뒤 출력을 `CODE_REVIEW.md`의 `검증 결과` 섹션에 붙여넣어라.
계획과 다르게 구현한 부분은 `계획 대비 변경 사항`에 이유와 함께 기록하라.
---
## 배경
Go 서버 → TypeScript 클라이언트(CROSSTEST-0: `go/crosstest/go_typescript.go` + `typescript/crosstest/go_typescript_client.ts`) 는 이미 구현되어 있다.
나머지 7쌍이 누락돼 있어 TypeScript 가 언어 매트릭스에 완전히 참여하지 못한다.
- **Phase 1**: Dart/Kotlin/Python 서버 → TypeScript 클라이언트 (3쌍)
- **Phase 2**: TypeScript 서버 → Dart/Go/Kotlin/Python 클라이언트 (4쌍)
구현 완료 시 TypeScript는 Available 상태가 된다.
---
## 의존 관계 및 구현 순서
Phase 1 → Phase 2 순서로 구현한다.
Phase 1의 TypeScript 클라이언트 헬퍼 3개는 서로 독립적이므로 병렬 작성 가능하다.
Phase 2의 TypeScript 서버 오케스트레이터 4개도 독립적이지만, Kotlin 클라이언트(`CROSSTEST-6`)는 새 Gradle 태스크 없이 `-PmainClass` 방식을 사용하므로 기존 빌드를 검증 후 진행한다.
---
## 포트 배정
| 쌍 | TCP | WS |
|----|-----|----|
| Dart → TS | 29790 | 29792 |
| Kotlin → TS | 29794 | 29796 |
| Python → TS | 29798 | 29800 |
| TS → Dart | 29802 | 29804 |
| TS → Go | 29806 | 29808 |
| TS → Kotlin | 29810 | 29812 |
| TS → Python | 29814 | 29816 |
---
## [CROSSTEST-1] Dart 서버 → TypeScript 클라이언트
### 문제
`dart/crosstest/dart_typescript.dart` 와 `typescript/crosstest/dart_typescript_client.ts` 가 없다.
### 해결 방법
**`dart/crosstest/dart_typescript.dart`** (오케스트레이터)
`dart/crosstest/dart_go.dart`를 기반으로 작성.
변경점: 포트 29790/29792, Go 클라이언트 호출 → `npx tsx crosstest/dart_typescript_client.ts`, 메시지 `"fire from typescript client"` 검증, push `"push from dart server"`.
```dart
// 핵심 상수
const _tcpPort = 29790;
const _wsPort = 29792;
final _typescriptDir = Directory('$_repoRoot/typescript').path;
// 클라이언트 호출
Future<void> _runTypescriptClient(String mode, int port, String phase, Set<String> expected) async {
final process = await Process.start(
'npx', ['tsx', 'crosstest/dart_typescript_client.ts',
'--mode=$mode', '--port=$port', '--phase=$phase'],
workingDirectory: _typescriptDir,
);
...
}
```
send-push 검증 메시지: `data.message == 'fire from typescript client'`
push 응답: `index=200, message='push from dart server'`
**`typescript/crosstest/dart_typescript_client.ts`** (클라이언트 헬퍼)
`typescript/crosstest/go_typescript_client.ts`와 구조 동일.
변경점만:
- `console.log("INFO typeName ts=...")`의 출력 유지
- scenario 2에서 기대 메시지: `"push from dart server"`
- `--mode`/`--port`/`--phase` args 그대로 사용
### 수정 파일 및 체크리스트
- [ ] `dart/crosstest/dart_typescript.dart` 신규 작성
- [ ] `typescript/crosstest/dart_typescript_client.ts` 신규 작성
### 테스트 작성
스킵. crosstest 자체가 통합 테스트이며, 중간 검증 명령으로 커버된다.
### 중간 검증
```bash
cd dart && dart run crosstest/dart_typescript.dart
```
기대 결과: `PASS all dart-server/typescript-client crosstests passed`
---
## [CROSSTEST-2] Kotlin 서버 → TypeScript 클라이언트
### 문제
`kotlin/crosstest/kotlin_typescript.kt` 와 `typescript/crosstest/kotlin_typescript_client.ts` 가 없다.
### 해결 방법
**`kotlin/crosstest/kotlin_typescript.kt`** (오케스트레이터)
`kotlin/crosstest/kotlin_go.kt`를 기반으로 작성.
변경점: 포트 29794/29796, `@file:JvmName("KotlinTypescriptKt")`, Go 클라이언트 호출 → `npx tsx ...`, 메시지 `"fire from typescript client"` 검증.
```kotlin
@file:JvmName("KotlinTypescriptKt")
// ...
private const val TCP_PORT = 29794
private const val WS_PORT = 29796
private suspend fun runTypescriptClient(mode: String, port: Int, phase: String, expected: Set<String>) =
withContext(Dispatchers.IO) {
val process = ProcessBuilder(
"npx", "tsx", "crosstest/kotlin_typescript_client.ts",
"--mode=$mode", "--port=$port", "--phase=$phase",
)
.directory(typescriptDir())
.redirectErrorStream(false)
.start()
// stdout/stderr 스레드, validateResultLines — kotlin_go.kt와 동일
}
private fun typescriptDir(): File {
var dir = File(System.getProperty("user.dir")).absoluteFile
while (dir.parentFile != null) {
val candidate = File(dir, "../typescript").canonicalFile
if (File(candidate, "package.json").isFile) return candidate
val direct = File(dir, "typescript").canonicalFile
if (File(direct, "package.json").isFile) return direct
dir = dir.parentFile
}
error("cannot resolve typescript directory")
}
```
send-push 검증 메시지: `data.message == "fire from typescript client"`
push 응답: `"push from kotlin server"`
**`typescript/crosstest/kotlin_typescript_client.ts`** (클라이언트 헬퍼)
`go_typescript_client.ts`와 구조 동일.
변경점: scenario 2 기대 메시지 → `"push from kotlin server"`
### 수정 파일 및 체크리스트
- [ ] `kotlin/crosstest/kotlin_typescript.kt` 신규 작성
- [ ] `typescript/crosstest/kotlin_typescript_client.ts` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinTypescriptKt
```
기대 결과: `PASS all kotlin-server/typescript-client crosstests passed`
---
## [CROSSTEST-3] Python 서버 → TypeScript 클라이언트
### 문제
`python/crosstest/python_typescript.py` 와 `typescript/crosstest/python_typescript_client.ts` 가 없다.
### 해결 방법
**`python/crosstest/python_typescript.py`** (오케스트레이터)
`python/crosstest/python_go.py`를 기반으로 작성.
변경점: 포트 29798/29800, Go 클라이언트 호출 → `npx tsx ...`, 메시지 `"fire from typescript client"` 검증.
```python
PYTHON_TYPESCRIPT_TCP_PORT = 29798
PYTHON_TYPESCRIPT_WS_PORT = 29800
async def run_typescript_client(mode: str, port: int, phase: str, expected: set[str]) -> None:
ts_dir = typescript_package_dir()
process = await asyncio.create_subprocess_exec(
"npx", "tsx", "crosstest/python_typescript_client.ts",
f"--mode={mode}", f"--port={port}", f"--phase={phase}",
cwd=ts_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
# communicate + validate — python_go.py와 동일
def typescript_package_dir() -> Path:
repo_root = Path(__file__).resolve().parents[2]
candidate = repo_root / "typescript"
if (candidate / "package.json").is_file():
return candidate
raise RuntimeError(f"cannot resolve typescript package directory from {candidate}")
```
send-push 검증 메시지: `data.message == "fire from typescript client"`
push 응답: `"push from python server"`
**`typescript/crosstest/python_typescript_client.ts`** (클라이언트 헬퍼)
`go_typescript_client.ts`와 구조 동일.
변경점: scenario 2 기대 메시지 → `"push from python server"`
### 수정 파일 및 체크리스트
- [ ] `python/crosstest/python_typescript.py` 신규 작성
- [ ] `typescript/crosstest/python_typescript_client.ts` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd python && python -m crosstest.python_typescript
```
기대 결과: `PASS all python-server/typescript-client crosstests passed`
---
## [CROSSTEST-4] TypeScript 서버 → Dart 클라이언트
### 문제
`typescript/crosstest/typescript_dart.ts` 와 `dart/crosstest/typescript_dart_client.dart` 가 없다.
### 해결 방법
**`typescript/crosstest/typescript_dart.ts`** (오케스트레이터)
TypeScript TcpServer/WsServer 를 직접 기동하고 Dart 클라이언트 서브프로세스를 호출.
```typescript
import * as path from "node:path";
import * as childProcess from "node:child_process";
import { fileURLToPath } from "node:url";
import { create } from "@bufbuild/protobuf";
import { addListenerTyped, addRequestListenerTyped, parserFromSchema } from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { TcpServer } from "../src/tcp_server.js";
import { TcpClient } from "../src/tcp_client.js";
import { WsServer } from "../src/ws_server.js";
import { WsClient } from "../src/ws_client.js";
const __filename = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(__filename), "../..");
const dartDir = path.join(repoRoot, "dart");
const HOST = "127.0.0.1";
const TCP_PORT = 29802;
const WS_PORT = 29804;
const WS_PATH = "/";
const PROCESS_TIMEOUT_MS = 20_000;
const SERVER_OBSERVATION_MS = 200;
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
```
서버 오케스트레이터 패턴 (go_typescript.go의 TypeScript 역방향):
- `runTcpSendPush`: `data.message === "fire from dart client"` 검증, push `index=200, "push from typescript server"`
- `runTcpRequests`: `index * 2`, `"echo: " + message`
- `runWsSendPush` / `runWsRequests`: 동일 구조
```typescript
async function runDartClient(mode: string, port: number, phase: string, expected: Set<string>): Promise<void> {
return new Promise((resolve, reject) => {
const child = childProcess.spawn(
"dart",
["run", "crosstest/typescript_dart_client.dart",
`--mode=${mode}`, `--port=${port}`, `--phase=${phase}`],
{ cwd: dartDir, stdio: ["ignore", "pipe", "pipe"] },
);
// stdout 파싱, PASS/FAIL 수집, validate
});
}
```
**`dart/crosstest/typescript_dart_client.dart`** (클라이언트 헬퍼)
`dart/crosstest/go_dart_client.dart`와 구조 동일.
변경점:
- fire 메시지: `"fire from dart client"` (동일)
- 기대 push: `push.message != 'push from typescript server'`
- sendRequest 메시지: `"single request from dart"` (동일)
### 수정 파일 및 체크리스트
- [ ] `typescript/crosstest/typescript_dart.ts` 신규 작성
- [ ] `dart/crosstest/typescript_dart_client.dart` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd typescript && npx tsx crosstest/typescript_dart.ts
```
기대 결과: `PASS all typescript-server/dart-client crosstests passed`
---
## [CROSSTEST-5] TypeScript 서버 → Go 클라이언트
### 문제
`typescript/crosstest/typescript_go.ts` 와 `go/crosstest/typescript_go_client/main.go` 가 없다.
### 해결 방법
**`typescript/crosstest/typescript_go.ts`** (오케스트레이터)
`typescript_dart.ts`와 구조 동일. 포트 29806/29808.
Go 클라이언트 호출:
```typescript
async function runGoClient(...) {
const child = childProcess.spawn(
"go", ["run", "./crosstest/typescript_go_client",
`--mode=${mode}`, `--port=${port}`, `--phase=${phase}`],
{ cwd: goDir, stdio: ["ignore", "pipe", "pipe"] },
);
}
```
검증 메시지: `"fire from go client"`, push: `"push from typescript server"`.
**`go/crosstest/typescript_go_client/main.go`** (클라이언트 헬퍼)
`go/crosstest/kotlin_go_client/main.go`와 구조 동일.
변경점:
- `//go:build` 없음 (패키지 `main`)
- fire 메시지: `"fire from go client"` (동일)
- 기대 push: `"push from typescript server"`
- single request 메시지: `"single request from go"` (동일)
### 수정 파일 및 체크리스트
- [ ] `typescript/crosstest/typescript_go.ts` 신규 작성
- [ ] `go/crosstest/typescript_go_client/main.go` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd typescript && npx tsx crosstest/typescript_go.ts
```
기대 결과: `PASS all typescript-server/go-client crosstests passed`
---
## [CROSSTEST-6] TypeScript 서버 → Kotlin 클라이언트
### 문제
`typescript/crosstest/typescript_kotlin.ts` 와 `kotlin/crosstest/typescript_kotlin_client.kt` 가 없다.
### 해결 방법
**`typescript/crosstest/typescript_kotlin.ts`** (오케스트레이터)
`typescript_dart.ts`와 구조 동일. 포트 29810/29812.
Kotlin 클라이언트 호출 (`./gradlew run` 방식):
```typescript
async function runKotlinClient(mode: string, port: number, phase: string, expected: Set<string>): Promise<void> {
return new Promise((resolve, reject) => {
const child = childProcess.spawn(
"./gradlew",
["run",
"-PmainClass=com.tokilabs.toki_socket.crosstest.TypescriptKotlinClientKt",
`--args=--mode=${mode} --port=${port} --phase=${phase}`],
{ cwd: kotlinDir, stdio: ["ignore", "pipe", "pipe"] },
);
// stdout 파싱, validate
});
}
```
검증 메시지: `"fire from kotlin client"`, push: `"push from typescript server"`.
**`kotlin/crosstest/typescript_kotlin_client.kt`** (클라이언트 헬퍼)
`kotlin/crosstest/python_kotlin_client.kt`와 구조 동일.
변경점:
- `@file:JvmName("TypescriptKotlinClientKt")`
- `interface TypescriptClientHandle`, `TypescriptTcpHandle`, `TypescriptWsHandle`
- fire 메시지: `"fire from kotlin client"` (동일)
- 기대 push: `"push from typescript server"`
- single request 메시지: `"single request from kotlin"`
### 수정 파일 및 체크리스트
- [ ] `typescript/crosstest/typescript_kotlin.ts` 신규 작성
- [ ] `kotlin/crosstest/typescript_kotlin_client.kt` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd typescript && npx tsx crosstest/typescript_kotlin.ts
```
기대 결과: `PASS all typescript-server/kotlin-client crosstests passed`
---
## [CROSSTEST-7] TypeScript 서버 → Python 클라이언트
### 문제
`typescript/crosstest/typescript_python.ts` 와 `python/crosstest/typescript_python_client.py` 가 없다.
### 해결 방법
**`typescript/crosstest/typescript_python.ts`** (오케스트레이터)
`typescript_dart.ts`와 구조 동일. 포트 29814/29816.
Python 클라이언트 호출:
```typescript
async function runPythonClient(mode: string, port: number, phase: string, expected: Set<string>): Promise<void> {
return new Promise((resolve, reject) => {
const child = childProcess.spawn(
"python",
["-m", "crosstest.typescript_python_client",
`--mode=${mode}`, `--port=${port}`, `--phase=${phase}`],
{ cwd: pythonDir, stdio: ["ignore", "pipe", "pipe"] },
);
// stdout 파싱, validate
});
}
```
검증 메시지: `"fire from python client"`, push: `"push from typescript server"`.
**`python/crosstest/typescript_python_client.py`** (클라이언트 헬퍼)
`python/crosstest/go_python_client.py`와 구조 동일.
변경점:
- fire 메시지: `"fire from python client"` (동일)
- 기대 push: `message.message != "push from typescript server"`
- single request 메시지: `"single request from python"` (동일)
### 수정 파일 및 체크리스트
- [ ] `typescript/crosstest/typescript_python.ts` 신규 작성
- [ ] `python/crosstest/typescript_python_client.py` 신규 작성
### 테스트 작성
스킵.
### 중간 검증
```bash
cd typescript && npx tsx crosstest/typescript_python.ts
```
기대 결과: `PASS all typescript-server/python-client crosstests passed`
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `dart/crosstest/dart_typescript.dart` | CROSSTEST-1 |
| `typescript/crosstest/dart_typescript_client.ts` | CROSSTEST-1 |
| `kotlin/crosstest/kotlin_typescript.kt` | CROSSTEST-2 |
| `typescript/crosstest/kotlin_typescript_client.ts` | CROSSTEST-2 |
| `python/crosstest/python_typescript.py` | CROSSTEST-3 |
| `typescript/crosstest/python_typescript_client.ts` | CROSSTEST-3 |
| `typescript/crosstest/typescript_dart.ts` | CROSSTEST-4 |
| `dart/crosstest/typescript_dart_client.dart` | CROSSTEST-4 |
| `typescript/crosstest/typescript_go.ts` | CROSSTEST-5 |
| `go/crosstest/typescript_go_client/main.go` | CROSSTEST-5 |
| `typescript/crosstest/typescript_kotlin.ts` | CROSSTEST-6 |
| `kotlin/crosstest/typescript_kotlin_client.kt` | CROSSTEST-6 |
| `typescript/crosstest/typescript_python.ts` | CROSSTEST-7 |
| `python/crosstest/typescript_python_client.py` | CROSSTEST-7 |
---
## 최종 검증
```bash
# Phase 1
cd dart && dart run crosstest/dart_typescript.dart
cd kotlin && ./gradlew run -PmainClass=com.tokilabs.toki_socket.crosstest.KotlinTypescriptKt
cd python && python -m crosstest.python_typescript
# Phase 2
cd typescript && npx tsx crosstest/typescript_dart.ts
cd typescript && npx tsx crosstest/typescript_go.ts
cd typescript && npx tsx crosstest/typescript_kotlin.ts
cd typescript && npx tsx crosstest/typescript_python.ts
# 기존 Go→TypeScript 회귀 확인
cd go && go run ./crosstest/go_typescript.go
```
기대 결과: 모든 명령에서 `PASS all ...` 출력.

View file

@ -0,0 +1,90 @@
<!-- task=typescript_impl plan=2 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
## 개요
date=2026-04-24
task=typescript_impl, plan=2, tag=REVIEW_API
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md``code_review_2.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md``plan_2.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] ws를 devDependencies에서 dependencies로 이동 | [x] |
## 계획 대비 변경 사항
- 없음. 계획대로 `typescript/package.json`에서 `ws``dependencies`로 이동했고, `npm install``typescript/package-lock.json`의 top-level dependency 분류를 함께 갱신했다.
## 주요 설계 결정
- 런타임 import를 사용하는 패키지이므로 소스 코드 수정 없이 의존성 선언만 바로잡았다. `ws_client.ts`, `ws_server.ts`의 동작 자체는 바꾸지 않고 배포/설치 시점의 누락 위험만 제거했다.
- 버전은 유지하고 위치만 이동했다. 이번 수정의 목적은 동작 변경이 아니라 production install 시 `ws`가 빠지지 않도록 패키지 메타데이터를 정정하는 것이다.
## 리뷰어를 위한 체크포인트
- `typescript/package.json``dependencies``ws`가 있고 `devDependencies`에서 제거됐는지 확인.
- `package-lock.json`이 변경됐는지 확인 (`npm install` 실행 여부).
- `npx vitest run` 결과 18개 테스트 PASS인지 확인.
## 검증 결과
### REVIEW_API-1 중간 검증
```
$ cd typescript && npm install && npx tsc --noEmit && npx vitest run
up to date, audited 65 packages in 542ms
17 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/base_client.test.ts (7 tests) 9ms
✓ test/communicator.test.ts (7 tests) 18ms
✓ test/tcp.test.ts (2 tests) 11ms
✓ test/ws.test.ts (2 tests) 21ms
Test Files 4 passed (4)
Tests 18 passed (18)
Start at 05:37:16
Duration 801ms (transform 341ms, setup 0ms, collect 953ms, tests 58ms, environment 1ms, prepare 790ms)
```
### 최종 검증
```
$ cd typescript && npm install && npx tsc --noEmit && npx vitest run
up to date, audited 65 packages in 584ms
17 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/communicator.test.ts (7 tests) 18ms
✓ test/base_client.test.ts (7 tests) 8ms
✓ test/tcp.test.ts (2 tests) 11ms
✓ test/ws.test.ts (2 tests) 18ms
Test Files 4 passed (4)
Tests 18 passed (18)
Start at 05:37:23
Duration 867ms (transform 406ms, setup 0ms, collect 1.05s, tests 54ms, environment 1ms, prepare 977ms)
```

View file

@ -0,0 +1,115 @@
<!-- task=typescript_impl plan=2 tag=REVIEW_API -->
# ws 런타임 의존성 위치 수정
## 이 파일을 읽는 구현 에이전트에게
체크리스트를 완료하고 중간 검증 명령을 실행한 후, 출력을 `CODE_REVIEW.md``검증 결과` 섹션에 붙여넣어라. 계획과 다르게 구현한 부분은 `계획 대비 변경 사항`에 이유와 함께 기록하라.
---
## 배경
1차 리뷰(plan=0)에서 `ws` 패키지가 `devDependencies`에 잘못 배치된 것이 확인됐다. `ws_client.ts``ws_server.ts`가 런타임에 `ws`를 import하므로 `dependencies`로 이동해야 한다. 현재 `typescript/package.json:21``ws``devDependencies`에 위치한다.
---
### [REVIEW_API-1] ws를 devDependencies → dependencies로 이동
#### 문제
`typescript/package.json` (line 21):
```json
"devDependencies": {
...
"ws": "^8.18.1" ← 런타임 import에 쓰이는 패키지가 devDependencies에 있음
}
```
`typescript/src/ws_client.ts:1`:
```typescript
import WebSocket, { type RawData } from "ws";
```
`typescript/src/ws_server.ts:2`:
```typescript
import WebSocket, { WebSocketServer } from "ws";
```
두 파일 모두 런타임에 `ws`를 import한다. `npm install --omit=dev`로 설치하면 `ws`가 설치되지 않아 실행 시 모듈 not found 오류가 발생한다.
#### 해결 방법
`ws``devDependencies`에서 제거하고 `dependencies`에 추가한다.
```json
// 수정 전 (typescript/package.json)
{
"dependencies": {
"@bufbuild/protobuf": "^2.2.5"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.2.5",
"@types/node": "^22.15.21",
"@types/ws": "^8.18.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vitest": "^3.1.3",
"ws": "^8.18.1"
}
}
// 수정 후 (typescript/package.json)
{
"dependencies": {
"@bufbuild/protobuf": "^2.2.5",
"ws": "^8.18.1"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.2.5",
"@types/node": "^22.15.21",
"@types/ws": "^8.18.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
}
}
```
수정 후 `npm install``package-lock.json`을 갱신한다.
#### 수정 파일 및 체크리스트
- [ ] `typescript/package.json`: `ws``devDependencies`에서 제거, `dependencies`에 추가
- [ ] `npm install` 실행 → `typescript/package-lock.json` 갱신됨
#### 테스트 작성
스킵. `test/ws.test.ts`의 2개 테스트가 `ws` 런타임 동작을 이미 검증하며, 의존성 위치 변경은 동작에 영향을 주지 않는다.
#### 중간 검증
```bash
cd typescript && npm install && npx tsc --noEmit && npx vitest run
```
기대 결과: 타입 오류 없음, 16개 테스트 PASS.
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `typescript/package.json` | REVIEW_API-1 |
| `typescript/package-lock.json` | REVIEW_API-1 |
---
## 최종 검증
```bash
cd typescript && npm install && npx tsc --noEmit && npx vitest run
```
기대 결과: 타입 오류 없음, 16개 테스트 PASS.

View file

@ -0,0 +1,217 @@
<!-- task=typescript_impl plan=0 tag=API -->
# Code Review Reference - API
## 개요
date=2026-04-24
task=typescript_impl, plan=0, tag=API
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_0.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md` → `plan_0.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [API-1] 패키지 스캐폴드 및 Protobuf 바인딩 생성 | [x] |
| [API-2] Communicator 구현 | [x] |
| [API-3] BaseClient 구현 (하트비트 + disconnect 리스너) | [x] |
| [API-4] TcpClient 구현 (Node.js) | [x] |
| [API-5] TcpServer 구현 (Node.js) | [x] |
| [API-6] WsClient 구현 (Node.js / Browser) | [x] |
| [API-7] WsServer 구현 (Node.js) | [x] |
| [API-8] 크로스테스트 추가 (Go 서버 / TypeScript 클라이언트) | [x] |
## 계획 대비 변경 사항
- `tsconfig.json`의 `rootDir`를 `src` 대신 `.`으로 두었다. 계획서의 핵심 옵션은 유지하되, `test/`와 `crosstest/`까지 `npx tsc --noEmit` 한 번으로 함께 검사하려면 `rootDir=src`로는 TS6059 오류가 발생하기 때문이다.
- protobuf-es v2 생성 코드는 메시지 인스턴스 메서드(`toBinary()`, `fromBinary()`)가 아니라 스키마(`GenMessage`) 기반 직렬화/파싱을 사용한다. 그래서 `ParserMap`에 `parserFromSchema()` 헬퍼를 추가하고, `Communicator` 내부에 `typeName -> schema` 레지스트리를 함께 유지하는 방식으로 구현했다.
- `WsClient`는 계획서의 “Node.js / Browser” 표현과 달리 이번 범위에서는 `ws` 패키지 기반 Node.js 런타임만 직접 제공한다. 브라우저 호환성은 코어 계층(`Communicator`, generated types)이 유지하는 수준으로 제한했고, README에 TLS/WSS 및 browser transport wrapper 미지원으로 명시했다.
- `API-8` 구현 파일과 실행 경로는 추가했지만, 이 환경에는 `go` 바이너리가 없어 `go run ./crosstest/go_typescript.go`를 실제로 수행하지 못했다. 따라서 cross-language run은 코드 추가 완료 상태이며 실행 검증은 환경 의존 caveat로 남긴다.
## 주요 설계 결정
- protobuf-es v2 generated schema를 그대로 체크인하고, 런타임은 `create()/toBinary()/fromBinary()`를 사용했다.
- `Communicator`의 write path는 Go/Python의 channel/queue를 배열 기반 직렬 큐(`writeQueue` + `runWriteQueue()`)로 옮겨, 동시 `send()`/`sendRequest()`에서도 nonce 순서와 byte write 순서를 보장했다.
- `BaseClient.close()`는 멱등성을 위해 `closePromise`를 사용하고, `doClose()`가 실패하더라도 disconnect listener는 항상 호출되도록 `notifyDisconnected()`를 분리했다.
- TCP/WS 서버는 실제 테스트에서 ephemeral port(`0`)를 사용할 수 있게 `port` getter를 노출했다.
- Go 오케스트레이터는 기존 `go_python.go` 패턴을 복제해 `npx tsx crosstest/go_typescript_client.ts`를 subprocess로 실행하도록 했다.
## 리뷰어를 위한 체크포인트
- protobuf-es의 `typeName`이 `"TestData"`, `"HeartBeat"`, `"PacketBase"` (단순 이름)인지 확인. `INFO typeName ts=...` 로그 출력 확인.
- `addListener`/`addRequestListener` 상호 배타 위반 시 `Error`가 throw되는지 확인.
- `Communicator.shutdown()` 호출 시 모든 `pendingRequests`가 `NotConnectedError`로 reject되는지 확인.
- `TcpClient.onData()`에서 불완전한 패킷(버퍼 부족)이 왔을 때 다음 `data` 이벤트까지 기다리는지 확인.
- `BaseClient.close()` 멱등성: `isClosed` 플래그로 중복 실행을 막는지 확인.
- `writeQueue` 배열 기반 큐: 여러 `await queuePacket()` 동시 호출 시 순서가 보장되는지 확인.
- 크로스테스트 `go_typescript.go`에서 TypeScript 실행 명령이 환경에 맞게 경로 해결되는지 확인 (`tsx` 또는 `node dist/...`).
- TLS/WSS는 이번 범위 밖이므로 README에 미지원임을 명시했는지 확인.
## 검증 결과
_구현 에이전트가 각 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### API-1 중간 검증
```
$ cd typescript && npm install && npx tsc --noEmit
added 1 package, and audited 65 packages in 2s
17 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
```
### API-2 중간 검증
```
$ cd typescript && npx tsc --noEmit && npx vitest run test/communicator.test.ts
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/communicator.test.ts (7 tests) 29ms
Test Files 1 passed (1)
Tests 7 passed (7)
Start at 04:08:52
Duration 1.83s (transform 232ms, setup 0ms, collect 385ms, tests 29ms, environment 0ms, prepare 454ms)
```
### API-3 중간 검증
```
$ cd typescript && npx tsc --noEmit && npx vitest run test/base_client.test.ts
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/base_client.test.ts (5 tests) 17ms
Test Files 1 passed (1)
Tests 5 passed (5)
Start at 04:08:52
Duration 1.83s (transform 223ms, setup 0ms, collect 395ms, tests 17ms, environment 0ms, prepare 447ms)
```
### API-4 중간 검증
```
$ cd typescript && npx tsc --noEmit && npx vitest run test/tcp.test.ts
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/tcp.test.ts (2 tests) 13ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 04:08:51
Duration 1.96s (transform 280ms, setup 0ms, collect 468ms, tests 13ms, environment 0ms, prepare 778ms)
```
### API-5 중간 검증
```
$ cd typescript && npx tsc --noEmit
(no output, exit 0)
```
### API-6 중간 검증
```
$ cd typescript && npx tsc --noEmit && npx vitest run test/ws.test.ts
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/ws.test.ts (2 tests) 94ms
Test Files 1 passed (1)
Tests 2 passed (2)
Start at 04:08:51
Duration 2.23s (transform 307ms, setup 0ms, collect 582ms, tests 94ms, environment 0ms, prepare 767ms)
```
### API-7 중간 검증
```
$ cd typescript && npx tsc --noEmit
(no output, exit 0)
```
### API-8 중간 검증
```
$ cd go && go run ./crosstest/go_typescript.go
bash: line 1: go: command not found
```
### 최종 검증
```
$ cd typescript && npx tsc --noEmit && npx vitest run
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/base_client.test.ts (5 tests) 9ms
✓ test/communicator.test.ts (7 tests) 19ms
✓ test/tcp.test.ts (2 tests) 35ms
✓ test/ws.test.ts (2 tests) 35ms
Test Files 4 passed (4)
Tests 16 passed (16)
Start at 04:08:51
Duration 2.17s (transform 611ms, setup 0ms, collect 2.27s, tests 97ms, environment 0ms, prepare 3.78s)
$ cd go && go run ./crosstest/go_typescript.go
bash: line 1: go: command not found
```
---
## 코드리뷰 결과
### 종합 판정
**WARN**
### 차원별 평가
| 차원 | 평가 | 비고 |
|------|------|------|
| 정확성 (Correctness) | Pass | 프로토콜 준수, 상태 관리, nonce 단조 증가, 경쟁 조건 없음 |
| 완료도 (Completeness) | Pass | 계획 8개 항목 전부 구현됨 |
| 테스트 커버리지 (Test coverage) | Pass | 16개 테스트 전부 PASS. 계획 명세 항목 모두 존재 |
| API 계약 (API contract) | Warn | `ws`가 devDependencies에 있어 런타임 배포 시 누락 가능 |
| 코드 품질 (Code quality) | Pass | debug print, dead code, TODO 없음 |
| 계획 이탈 (Plan deviation) | Pass | 3가지 이탈 모두 이유가 타당하고 문서화됨 |
| 검증 신뢰도 (Verification trust) | Pass | 검증 출력이 실제 코드와 일치. `go` 실행 불가 caveat 명시됨 |
### 발견된 문제
**Suggested — 1건**
- `typescript/package.json:17`: `ws`가 `devDependencies`에 있으나 런타임 의존성이다. `ws_client.ts`와 `ws_server.ts` 모두 `ws`를 런타임 import한다. `npm install --omit=dev` 또는 프로덕션 설치 시 실패한다. `dependencies`로 이동할 것.
```diff
- "devDependencies": {
- ...
- "ws": "^8.18.1"
- }
+ "dependencies": {
+ "@bufbuild/protobuf": "^2.2.5",
+ "ws": "^8.18.1"
+ }
```
**Nit — 3건**
- `typescript/src/communicator.ts:334`: `encodeMessage()`는 `schemaMap`에 스키마가 등록된 타입만 직렬화할 수 있다. `parserFromSchema()` 없이 직접 파서를 등록하면 `send()` 시 `"protobuf schema is not registered"` 에러가 발생하는 제약이 문서화되지 않았다. API 주석 한 줄로 명시 권장.
- `typescript/src/ws_client.ts:85-86`: `Array.isArray(data)` 분기에서 각 chunk를 `Buffer.from(chunk)`로 감싼 후 concat한다. `ws` 패키지는 `Buffer[]`를 방출하므로 `Buffer.concat(data as Buffer[])` 가 더 직접적이다.
- `typescript/README.md:3` + `PROTOCOL.md:183`: 상태가 각각 `"planned"`, `"Planned"`로 남아 있다. `go` 환경에서 크로스테스트가 통과하면 `"Available"`로 갱신해야 한다. 현재는 환경 제약으로 인한 정당한 보류이므로 별도 액션 불필요.
### 다음 단계
WARN이므로 Suggested 항목(`ws` dependencies 이동)을 수정하는 새 PLAN.md + CODE_REVIEW.md 스텁을 작성한다.

View file

@ -0,0 +1,55 @@
<!-- task=typescript_impl plan=1 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
## 개요
date=2026-04-24
task=typescript_impl, plan=1, tag=REVIEW_API
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_1.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md` → `plan_1.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] ws를 devDependencies에서 dependencies로 이동 | [ ] |
## 계획 대비 변경 사항
_구현 에이전트가 계획과 다르게 구현한 부분을 이유와 함께 기록한다._
## 주요 설계 결정
_구현 에이전트가 주요 설계 결정 사항을 기록한다._
## 리뷰어를 위한 체크포인트
- `typescript/package.json`의 `dependencies`에 `ws`가 있고 `devDependencies`에서 제거됐는지 확인.
- `package-lock.json`이 갱신됐는지 확인 (`npm install` 실행 흔적).
- 기존 16개 테스트가 여전히 PASS인지 확인.
## 검증 결과
_구현 에이전트가 중간 검증 및 최종 검증 명령 실행 후 출력을 여기에 붙여 넣는다._
### REVIEW_API-1 중간 검증
```
$ cd typescript && npm install && npx tsc --noEmit && npx vitest run
(output)
```
### 최종 검증
```
$ cd typescript && npm install && npx tsc --noEmit && npx vitest run
(output)
```

View file

@ -0,0 +1,90 @@
<!-- task=typescript_impl plan=2 tag=REVIEW_API -->
# Code Review Reference - REVIEW_API
## 개요
date=2026-04-24
task=typescript_impl, plan=2, tag=REVIEW_API
## 이 파일을 읽는 리뷰 에이전트에게
각 항목의 구현을 실제 소스 파일과 대조하고, `검증 결과` 섹션의 출력이 코드와 일치하는지 확인하세요.
리뷰 완료 후 반드시 아래 순서로 아카이브하세요.
1. `CODE_REVIEW.md` → `code_review_2.log` (N = 기존 code_review_*.log 수)
2. `PLAN.md` → `plan_2.log` (M = 기존 plan_*.log 수)
3. PASS인 경우 `complete.log` 작성 후 종료. WARN/FAIL인 경우 새 `PLAN.md` + `CODE_REVIEW.md` 스텁 작성.
---
## 구현 항목별 완료 여부
| 항목 | 완료 여부 |
|------|---------|
| [REVIEW_API-1] ws를 devDependencies에서 dependencies로 이동 | [x] |
## 계획 대비 변경 사항
- 없음. 계획대로 `typescript/package.json`에서 `ws`를 `dependencies`로 이동했고, `npm install`로 `typescript/package-lock.json`의 top-level dependency 분류를 함께 갱신했다.
## 주요 설계 결정
- 런타임 import를 사용하는 패키지이므로 소스 코드 수정 없이 의존성 선언만 바로잡았다. `ws_client.ts`, `ws_server.ts`의 동작 자체는 바꾸지 않고 배포/설치 시점의 누락 위험만 제거했다.
- 버전은 유지하고 위치만 이동했다. 이번 수정의 목적은 동작 변경이 아니라 production install 시 `ws`가 빠지지 않도록 패키지 메타데이터를 정정하는 것이다.
## 리뷰어를 위한 체크포인트
- `typescript/package.json`의 `dependencies`에 `ws`가 있고 `devDependencies`에서 제거됐는지 확인.
- `package-lock.json`이 변경됐는지 확인 (`npm install` 실행 여부).
- `npx vitest run` 결과 18개 테스트 PASS인지 확인.
## 검증 결과
### REVIEW_API-1 중간 검증
```
$ cd typescript && npm install && npx tsc --noEmit && npx vitest run
up to date, audited 65 packages in 542ms
17 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/base_client.test.ts (7 tests) 9ms
✓ test/communicator.test.ts (7 tests) 18ms
✓ test/tcp.test.ts (2 tests) 11ms
✓ test/ws.test.ts (2 tests) 21ms
Test Files 4 passed (4)
Tests 18 passed (18)
Start at 05:37:16
Duration 801ms (transform 341ms, setup 0ms, collect 953ms, tests 58ms, environment 1ms, prepare 790ms)
```
### 최종 검증
```
$ cd typescript && npm install && npx tsc --noEmit && npx vitest run
up to date, audited 65 packages in 584ms
17 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
RUN v3.2.4 /config/workspace/toki_socket/typescript
✓ test/communicator.test.ts (7 tests) 18ms
✓ test/base_client.test.ts (7 tests) 8ms
✓ test/tcp.test.ts (2 tests) 11ms
✓ test/ws.test.ts (2 tests) 18ms
Test Files 4 passed (4)
Tests 18 passed (18)
Start at 05:37:23
Duration 867ms (transform 406ms, setup 0ms, collect 1.05s, tests 54ms, environment 1ms, prepare 977ms)
```

View file

@ -0,0 +1,18 @@
date=2026-04-24
task=typescript_impl
result=PASS
## 완료된 계획
| plan | tag | 내용 |
|------|------------|-------------------------------------------|
| 0 | REVIEW_API | 1차 리뷰 — ws devDependencies 문제 식별 |
| 1 | REVIEW_API | 2차 리뷰 (plan=1) — 동일 수정 계획 수립 |
| 2 | REVIEW_API | ws를 devDependencies → dependencies로 이동 |
## 최종 검증 결과
- typescript/package.json: ws가 dependencies에 위치, devDependencies에서 제거 확인
- typescript/package-lock.json: npm install로 갱신 확인
- npx tsc --noEmit: 타입 오류 없음
- npx vitest run: 18개 테스트 PASS (4 files)

View file

@ -0,0 +1,742 @@
<!-- task=typescript_impl plan=0 tag=API -->
# TypeScript 구현체 추가
## 이 파일을 읽는 구현 에이전트에게
각 항목의 체크리스트를 완료하면서 구현하라. 각 중간 검증 명령을 실행하고 그 출력을 `CODE_REVIEW.md`의 `검증 결과` 섹션에 붙여넣어라. 계획과 다르게 구현한 부분은 `계획 대비 변경 사항`에 이유와 함께 기록하라.
---
## 배경
Toki Socket 프로토콜 라이브러리는 현재 Dart, Go, Kotlin 구현체가 완료됐고 Python 구현체가 추가된 상태다. TypeScript는 브라우저(WebSocket)와 Node.js(TCP + WebSocket) 양쪽에서 사용되는 중요 플랫폼이다. `PORTING_GUIDE.md`와 `PROTOCOL.md`를 기준으로 TypeScript 구현체를 추가하고, Go 서버와의 크로스테스트까지 통과해야 한다.
---
## 의존 관계 및 구현 순서
API-1 → API-2 → API-3 → API-4 → API-5 → API-6 → API-7 → API-8
각 항목은 이전 항목의 완료를 전제한다.
---
### [API-1] 패키지 스캐폴드 및 Protobuf 바인딩 생성
#### 문제
`typescript/` 디렉터리가 존재하지 않는다. TypeScript 소스, 패키지 메타데이터, protobuf 바인딩이 모두 없다.
#### 해결 방법
1. `typescript/` 디렉터리를 만들고 `package.json`, `tsconfig.json`을 생성한다.
2. canonical proto(`dart/lib/src/packets/message_common.proto`)에서 protobuf-es 바인딩을 생성한다. `@bufbuild/buf` CLI 또는 `protoc`를 사용한다.
3. 생성된 파일을 `typescript/src/packets/`에 둔다.
**package.json 주요 의존성:**
```json
{
"name": "toki-socket",
"version": "0.1.0",
"type": "module",
"dependencies": {
"@bufbuild/protobuf": "^2.0.0"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/ws": "^8.0.0",
"typescript": "^5.5.0",
"vitest": "^2.0.0",
"ws": "^8.0.0"
}
}
```
**tsconfig.json 핵심 옵션:**
```json
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"outDir": "dist",
"rootDir": "src"
}
}
```
**protobuf-es 생성 명령:**
```bash
# buf CLI 사용 시
cd typescript
npx buf generate ../dart/lib/src/packets/message_common.proto
# 또는 직접 복사 후 protoc-gen-es 사용
```
protobuf-es가 환경에서 사용 불가능한 경우 `@bufbuild/protobuf`로 수동 타입 정의를 작성하는 대안도 허용한다. 단, typeName은 `"TestData"`, `"HeartBeat"`, `"PacketBase"` 그대로 유지해야 한다.
#### 수정 파일 및 체크리스트
- [ ] `typescript/package.json` 신규 생성
- [ ] `typescript/tsconfig.json` 신규 생성
- [ ] `typescript/src/packets/` 디렉터리 + 생성/수동 작성 파일
- [ ] `message_common_pb.ts` (또는 protobuf-es 생성 파일)
- [ ] `typescript/.gitignore` (`node_modules/`, `dist/`)
#### 테스트 작성
스킵. 패키지 구성 자체는 컴파일 성공 여부로 검증한다.
#### 중간 검증
```bash
cd typescript && npm install && npx tsc --noEmit
```
기대 결과: 오류 없이 종료.
---
### [API-2] Communicator 구현
#### 문제
핵심 라우팅·직렬화 클래스가 없다. Go의 `communicator.go`와 Python의 `communicator.py`에 해당하는 TypeScript 구현이 필요하다.
#### 해결 방법
`typescript/src/communicator.ts` 파일을 새로 생성한다.
**핵심 타입 및 인터페이스:**
```typescript
import { Message, MessageType } from "@bufbuild/protobuf";
import { PacketBase } from "./packets/message_common_pb.js";
export type ParserMap = Map<string, (data: Uint8Array) => Message>;
export interface Transport {
writePacket(base: PacketBase): Promise<void>;
close(): Promise<void>;
}
interface PendingRequest {
expectedTypeName: string;
resolve: (msg: Message) => void;
reject: (err: Error) => void;
}
interface QueueItem {
base: PacketBase;
resolve: () => void;
reject: (err: Error) => void;
}
```
**Communicator 클래스 구조:**
```typescript
export class Communicator {
private nonce = 0;
private isAliveFlag = false;
private parserMap: ParserMap = new Map();
private handlers = new Map<string, Array<(msg: Message) => void>>();
private reqHandlers = new Map<string, (msg: Message, nonce: number) => void>();
private pendingRequests = new Map<number, PendingRequest>();
private writeQueue: QueueItem[] = [];
private writeQueueRunning = false;
private transport: Transport | null = null;
private closedResolvers: Array<() => void> = [];
private writeErrorHandler: ((err: Error) => void) | null = null;
initialize(transport: Transport, parserMap: ParserMap): void { ... }
isAlive(): boolean { return this.isAliveFlag; }
setWriteErrorHandler(fn: (err: Error) => void): void { ... }
private nextNonce(): number { return ++this.nonce; }
shutdown(): void { ... }
async close(): Promise<void> { ... }
async queuePacket(base: PacketBase): Promise<void> { ... }
async send(msg: Message): Promise<void> { ... }
async sendRequest<TRes extends Message>(req: Message, resType: MessageType<TRes>, timeoutMs?: number): Promise<TRes> { ... }
addListener(typeName: string, fn: (msg: Message) => void): void { ... }
removeListeners(typeName: string): void { ... }
addRequestListener(typeName: string, fn: (msg: Message, nonce: number) => void): void { ... }
onReceivedData(typeName: string, data: Uint8Array, nonce: number, responseNonce: number): void { ... }
private handleResponse(typeName: string, data: Uint8Array, responseNonce: number): void { ... }
private parse(typeName: string, data: Uint8Array): Message { ... }
private removePending(nonce: number): PendingRequest | undefined { ... }
private runWriteQueue(): void { ... }
}
export function typeNameOf(msgOrType: Message | MessageType): string { ... }
export function addListenerTyped<T extends Message>(
comm: Communicator,
msgType: MessageType<T>,
fn: (msg: T) => void
): void { ... }
export function addRequestListenerTyped<TReq extends Message, TRes extends Message>(
comm: Communicator,
reqType: MessageType<TReq>,
fn: (req: TReq) => TRes | Promise<TRes | null> | null
): void { ... }
export async function sendRequestTyped<TReq extends Message, TRes extends Message>(
comm: Communicator,
req: TReq,
resType: MessageType<TRes>,
timeoutMs?: number
): Promise<TRes> { ... }
```
**writeQueue 구현 방식**: Go/Python의 channel/Queue와 달리 JS는 단일 스레드이므로 배열 기반 큐 + `runWriteQueue()` 패턴을 사용한다. `await` 구간마다 큐에서 한 항목씩 꺼내 처리하고, 처리 완료 후 다음 항목을 처리한다.
**typeNameOf**: `@bufbuild/protobuf`에서 `Message` 인스턴스의 경우 `msg.$typeName`으로, `MessageType` 객체의 경우 `msgType.typeName`으로 가져온다.
```typescript
export function typeNameOf(msgOrType: Message | MessageType): string {
if (typeof (msgOrType as MessageType).typeName === "string") {
return (msgOrType as MessageType).typeName;
}
return (msgOrType as Message).$typeName;
}
```
주의: protobuf-es에서 typeName은 `package.MessageName` 형태다. 현재 proto에 package 선언이 없으면 단순 `TestData`가 된다. 실행 시 반드시 확인해야 한다.
#### 수정 파일 및 체크리스트
- [ ] `typescript/src/communicator.ts` 신규 생성
- [ ] `ParserMap`, `Transport` 타입 정의
- [ ] `Communicator` 클래스 전체 구현
- [ ] `typeNameOf` 함수
- [ ] `addListenerTyped`, `addRequestListenerTyped`, `sendRequestTyped` 제네릭 헬퍼
#### 테스트 작성
`typescript/test/communicator.test.ts`를 작성한다.
| 테스트명 | 검증 목표 |
|----------|-----------|
| `addListener/addRequestListener mutual exclusion` | 동일 typeName에 양쪽 등록 시 `Error` 발생 |
| `send increments nonce` | 두 번 send 시 nonce가 1, 2로 증가 |
| `sendRequest resolves on response` | responseNonce로 올바른 Future 완료 |
| `sendRequest rejects on timeout` | 타임아웃 시 Error 발생 |
| `shutdown cancels pending requests` | shutdown 시 모든 pendingRequests에 NotConnectedError |
테스트용 Mock Transport:
```typescript
class MockTransport implements Transport {
packets: PacketBase[] = [];
async writePacket(base: PacketBase) { this.packets.push(base); }
async close() {}
}
```
#### 중간 검증
```bash
cd typescript && npx tsc --noEmit && npx vitest run test/communicator.test.ts
```
기대 결과: 모든 테스트 PASS.
---
### [API-3] BaseClient 구현 (하트비트 + disconnect 리스너)
#### 문제
하트비트 타이머 로직과 disconnect 리스너 관리가 없다. Go의 `base_client.go`, Python의 `base_client.py`에 해당한다.
#### 해결 방법
`typescript/src/base_client.ts`를 작성한다.
```typescript
export abstract class BaseClient {
readonly communicator: Communicator;
private intervalSec: number;
private waitSec: number;
private doClose: () => Promise<void>;
private isClosed = false;
private hbTimer: ReturnType<typeof setTimeout> | null = null;
private waitingHbResponse = false;
private disconnectListeners: Array<(self: this) => void | Promise<void>> = [];
constructor(intervalSec: number, waitSec: number, doClose: () => Promise<void>) { ... }
protected initBase(parserMap: ParserMap): void { ... }
async close(): Promise<void> {
if (this.isClosed) return;
this.isClosed = true;
this.communicator.shutdown();
this.stopHeartbeat();
await this.doClose();
await this.notifyDisconnected();
}
addDisconnectListener(fn: (self: this) => void | Promise<void>): void { ... }
removeDisconnectListeners(): void { ... }
protected async sendHeartbeat(): Promise<void> { ... }
protected async onHeartbeat(): Promise<void> { ... }
protected async onDisconnected(): Promise<void> { await this.close(); }
private stopHeartbeat(): void { ... }
private async notifyDisconnected(): Promise<void> { ... }
}
```
하트비트 타이머는 `setTimeout` + `clearTimeout`으로 구현한다. Python의 `_HbTimer`와 동일한 구조다.
#### 수정 파일 및 체크리스트
- [ ] `typescript/src/base_client.ts` 신규 생성
- [ ] `BaseClient` abstract class
- [ ] `close()` 멱등성 (`isClosed` 플래그)
- [ ] 하트비트 타이머 (`setTimeout`/`clearTimeout`)
- [ ] `addDisconnectListener`, `removeDisconnectListeners`
#### 테스트 작성
`typescript/test/base_client.test.ts`를 작성한다.
| 테스트명 | 검증 목표 |
|----------|-----------|
| `close is idempotent` | 여러 번 close 호출 시 doClose 1회만 실행 |
| `disconnect listeners called on close` | close 시 리스너 호출 |
| `heartbeat sends HeartBeat after interval` | intervalSec 후 HeartBeat 전송 확인 (vitest fake timers) |
| `heartbeat timeout triggers disconnect` | 응답 없이 waitSec 경과 시 onDisconnected 호출 |
| `onHeartbeat resets waiting flag` | 응답 HeartBeat 수신 시 waitingHbResponse=false |
#### 중간 검증
```bash
cd typescript && npx tsc --noEmit && npx vitest run test/base_client.test.ts
```
기대 결과: 모든 테스트 PASS.
---
### [API-4] TcpClient 구현 (Node.js)
#### 문제
Node.js TCP 소켓 클라이언트가 없다. Go의 `tcp_client.go`, Python의 `tcp_client.py`에 해당한다.
#### 해결 방법
`typescript/src/tcp_client.ts`를 작성한다. Node.js `net` 모듈을 사용하며, 브라우저에서는 사용 불가이므로 파일 상단에 주석으로 명시한다.
```typescript
import * as net from "node:net";
import { BaseClient } from "./base_client.js";
const MAX_PACKET_SIZE = 64 << 20;
export class TcpClient extends BaseClient {
private socket: net.Socket;
private readBuf = Buffer.alloc(0);
constructor(socket: net.Socket, intervalSec: number, waitSec: number, parserMap: ParserMap) {
super(intervalSec, waitSec, async () => {
socket.destroy();
});
this.socket = socket;
this.initBase(parserMap);
this.communicator.setWriteErrorHandler(() => this.onDisconnected());
addListenerTyped(this.communicator, HeartBeat, () => this.onHeartbeat());
socket.on("data", (chunk) => this.onData(chunk));
socket.on("error", () => this.onDisconnected());
socket.on("close", () => this.onDisconnected());
this.sendHeartbeat();
}
async writePacket(base: PacketBase): Promise<void> {
const data = base.toBinary();
const header = Buffer.allocUnsafe(4);
header.writeUInt32BE(data.length, 0);
await new Promise<void>((resolve, reject) => {
this.socket.write(Buffer.concat([header, data]), (err) => {
if (err) reject(err); else resolve();
});
});
}
private onData(chunk: Buffer): void {
this.readBuf = Buffer.concat([this.readBuf, chunk]);
while (this.readBuf.length >= 4) {
const length = this.readBuf.readUInt32BE(0);
if (length === 0) { this.readBuf = this.readBuf.subarray(4); continue; }
if (length > MAX_PACKET_SIZE) { this.onDisconnected(); return; }
if (this.readBuf.length < 4 + length) break;
const packetBytes = this.readBuf.subarray(4, 4 + length);
this.readBuf = this.readBuf.subarray(4 + length);
try {
const base = PacketBase.fromBinary(packetBytes);
this.communicator.onReceivedData(base.typeName, base.data, base.nonce, base.responseNonce);
this.sendHeartbeat();
} catch {
this.onDisconnected();
return;
}
}
}
}
export async function connectTcp(
host: string, port: number,
intervalSec: number, waitSec: number,
parserMap: ParserMap
): Promise<TcpClient> {
return new Promise((resolve, reject) => {
const socket = net.createConnection({ host, port }, () => {
resolve(new TcpClient(socket, intervalSec, waitSec, parserMap));
});
socket.once("error", reject);
});
}
```
`readBuf` 방식으로 TCP 스트림 버퍼링을 처리한다. 4바이트 빅엔디안 헤더 파싱 후 전체 패킷 도착을 기다린다.
#### 수정 파일 및 체크리스트
- [ ] `typescript/src/tcp_client.ts` 신규 생성
- [ ] `TcpClient extends BaseClient` 구현
- [ ] `writePacket`: 4바이트 빅엔디안 헤더 + protobuf 직렬화
- [ ] `onData`: 스트림 버퍼링 + 길이 프리픽스 파싱
- [ ] `connectTcp` 팩토리 함수
#### 테스트 작성
`typescript/test/tcp.test.ts`를 작성한다. Node.js `net` 모듈을 사용하는 실제 TCP 서버를 띄워 검증한다 (vitest에서 실제 소켓 테스트 가능).
| 테스트명 | 검증 목표 |
|----------|-----------|
| `connectTcp sends and receives TestData` | send → 서버 echo → client 수신 |
| `sendRequest/response roundtrip` | sendRequest 완료 + 값 검증 |
테스트 서버는 `TcpServer`(API-5 이후) 또는 직접 `net.createServer`로 구성한다.
#### 중간 검증
```bash
cd typescript && npx tsc --noEmit && npx vitest run test/tcp.test.ts
```
기대 결과: 모든 테스트 PASS.
---
### [API-5] TcpServer 구현 (Node.js)
#### 문제
TCP 서버가 없다. Go의 `tcp_server.go`, Python의 `tcp_server.py`에 해당한다.
#### 해결 방법
`typescript/src/tcp_server.ts`를 작성한다.
```typescript
import * as net from "node:net";
export class TcpServer {
private server: net.Server;
private clients: TcpClient[] = [];
onClientConnected: (client: TcpClient) => void = () => {};
constructor(
private host: string,
private port: number,
private newClient: (socket: net.Socket) => TcpClient
) {
this.server = net.createServer((socket) => {
const client = this.newClient(socket);
this.clients.push(client);
client.addDisconnectListener(() => this.removeClient(client));
this.onClientConnected(client);
});
}
start(): Promise<void> { ... }
stop(): Promise<void> { ... }
async broadcast(msg: Message): Promise<void> { ... }
private removeClient(client: TcpClient): void { ... }
}
```
#### 수정 파일 및 체크리스트
- [ ] `typescript/src/tcp_server.ts` 신규 생성
- [ ] `TcpServer` 클래스
- [ ] `start()`, `stop()`, `broadcast()`
#### 테스트 작성
API-4 테스트에서 TcpServer를 함께 사용하므로 별도 파일은 작성하지 않는다.
#### 중간 검증
```bash
cd typescript && npx tsc --noEmit
```
기대 결과: 타입 오류 없음.
---
### [API-6] WsClient 구현 (Node.js / Browser)
#### 문제
WebSocket 클라이언트가 없다. Go의 `ws_client.go`, Python의 `ws_client.py`에 해당한다.
#### 해결 방법
`typescript/src/ws_client.ts`를 작성한다. Node.js에서는 `ws` 패키지, 브라우저에서는 내장 `WebSocket`을 사용한다. Transport를 주입받는 설계로 환경 독립성을 유지한다.
실제 구현은 Node.js `ws` 패키지를 기본으로 한다:
```typescript
import WebSocket from "ws";
export class WsClient extends BaseClient {
private ws: WebSocket;
constructor(ws: WebSocket, intervalSec: number, waitSec: number, parserMap: ParserMap) {
super(intervalSec, waitSec, async () => {
ws.close();
});
this.ws = ws;
this.initBase(parserMap);
this.communicator.setWriteErrorHandler(() => this.onDisconnected());
addListenerTyped(this.communicator, HeartBeat, () => this.onHeartbeat());
ws.on("message", (data: Buffer) => {
try {
const base = PacketBase.fromBinary(new Uint8Array(data));
this.communicator.onReceivedData(base.typeName, base.data, base.nonce, base.responseNonce);
this.sendHeartbeat();
} catch { this.onDisconnected(); }
});
ws.on("close", () => this.onDisconnected());
ws.on("error", () => this.onDisconnected());
this.sendHeartbeat();
}
async writePacket(base: PacketBase): Promise<void> {
const data = base.toBinary();
return new Promise((resolve, reject) => {
this.ws.send(data, { binary: true }, (err) => {
if (err) reject(err); else resolve();
});
});
}
}
export async function connectWs(
host: string, port: number, path: string,
intervalSec: number, waitSec: number,
parserMap: ParserMap
): Promise<WsClient> { ... }
```
#### 수정 파일 및 체크리스트
- [ ] `typescript/src/ws_client.ts` 신규 생성
- [ ] `WsClient extends BaseClient` 구현
- [ ] `writePacket`: protobuf 바이너리 직렬화 후 binary 프레임 전송
- [ ] `connectWs` 팩토리 함수
#### 테스트 작성
`typescript/test/ws.test.ts`를 작성한다. `ws` 패키지의 `WebSocket.Server`로 로컬 서버를 구성한다.
| 테스트명 | 검증 목표 |
|----------|-----------|
| `connectWs sends and receives TestData` | send → echo → client 수신 |
| `sendRequest/response roundtrip over WS` | sendRequest 완료 + 값 검증 |
#### 중간 검증
```bash
cd typescript && npx tsc --noEmit && npx vitest run test/ws.test.ts
```
기대 결과: 모든 테스트 PASS.
---
### [API-7] WsServer 구현 (Node.js)
#### 문제
WebSocket 서버가 없다. Go의 `ws_server.go`, Python의 `ws_server.py`에 해당한다.
#### 해결 방법
`typescript/src/ws_server.ts`를 작성한다. `ws` 패키지의 `WebSocket.Server`를 사용한다.
```typescript
import { WebSocketServer, WebSocket } from "ws";
export class WsServer {
private wss: WebSocketServer | null = null;
private clients: WsClient[] = [];
onClientConnected: (client: WsClient) => void = () => {};
constructor(
private host: string,
private port: number,
private path: string,
private newClient: (ws: WebSocket) => WsClient
) {}
start(): Promise<void> { ... }
stop(): Promise<void> { ... }
async broadcast(msg: Message): Promise<void> { ... }
private removeClient(client: WsClient): void { ... }
}
```
#### 수정 파일 및 체크리스트
- [ ] `typescript/src/ws_server.ts` 신규 생성
- [ ] `WsServer` 클래스
- [ ] `start()`, `stop()`, `broadcast()`
#### 테스트 작성
API-6 테스트에서 WsServer를 함께 사용하므로 별도 파일은 작성하지 않는다.
#### 중간 검증
```bash
cd typescript && npx tsc --noEmit
```
기대 결과: 타입 오류 없음.
---
### [API-8] 크로스테스트 추가 (Go 서버 / TypeScript 클라이언트)
#### 문제
TypeScript 클라이언트와 Go 서버 간 프로토콜 호환성을 검증하는 크로스테스트가 없다.
#### 해결 방법
Go 서버 orchestrator + TypeScript 클라이언트 방식으로 구성한다.
**포트 배정** (기존 할당과 충돌하지 않는 값 사용):
- Go server / TypeScript client TCP: `29490`
- Go server / TypeScript client WS: `29492`
**파일:**
1. `go/crosstest/go_typescript.go` — Go 오케스트레이터 (Go 서버 시작 + TypeScript 클라이언트 서브프로세스 실행)
2. `typescript/crosstest/go_typescript_client.ts` — TypeScript 클라이언트 (4개 시나리오 실행)
**go_typescript.go 구조** (go_python.go 참조):
```go
//go:build ignore
package main
const (
goPythonTCPPort = 29490
goPythonWSPort = 29492
)
func runTypescriptClient(mode, port, phase, ...) error {
cmd := exec.CommandContext(ctx, "node", "--input-type=module",
// 또는 tsx, ts-node 등 TypeScript 실행 방법
)
// ...
}
```
TypeScript 클라이언트 실행 방법: `tsx crosstest/go_typescript_client.ts` (개발 환경) 또는 컴파일 후 `node dist/crosstest/go_typescript_client.js`를 사용한다. `tsx`를 devDependency로 추가한다.
**go_typescript_client.ts 구조** (go_python_client.py 참조):
```typescript
// 4개 시나리오: send-push (1,2), requests (3,4)
// PASS scenario=N detail=...
// FAIL scenario=N error=...
```
**크로스테스트 시나리오:**
| 시나리오 | 검증 내용 |
|----------|-----------|
| 1 | TypeScript 클라이언트 `send(TestData{index:101, message:"fire from typescript client"})` → Go 서버 수신 확인 |
| 2 | Go 서버 `Send(TestData{index:200, message:"push from go server"})` → TypeScript 수신 확인 |
| 3 | TypeScript `sendRequest(TestData{index:21})` → Go 서버 응답 `{index:42, message:"echo: ..."}` 확인 |
| 4 | 5개 concurrent `sendRequest` → 모든 nonce/responseNonce 매핑 검증 |
TCP와 WebSocket 양쪽으로 4개 시나리오를 실행한다. `send-push`와 `requests` 단계로 분리한다.
#### 수정 파일 및 체크리스트
- [ ] `go/crosstest/go_typescript.go` 신규 생성
- [ ] Go 서버 시작 (TCP + WS)
- [ ] TypeScript 클라이언트 서브프로세스 실행
- [ ] PASS/FAIL 파싱 및 검증
- [ ] `typescript/crosstest/go_typescript_client.ts` 신규 생성
- [ ] `--mode`, `--port`, `--phase` 인수 파싱
- [ ] `send-push` 페이즈: 시나리오 1, 2
- [ ] `requests` 페이즈: 시나리오 3, 4
- [ ] `typescript/package.json`에 `tsx` devDependency 추가
#### 테스트 작성
크로스테스트 자체가 통합 테스트다. 별도 단위 테스트는 작성하지 않는다.
#### 중간 검증
```bash
# TypeScript 클라이언트 직접 실행 테스트 (Go 서버 먼저 수동 실행 필요)
cd typescript && npx tsx crosstest/go_typescript_client.ts --mode=tcp --port=29490 --phase=send-push
# Go 오케스트레이터 전체 실행
cd go && go run ./crosstest/go_typescript.go
```
기대 결과: `PASS all go-server/typescript-client crosstests passed`
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `typescript/package.json` | API-1 |
| `typescript/tsconfig.json` | API-1 |
| `typescript/.gitignore` | API-1 |
| `typescript/src/packets/message_common_pb.ts` | API-1 |
| `typescript/src/communicator.ts` | API-2 |
| `typescript/src/base_client.ts` | API-3 |
| `typescript/src/tcp_client.ts` | API-4 |
| `typescript/src/tcp_server.ts` | API-5 |
| `typescript/src/ws_client.ts` | API-6 |
| `typescript/src/ws_server.ts` | API-7 |
| `typescript/test/communicator.test.ts` | API-2 |
| `typescript/test/base_client.test.ts` | API-3 |
| `typescript/test/tcp.test.ts` | API-4 |
| `typescript/test/ws.test.ts` | API-6 |
| `typescript/crosstest/go_typescript_client.ts` | API-8 |
| `go/crosstest/go_typescript.go` | API-8 |
---
## 최종 검증
```bash
# 1. TypeScript 컴파일 검사
cd typescript && npx tsc --noEmit
# 2. 단위 테스트 전체 실행
cd typescript && npx vitest run
# 3. Go 크로스테스트 (Go 환경 필요)
cd go && go run ./crosstest/go_typescript.go
```
기대 결과:
1. 타입 오류 없음
2. 모든 vitest 테스트 PASS
3. `PASS all go-server/typescript-client crosstests passed`

View file

@ -0,0 +1,91 @@
<!-- task=typescript_impl plan=1 tag=REVIEW_API -->
# ws를 dependencies로 이동
## 이 파일을 읽는 구현 에이전트에게
체크리스트를 완료하고, 중간 검증 명령을 실행한 후 출력을 `CODE_REVIEW.md`의 `검증 결과` 섹션에 붙여넣어라. 계획과 다르게 구현한 부분은 `계획 대비 변경 사항`에 이유와 함께 기록하라.
---
## 배경
1차 리뷰에서 `ws` 패키지가 `devDependencies`에 잘못 배치된 것이 발견됐다. `ws_client.ts`와 `ws_server.ts`가 모두 `ws`를 런타임에 import하므로, `npm install --omit=dev` 또는 프로덕션 설치 환경에서 실패한다. `dependencies`로 이동해야 한다.
---
### [REVIEW_API-1] ws 패키지를 devDependencies에서 dependencies로 이동
#### 문제
`typescript/package.json` (현재 상태):
```json
"dependencies": {
"@bufbuild/protobuf": "^2.2.5"
},
"devDependencies": {
...
"ws": "^8.18.1" ← 런타임 의존성인데 devDependencies에 있음
}
```
`ws_client.ts:1`과 `ws_server.ts:2`에서 `import WebSocket from "ws"`, `import { WebSocketServer } from "ws"`로 런타임에 사용한다.
#### 해결 방법
`ws`를 `dependencies`로 이동한다.
```diff
"dependencies": {
- "@bufbuild/protobuf": "^2.2.5"
+ "@bufbuild/protobuf": "^2.2.5",
+ "ws": "^8.18.1"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.2.5",
"@types/node": "^22.15.21",
"@types/ws": "^8.18.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
- "vitest": "^3.1.3",
- "ws": "^8.18.1"
+ "vitest": "^3.1.3"
}
```
그 후 `npm install`을 실행해 `package-lock.json`을 갱신한다.
#### 수정 파일 및 체크리스트
- [ ] `typescript/package.json`: `ws`를 `devDependencies`에서 제거, `dependencies`에 추가
- [ ] `npm install` 실행 후 `typescript/package-lock.json` 갱신
#### 테스트 작성
스킵. 기존 ws 테스트(`test/ws.test.ts`)가 동작 검증을 충분히 커버한다.
#### 중간 검증
```bash
cd typescript && npm install && npx tsc --noEmit && npx vitest run
```
기대 결과: 타입 오류 없음, 16개 테스트 PASS.
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `typescript/package.json` | REVIEW_API-1 |
| `typescript/package-lock.json` | REVIEW_API-1 |
---
## 최종 검증
```bash
cd typescript && npm install && npx tsc --noEmit && npx vitest run
```
기대 결과: 타입 오류 없음, 16개 테스트 PASS.

View file

@ -0,0 +1,115 @@
<!-- task=typescript_impl plan=2 tag=REVIEW_API -->
# ws 런타임 의존성 위치 수정
## 이 파일을 읽는 구현 에이전트에게
체크리스트를 완료하고 중간 검증 명령을 실행한 후, 출력을 `CODE_REVIEW.md`의 `검증 결과` 섹션에 붙여넣어라. 계획과 다르게 구현한 부분은 `계획 대비 변경 사항`에 이유와 함께 기록하라.
---
## 배경
1차 리뷰(plan=0)에서 `ws` 패키지가 `devDependencies`에 잘못 배치된 것이 확인됐다. `ws_client.ts`와 `ws_server.ts`가 런타임에 `ws`를 import하므로 `dependencies`로 이동해야 한다. 현재 `typescript/package.json:21`에 `ws`가 `devDependencies`에 위치한다.
---
### [REVIEW_API-1] ws를 devDependencies → dependencies로 이동
#### 문제
`typescript/package.json` (line 21):
```json
"devDependencies": {
...
"ws": "^8.18.1" ← 런타임 import에 쓰이는 패키지가 devDependencies에 있음
}
```
`typescript/src/ws_client.ts:1`:
```typescript
import WebSocket, { type RawData } from "ws";
```
`typescript/src/ws_server.ts:2`:
```typescript
import WebSocket, { WebSocketServer } from "ws";
```
두 파일 모두 런타임에 `ws`를 import한다. `npm install --omit=dev`로 설치하면 `ws`가 설치되지 않아 실행 시 모듈 not found 오류가 발생한다.
#### 해결 방법
`ws`를 `devDependencies`에서 제거하고 `dependencies`에 추가한다.
```json
// 수정 전 (typescript/package.json)
{
"dependencies": {
"@bufbuild/protobuf": "^2.2.5"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.2.5",
"@types/node": "^22.15.21",
"@types/ws": "^8.18.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vitest": "^3.1.3",
"ws": "^8.18.1"
}
}
// 수정 후 (typescript/package.json)
{
"dependencies": {
"@bufbuild/protobuf": "^2.2.5",
"ws": "^8.18.1"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.2.5",
"@types/node": "^22.15.21",
"@types/ws": "^8.18.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
}
}
```
수정 후 `npm install`로 `package-lock.json`을 갱신한다.
#### 수정 파일 및 체크리스트
- [ ] `typescript/package.json`: `ws`를 `devDependencies`에서 제거, `dependencies`에 추가
- [ ] `npm install` 실행 → `typescript/package-lock.json` 갱신됨
#### 테스트 작성
스킵. `test/ws.test.ts`의 2개 테스트가 `ws` 런타임 동작을 이미 검증하며, 의존성 위치 변경은 동작에 영향을 주지 않는다.
#### 중간 검증
```bash
cd typescript && npm install && npx tsc --noEmit && npx vitest run
```
기대 결과: 타입 오류 없음, 16개 테스트 PASS.
---
## 수정 파일 요약
| 파일 | 항목 |
|------|------|
| `typescript/package.json` | REVIEW_API-1 |
| `typescript/package-lock.json` | REVIEW_API-1 |
---
## 최종 검증
```bash
cd typescript && npm install && npx tsc --noEmit && npx vitest run
```
기대 결과: 타입 오류 없음, 16개 테스트 PASS.

View file

@ -0,0 +1,238 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:toki_socket/toki_socket.dart';
const _host = '127.0.0.1';
const _tcpPort = 29790;
const _wsPort = 29792;
const _heartbeatIntervalSeconds = 30;
const _heartbeatWaitSeconds = 10;
const _serverObservationWindow = Duration(milliseconds: 200);
const _processTimeout = Duration(seconds: 20);
final _repoRoot = File.fromUri(Platform.script).parent.parent.parent.path;
final _typescriptDir = Directory('$_repoRoot/typescript').path;
class _DartTcpClient extends ProtobufClient {
_DartTcpClient(Socket socket)
: super(socket, _heartbeatIntervalSeconds, _heartbeatWaitSeconds, {
TestData.getDefault().info_.qualifiedMessageName: TestData.fromBuffer,
});
}
class _DartWsClient extends WsProtobufClient {
_DartWsClient(WebSocket ws)
: super(ws, _heartbeatIntervalSeconds, _heartbeatWaitSeconds, {
TestData.getDefault().info_.qualifiedMessageName: TestData.fromBuffer,
});
}
class _DartTcpServer extends ProtobufServer {
final void Function(ProtobufClient) onConnected;
_DartTcpServer(int port, this.onConnected)
: super(_host, port, (socket) => _DartTcpClient(socket));
@override
void onClientConnected(ProtobufClient client) => onConnected(client);
}
class _DartWsServer extends WsProtobufServer {
final void Function(WsProtobufClient) onConnected;
_DartWsServer(int port, this.onConnected)
: super(_host, port, (ws) => _DartWsClient(ws));
@override
void onClientConnected(WsProtobufClient client) => onConnected(client);
}
Future<void> main() async {
print(
'INFO typeName dart=${TestData.getDefault().info_.qualifiedMessageName}');
try {
await _runTcp();
await _runWs();
print('PASS all dart-server/typescript-client crosstests passed');
} catch (error) {
stderr.writeln('FAIL crosstest error=$error');
exitCode = 1;
}
}
Future<void> _runTcp() async {
await _runTcpSendPush();
await Future<void>.delayed(const Duration(milliseconds: 150));
await _runTcpRequests();
}
Future<void> _runWs() async {
await _runWsSendPush();
await Future<void>.delayed(const Duration(milliseconds: 150));
await _runWsRequests();
}
Future<void> _runTcpSendPush() async {
final received = Completer<bool>();
final server = _DartTcpServer(_tcpPort, (client) {
client.addListener<TestData>((data) {
print('SERVER_RECEIVED index=${data.index} message=${data.message}');
final valid =
data.index == 101 && data.message == 'fire from typescript client';
if (!received.isCompleted) {
received.complete(valid);
}
if (valid) {
unawaited(client.send(TestData()
..index = 200
..message = 'push from dart server'));
}
});
});
await _withServer(server.start, server.stop, () async {
await _runTypescriptClient('tcp', _tcpPort, 'send-push', {'1', '2'});
final ok = await received.future
.timeout(_serverObservationWindow, onTimeout: () => false);
if (!ok) {
throw StateError('TCP send-push server did not receive expected data');
}
});
}
Future<void> _runTcpRequests() async {
final server = _DartTcpServer(_tcpPort, (client) {
client.addRequestListener<TestData, TestData>((req) async {
return TestData()
..index = req.index * 2
..message = 'echo: ${req.message}';
});
});
await _withServer(
server.start,
server.stop,
() => _runTypescriptClient('tcp', _tcpPort, 'requests', {'3', '4'}),
);
}
Future<void> _runWsSendPush() async {
final received = Completer<bool>();
final server = _DartWsServer(_wsPort, (client) {
client.addListener<TestData>((data) {
print('SERVER_RECEIVED index=${data.index} message=${data.message}');
final valid =
data.index == 101 && data.message == 'fire from typescript client';
if (!received.isCompleted) {
received.complete(valid);
}
if (valid) {
unawaited(client.send(TestData()
..index = 200
..message = 'push from dart server'));
}
});
});
await _withServer(server.start, server.stop, () async {
await _runTypescriptClient('ws', _wsPort, 'send-push', {'1', '2'});
final ok = await received.future
.timeout(_serverObservationWindow, onTimeout: () => false);
if (!ok) {
throw StateError('WS send-push server did not receive expected data');
}
});
}
Future<void> _runWsRequests() async {
final server = _DartWsServer(_wsPort, (client) {
client.addRequestListener<TestData, TestData>((req) async {
return TestData()
..index = req.index * 2
..message = 'echo: ${req.message}';
});
});
await _withServer(
server.start,
server.stop,
() => _runTypescriptClient('ws', _wsPort, 'requests', {'3', '4'}),
);
}
Future<void> _withServer(Future<void> Function() start,
Future<void> Function() stop, Future<void> Function() body) async {
await start();
try {
await body();
} finally {
await stop();
}
}
Future<void> _runTypescriptClient(
String mode,
int port,
String phase,
Set<String> expectedScenarios,
) async {
final process = await Process.start(
'npx',
[
'tsx',
'crosstest/dart_typescript_client.ts',
'--mode=$mode',
'--port=$port',
'--phase=$phase',
],
workingDirectory: _typescriptDir,
);
final resultLines = <String>[];
final stdoutDone = process.stdout
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen((line) {
print(line);
if (line.startsWith('PASS ') || line.startsWith('FAIL ')) {
resultLines.add(line);
}
}).asFuture<void>();
final stderrDone = process.stderr
.transform(utf8.decoder)
.transform(const LineSplitter())
.listen(stderr.writeln)
.asFuture<void>();
final code = await process.exitCode.timeout(_processTimeout, onTimeout: () {
process.kill(ProcessSignal.sigkill);
return -1;
});
await Future.wait([stdoutDone, stderrDone]);
_validateResultLines(
'typescript-client $mode/$phase',
code,
resultLines,
expectedScenarios,
);
}
void _validateResultLines(
String label,
int exitCode,
List<String> lines,
Set<String> expectedScenarios,
) {
final failed = lines.where((line) => line.startsWith('FAIL ')).toList();
final passed = <String>{};
for (final line in lines.where((line) => line.startsWith('PASS '))) {
final match = RegExp(r'scenario=([^ ]+)').firstMatch(line);
if (match != null) {
passed.add(match.group(1)!);
}
}
final missing = expectedScenarios.difference(passed);
if (exitCode != 0 || failed.isNotEmpty || missing.isNotEmpty) {
throw StateError(
'$label failed exitCode=$exitCode failed=$failed missing=$missing');
}
}

View file

@ -0,0 +1,193 @@
import 'dart:async';
import 'dart:io';
import 'package:toki_socket/toki_socket.dart';
const _host = '127.0.0.1';
const _wsPath = '/';
const _heartbeatIntervalSeconds = 30;
const _heartbeatWaitSeconds = 10;
const _connectWindow = Duration(seconds: 3);
const _requestWindow = Duration(seconds: 2);
class _TcpClient extends ProtobufClient {
_TcpClient(Socket socket)
: super(socket, _heartbeatIntervalSeconds, _heartbeatWaitSeconds, {
TestData.getDefault().info_.qualifiedMessageName: TestData.fromBuffer,
});
}
class _WsClient extends WsProtobufClient {
_WsClient(WebSocket ws)
: super(ws, _heartbeatIntervalSeconds, _heartbeatWaitSeconds, {
TestData.getDefault().info_.qualifiedMessageName: TestData.fromBuffer,
});
}
class _ClientHandle {
final Communicator client;
final Future<void> Function() close;
_ClientHandle(this.client, this.close);
}
Future<void> main(List<String> args) async {
final mode = _argValue(args, 'mode') ?? 'tcp';
final phase = _argValue(args, 'phase') ?? 'send-push';
final port = int.tryParse(_argValue(args, 'port') ?? '');
print(
'INFO typeName dart=${TestData.getDefault().info_.qualifiedMessageName}');
if (port == null) {
_fail('setup', 'port is required');
exitCode = 1;
return;
}
late _ClientHandle handle;
try {
handle = await _connectWithRetry(mode, port);
} catch (error) {
_fail('setup', error.toString());
exitCode = 1;
return;
}
var ok = false;
try {
switch (phase) {
case 'send-push':
ok = await _runSendPush(handle.client);
break;
case 'requests':
ok = await _runRequests(handle.client);
break;
default:
_fail('setup', 'unknown phase "$phase"');
}
} finally {
await handle.close();
}
if (!ok) {
exitCode = 1;
}
exit(exitCode);
}
Future<_ClientHandle> _connectWithRetry(String mode, int port) async {
final deadline = DateTime.now().add(_connectWindow);
Object? lastError;
while (DateTime.now().isBefore(deadline)) {
try {
switch (mode) {
case 'tcp':
final socket = await ProtobufClient.connect(_host, port)
.timeout(const Duration(milliseconds: 300));
final client = _TcpClient(socket);
return _ClientHandle(client, client.close);
case 'ws':
final ws = await WsProtobufClient.connect(_host, port, path: _wsPath)
.timeout(const Duration(milliseconds: 300));
final client = _WsClient(ws);
return _ClientHandle(client, client.close);
default:
throw ArgumentError('unknown mode "$mode"');
}
} catch (error) {
lastError = error;
await Future<void>.delayed(const Duration(milliseconds: 100));
}
}
throw TimeoutException(
'connect $mode:$port timed out: $lastError', _connectWindow);
}
Future<bool> _runSendPush(Communicator client) async {
final pushCompleter = Completer<TestData>();
client.addListener<TestData>((data) {
if (!pushCompleter.isCompleted) {
pushCompleter.complete(data);
}
});
await client.send(TestData()
..index = 101
..message = 'fire from dart client');
_pass('1', 'fire-and-forget sent');
try {
final push = await pushCompleter.future.timeout(_requestWindow);
if (push.index != 200 || push.message != 'push from typescript server') {
_fail(
'2', 'unexpected push index=${push.index} message="${push.message}"');
return false;
}
_pass('2', 'received push from typescript server');
return true;
} catch (error) {
_fail('2', error.toString());
return false;
}
}
Future<bool> _runRequests(Communicator client) async {
try {
final single = await client
.sendRequest<TestData, TestData>(TestData()
..index = 21
..message = 'single request from dart')
.timeout(_requestWindow);
if (single.index != 42 ||
single.message != 'echo: single request from dart') {
_fail('3',
'unexpected response index=${single.index} message="${single.message}"');
return false;
}
_pass('3', 'single request response matched');
final futures = <Future<void>>[];
for (var i = 0; i < 5; i++) {
futures.add(() async {
final index = 30 + i;
final message = 'multi request $i from dart';
final res = await client
.sendRequest<TestData, TestData>(TestData()
..index = index
..message = message)
.timeout(_requestWindow);
if (res.index != index * 2 || res.message != 'echo: $message') {
throw StateError(
'request $i got index=${res.index} message="${res.message}"');
}
}());
}
await Future.wait(futures);
_pass('4', 'concurrent request responses matched');
return true;
} catch (error) {
_fail('4', error.toString());
return false;
}
}
String? _argValue(List<String> args, String name) {
final prefix = '--$name=';
for (final arg in args) {
if (arg.startsWith(prefix)) {
return arg.substring(prefix.length);
}
}
return null;
}
void _pass(String scenario, String detail) {
print('PASS scenario=$scenario detail=$detail');
}
void _fail(String scenario, String error) {
print('FAIL scenario=$scenario error=$error');
}

View file

@ -0,0 +1,330 @@
//go:build ignore
package main
import (
"bufio"
"context"
"errors"
"fmt"
"io"
"net"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sync"
"time"
"google.golang.org/protobuf/proto"
"nhooyr.io/websocket"
toki "toki-labs.com/toki_socket/go"
"toki-labs.com/toki_socket/go/packets"
)
const (
host = "127.0.0.1"
goTypescriptTCPPort = 29490
goTypescriptWSPort = 29492
wsPath = "/"
processTimeout = 20 * time.Second
serverObservationWindow = 200 * time.Millisecond
)
func parserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&packets.TestData{}): func(b []byte) (proto.Message, error) {
m := &packets.TestData{}
return m, proto.Unmarshal(b, m)
},
}
}
func main() {
fmt.Printf("INFO typeName go=%s\n", toki.TypeNameOf(&packets.TestData{}))
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "FAIL crosstest error=%v\n", err)
os.Exit(1)
}
fmt.Println("PASS all go-server/typescript-client crosstests passed")
}
func run() error {
if err := runTCPSendPush(); err != nil {
return err
}
time.Sleep(150 * time.Millisecond)
if err := runTCPRequests(); err != nil {
return err
}
time.Sleep(150 * time.Millisecond)
if err := runWSSendPush(); err != nil {
return err
}
time.Sleep(150 * time.Millisecond)
return runWSRequests()
}
func runTCPSendPush() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
received := make(chan bool, 1)
server := toki.NewTcpServer(host, goTypescriptTCPPort, func(conn net.Conn) *toki.TcpClient {
return toki.NewTcpClient(conn, 0, 0, parserMap())
})
server.OnClientConnected = func(client *toki.TcpClient) {
toki.AddListenerTyped[*packets.TestData](&client.Communicator, func(data *packets.TestData) {
fmt.Printf("SERVER_RECEIVED index=%d message=%s\n", data.GetIndex(), data.GetMessage())
valid := data.GetIndex() == 101 && data.GetMessage() == "fire from typescript client"
select {
case received <- valid:
default:
}
if valid {
_ = client.Send(&packets.TestData{Index: 200, Message: "push from go server"})
}
})
}
if err := server.Start(ctx); err != nil {
return err
}
defer server.Stop()
if err := runTypescriptClient("tcp", goTypescriptTCPPort, "send-push", map[string]bool{"1": true, "2": true}); err != nil {
return err
}
select {
case ok := <-received:
if !ok {
return errors.New("TCP send-push server received unexpected data")
}
case <-time.After(serverObservationWindow):
return errors.New("TCP send-push server did not receive expected data")
}
return nil
}
func runTCPRequests() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
server := toki.NewTcpServer(host, goTypescriptTCPPort, func(conn net.Conn) *toki.TcpClient {
return toki.NewTcpClient(conn, 0, 0, parserMap())
})
server.OnClientConnected = func(client *toki.TcpClient) {
toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](&client.Communicator, func(req *packets.TestData) (*packets.TestData, error) {
return &packets.TestData{
Index: req.GetIndex() * 2,
Message: "echo: " + req.GetMessage(),
}, nil
})
}
if err := server.Start(ctx); err != nil {
return err
}
defer server.Stop()
return runTypescriptClient("tcp", goTypescriptTCPPort, "requests", map[string]bool{"3": true, "4": true})
}
func runWSSendPush() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
received := make(chan bool, 1)
server := toki.NewWsServer(host, goTypescriptWSPort, wsPath, func(conn *websocket.Conn) *toki.WsClient {
return toki.NewWsClient(conn, 0, 0, parserMap())
})
server.OnClientConnected = func(client *toki.WsClient) {
toki.AddListenerTyped[*packets.TestData](&client.Communicator, func(data *packets.TestData) {
fmt.Printf("SERVER_RECEIVED index=%d message=%s\n", data.GetIndex(), data.GetMessage())
valid := data.GetIndex() == 101 && data.GetMessage() == "fire from typescript client"
select {
case received <- valid:
default:
}
if valid {
_ = client.Send(&packets.TestData{Index: 200, Message: "push from go server"})
}
})
}
if err := server.Start(ctx); err != nil {
return err
}
defer server.Stop()
if err := runTypescriptClient("ws", goTypescriptWSPort, "send-push", map[string]bool{"1": true, "2": true}); err != nil {
return err
}
select {
case ok := <-received:
if !ok {
return errors.New("WS send-push server received unexpected data")
}
case <-time.After(serverObservationWindow):
return errors.New("WS send-push server did not receive expected data")
}
return nil
}
func runWSRequests() error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
server := toki.NewWsServer(host, goTypescriptWSPort, wsPath, func(conn *websocket.Conn) *toki.WsClient {
return toki.NewWsClient(conn, 0, 0, parserMap())
})
server.OnClientConnected = func(client *toki.WsClient) {
toki.AddRequestListenerTyped[*packets.TestData, *packets.TestData](&client.Communicator, func(req *packets.TestData) (*packets.TestData, error) {
return &packets.TestData{
Index: req.GetIndex() * 2,
Message: "echo: " + req.GetMessage(),
}, nil
})
}
if err := server.Start(ctx); err != nil {
return err
}
defer server.Stop()
return runTypescriptClient("ws", goTypescriptWSPort, "requests", map[string]bool{"3": true, "4": true})
}
func runTypescriptClient(mode string, port int, phase string, expected map[string]bool) error {
ctx, cancel := context.WithTimeout(context.Background(), processTimeout)
defer cancel()
typescriptDir, err := typescriptPackageDir()
if err != nil {
return err
}
cmd := exec.CommandContext(ctx, "npx", "tsx", "crosstest/go_typescript_client.ts",
"--mode="+mode,
fmt.Sprintf("--port=%d", port),
"--phase="+phase,
)
cmd.Dir = typescriptDir
stdoutPipe, err := cmd.StdoutPipe()
if err != nil {
return err
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return err
}
if err := cmd.Start(); err != nil {
return err
}
var mu sync.Mutex
var resultLines []string
var wg sync.WaitGroup
wg.Add(2)
go scanLines(&wg, stdoutPipe, os.Stdout, &mu, &resultLines)
go scanLines(&wg, stderrPipe, os.Stderr, nil, nil)
waitErr := cmd.Wait()
wg.Wait()
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("typescript client %s/%s timed out", mode, phase)
}
return validateResultLines("typescript-client "+mode+"/"+phase, waitErr, resultLines, expected)
}
func typescriptPackageDir() (string, error) {
candidates := make([]string, 0, 3)
_, filename, _, ok := runtime.Caller(0)
if ok {
repoRoot := filepath.Dir(filepath.Dir(filepath.Dir(filename)))
candidates = append(candidates, filepath.Join(repoRoot, "typescript"))
}
if wd, err := os.Getwd(); err == nil {
candidates = append(candidates, findTypescriptPackageCandidates(wd)...)
}
if executable, err := os.Executable(); err == nil {
candidates = append(candidates, findTypescriptPackageCandidates(filepath.Dir(executable))...)
}
for _, candidate := range candidates {
if isTypescriptPackageDir(candidate) {
return candidate, nil
}
}
return "", fmt.Errorf("cannot resolve typescript package directory from candidates %v", candidates)
}
func findTypescriptPackageCandidates(start string) []string {
candidates := make([]string, 0)
for dir := start; ; dir = filepath.Dir(dir) {
candidates = append(candidates, filepath.Join(dir, "typescript"))
if filepath.Base(dir) == "typescript" {
candidates = append(candidates, dir)
}
parent := filepath.Dir(dir)
if parent == dir {
return candidates
}
}
}
func isTypescriptPackageDir(dir string) bool {
packageJSON, err := os.Stat(filepath.Join(dir, "package.json"))
if err != nil || packageJSON.IsDir() {
return false
}
tsconfig, err := os.Stat(filepath.Join(dir, "tsconfig.json"))
return err == nil && !tsconfig.IsDir()
}
func scanLines(wg *sync.WaitGroup, reader io.Reader, writer *os.File, mu *sync.Mutex, resultLines *[]string) {
defer wg.Done()
scanner := bufio.NewScanner(reader)
for scanner.Scan() {
line := scanner.Text()
fmt.Fprintln(writer, line)
if mu != nil && startsWithResult(line) {
mu.Lock()
*resultLines = append(*resultLines, line)
mu.Unlock()
}
}
}
func validateResultLines(label string, waitErr error, lines []string, expected map[string]bool) error {
failed := make([]string, 0)
passed := make(map[string]bool)
re := regexp.MustCompile(`scenario=([^ ]+)`)
for _, line := range lines {
if len(line) >= 5 && line[:5] == "FAIL " {
failed = append(failed, line)
continue
}
if len(line) >= 5 && line[:5] == "PASS " {
match := re.FindStringSubmatch(line)
if len(match) == 2 {
passed[match[1]] = true
}
}
}
missing := make([]string, 0)
for scenario := range expected {
if !passed[scenario] {
missing = append(missing, scenario)
}
}
if waitErr != nil || len(failed) > 0 || len(missing) > 0 {
return fmt.Errorf("%s failed waitErr=%v failed=%v missing=%v", label, waitErr, failed, missing)
}
return nil
}
func startsWithResult(line string) bool {
return (len(line) >= 5 && line[:5] == "PASS ") || (len(line) >= 5 && line[:5] == "FAIL ")
}

View file

@ -0,0 +1,207 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"sync"
"time"
"google.golang.org/protobuf/proto"
toki "toki-labs.com/toki_socket/go"
"toki-labs.com/toki_socket/go/packets"
)
const (
host = "127.0.0.1"
wsPath = "/"
connectWindow = 3 * time.Second
requestWindow = 2 * time.Second
serverReadyDelay = 50 * time.Millisecond
)
type clientHandle struct {
communicator *toki.Communicator
send func(proto.Message) error
close func() error
}
func parserMap() toki.ParserMap {
return toki.ParserMap{
toki.TypeNameOf(&packets.TestData{}): func(b []byte) (proto.Message, error) {
m := &packets.TestData{}
return m, proto.Unmarshal(b, m)
},
}
}
func main() {
mode := flag.String("mode", "tcp", "transport mode: tcp or ws")
port := flag.Int("port", 0, "server port")
phase := flag.String("phase", "send-push", "test phase: send-push or requests")
flag.Parse()
fmt.Printf("INFO typeName go=%s\n", toki.TypeNameOf(&packets.TestData{}))
if *port == 0 {
fail("setup", "port is required")
os.Exit(1)
}
client, err := dialWithRetry(*mode, *port)
if err != nil {
fail("setup", err.Error())
os.Exit(1)
}
defer client.close()
time.Sleep(serverReadyDelay)
var ok bool
switch *phase {
case "send-push":
ok = runSendPush(client)
case "requests":
ok = runRequests(client)
default:
fail("setup", fmt.Sprintf("unknown phase %q", *phase))
ok = false
}
if !ok {
os.Exit(1)
}
}
func dialWithRetry(mode string, port int) (*clientHandle, error) {
deadline := time.Now().Add(connectWindow)
var lastErr error
for time.Now().Before(deadline) {
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
handle, err := dial(ctx, mode, port)
cancel()
if err == nil {
return handle, nil
}
lastErr = err
time.Sleep(100 * time.Millisecond)
}
return nil, fmt.Errorf("connect %s:%d timed out: %w", mode, port, lastErr)
}
func dial(ctx context.Context, mode string, port int) (*clientHandle, error) {
switch mode {
case "tcp":
client, err := toki.DialTcp(ctx, host, port, 0, 0, parserMap())
if err != nil {
return nil, err
}
return &clientHandle{
communicator: &client.Communicator,
send: client.Send,
close: client.Close,
}, nil
case "ws":
client, err := toki.DialWsWithHeartbeat(ctx, host, port, wsPath, 0, 0, parserMap())
if err != nil {
return nil, err
}
return &clientHandle{
communicator: &client.Communicator,
send: client.Send,
close: client.Close,
}, nil
default:
return nil, fmt.Errorf("unknown mode %q", mode)
}
}
func runSendPush(client *clientHandle) bool {
pushCh := make(chan *packets.TestData, 1)
toki.AddListenerTyped[*packets.TestData](client.communicator, func(msg *packets.TestData) {
pushCh <- msg
})
err := client.send(&packets.TestData{
Index: 101,
Message: "fire from go client",
})
if err != nil {
fail("1", err.Error())
return false
}
pass("1", "fire-and-forget sent")
select {
case msg := <-pushCh:
if msg.GetIndex() != 200 || msg.GetMessage() != "push from typescript server" {
fail("2", fmt.Sprintf("unexpected push index=%d message=%q", msg.GetIndex(), msg.GetMessage()))
return false
}
pass("2", "received push from typescript server")
return true
case <-time.After(requestWindow):
fail("2", "timeout waiting for server push")
return false
}
}
func runRequests(client *clientHandle) bool {
res, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData](
client.communicator,
&packets.TestData{Index: 21, Message: "single request from go"},
requestWindow,
)
if err != nil {
fail("3", err.Error())
return false
}
if res.GetIndex() != 42 || res.GetMessage() != "echo: single request from go" {
fail("3", fmt.Sprintf("unexpected response index=%d message=%q", res.GetIndex(), res.GetMessage()))
return false
}
pass("3", "single request response matched")
const count = 5
var wg sync.WaitGroup
errCh := make(chan error, count)
for i := 0; i < count; i++ {
i := i
wg.Add(1)
go func() {
defer wg.Done()
index := int32(30 + i)
message := fmt.Sprintf("multi request %d from go", i)
res, err := toki.SendRequestTyped[*packets.TestData, *packets.TestData](
client.communicator,
&packets.TestData{Index: index, Message: message},
requestWindow,
)
if err != nil {
errCh <- err
return
}
if res.GetIndex() != index*2 || res.GetMessage() != "echo: "+message {
errCh <- fmt.Errorf("request %d got index=%d message=%q", i, res.GetIndex(), res.GetMessage())
}
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
fail("4", err.Error())
return false
}
}
pass("4", "concurrent request responses matched")
return true
}
func pass(scenario, detail string) {
fmt.Printf("PASS scenario=%s detail=%s\n", scenario, detail)
}
func fail(scenario, detail string) {
fmt.Printf("FAIL scenario=%s error=%s\n", scenario, detail)
}

View file

@ -0,0 +1,239 @@
@file:JvmName("KotlinTypescriptKt")
package com.tokilabs.toki_socket.crosstest
import com.tokilabs.toki_socket.ParserMap
import com.tokilabs.toki_socket.TcpClient
import com.tokilabs.toki_socket.TcpServer
import com.tokilabs.toki_socket.WsClient
import com.tokilabs.toki_socket.WsServer
import com.tokilabs.toki_socket.addListenerTyped
import com.tokilabs.toki_socket.addRequestListenerTyped
import com.tokilabs.toki_socket.packets.TestData
import com.tokilabs.toki_socket.typeNameOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import java.io.File
import java.util.concurrent.TimeUnit
import kotlin.system.exitProcess
private const val HOST = "127.0.0.1"
private const val TCP_PORT = 29794
private const val WS_PORT = 29796
private const val WS_PATH = "/"
private const val PROCESS_TIMEOUT_MS = 20_000L
private const val SERVER_OBSERVATION_MS = 200L
fun main() = runBlocking {
println("INFO typeName kotlin=${typeNameOf<TestData>()}")
try {
runTcpSendPush()
delay(150)
runTcpRequests()
delay(150)
runWs()
println("PASS all kotlin-server/typescript-client crosstests passed")
} catch (error: Throwable) {
System.err.println("FAIL crosstest error=${error.message ?: error}")
exitProcess(1)
}
}
private suspend fun runTcpSendPush() = coroutineScope {
val received = CompletableDeferred<Boolean>()
val server = TcpServer(HOST, TCP_PORT) { socket ->
TcpClient.fromSocket(socket, 0, 0, parserMap())
}
server.onClientConnected = { client ->
addListenerTyped<TestData>(client.communicator) { data ->
println("SERVER_RECEIVED index=${data.index} message=${data.message}")
val valid = data.index == 101 && data.message == "fire from typescript client"
received.complete(valid)
if (valid) {
launch {
client.send(
TestData.newBuilder()
.setIndex(200)
.setMessage("push from kotlin server")
.build(),
)
}
}
}
}
withServer(server::start, server::stop) {
runTypescriptClient("tcp", TCP_PORT, "send-push", setOf("1", "2"))
val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false
check(ok) { "TCP send-push server did not receive expected data" }
}
}
private suspend fun runTcpRequests() {
val server = TcpServer(HOST, TCP_PORT) { socket ->
TcpClient.fromSocket(socket, 0, 0, parserMap())
}
server.onClientConnected = { client ->
addRequestListenerTyped<TestData, TestData>(client.communicator) { req ->
TestData.newBuilder()
.setIndex(req.index * 2)
.setMessage("echo: ${req.message}")
.build()
}
}
withServer(server::start, server::stop) {
runTypescriptClient("tcp", TCP_PORT, "requests", setOf("3", "4"))
}
}
private suspend fun runWs() = coroutineScope {
val server = WsServer(HOST, WS_PORT, WS_PATH) { conn ->
WsClient.forServer(conn, 0, 0, parserMap())
}
server.start()
delay(100)
try {
runWsSendPush(server)
delay(150)
runWsRequests(server)
} finally {
server.stop()
}
}
private suspend fun runWsSendPush(server: WsServer) = coroutineScope {
val received = CompletableDeferred<Boolean>()
server.onClientConnected = { client ->
addListenerTyped<TestData>(client.communicator) { data ->
println("SERVER_RECEIVED index=${data.index} message=${data.message}")
val valid = data.index == 101 && data.message == "fire from typescript client"
received.complete(valid)
if (valid) {
launch {
client.send(
TestData.newBuilder()
.setIndex(200)
.setMessage("push from kotlin server")
.build(),
)
}
}
}
}
runTypescriptClient("ws", WS_PORT, "send-push", setOf("1", "2"))
val ok = withTimeoutOrNull(SERVER_OBSERVATION_MS) { received.await() } ?: false
check(ok) { "WS send-push server did not receive expected data" }
}
private suspend fun runWsRequests(server: WsServer) {
server.onClientConnected = { client ->
addRequestListenerTyped<TestData, TestData>(client.communicator) { req ->
TestData.newBuilder()
.setIndex(req.index * 2)
.setMessage("echo: ${req.message}")
.build()
}
}
runTypescriptClient("ws", WS_PORT, "requests", setOf("3", "4"))
}
private suspend fun withServer(
start: () -> Unit,
stop: () -> Unit,
body: suspend () -> Unit,
) {
start()
delay(100)
try {
body()
} finally {
stop()
}
}
private suspend fun runTypescriptClient(
mode: String,
port: Int,
phase: String,
expectedScenarios: Set<String>,
) = withContext(Dispatchers.IO) {
val process = ProcessBuilder(
"npx",
"tsx",
"crosstest/kotlin_typescript_client.ts",
"--mode=$mode",
"--port=$port",
"--phase=$phase",
)
.directory(typescriptDir())
.redirectErrorStream(false)
.start()
val stdoutLines = mutableListOf<String>()
val stdoutThread = Thread {
process.inputStream.bufferedReader().forEachLine { line ->
println(line)
if (line.startsWith("PASS ") || line.startsWith("FAIL ")) {
stdoutLines.add(line)
}
}
}
val stderrThread = Thread {
process.errorStream.bufferedReader().forEachLine { line ->
System.err.println(line)
}
}
stdoutThread.start()
stderrThread.start()
val finished = process.waitFor(PROCESS_TIMEOUT_MS, TimeUnit.MILLISECONDS)
if (!finished) {
process.destroyForcibly()
}
stdoutThread.join()
stderrThread.join()
validateResultLines(
"typescript-client $mode/$phase",
if (finished) process.exitValue() else -1,
stdoutLines,
expectedScenarios,
)
}
private fun validateResultLines(
label: String,
exitCode: Int,
lines: List<String>,
expectedScenarios: Set<String>,
) {
val failed = lines.filter { it.startsWith("FAIL ") }
val passed = lines
.filter { it.startsWith("PASS ") }
.mapNotNull { Regex("""scenario=([^ ]+)""").find(it)?.groupValues?.get(1) }
.toSet()
val missing = expectedScenarios - passed
check(exitCode == 0 && failed.isEmpty() && missing.isEmpty()) {
"$label failed exitCode=$exitCode failed=$failed missing=$missing"
}
}
private fun parserMap(): ParserMap =
mapOf(typeNameOf<TestData>() to { TestData.parseFrom(it) })
private fun typescriptDir(): File {
var dir = File(System.getProperty("user.dir")).absoluteFile
while (dir.parentFile != null) {
val candidate = File(dir, "../typescript").canonicalFile
if (File(candidate, "package.json").isFile) return candidate
val direct = File(dir, "typescript").canonicalFile
if (File(direct, "package.json").isFile) return direct
dir = dir.parentFile
}
error("cannot resolve typescript directory")
}

View file

@ -24,23 +24,23 @@ private const val WS_PATH = "/"
private const val CONNECT_WINDOW_MS = 3_000L
private const val REQUEST_WINDOW_MS = 2_000L
private interface ClientHandle {
private interface PythonClientHandle {
suspend fun send(data: TestData)
suspend fun close()
val communicator: com.tokilabs.toki_socket.Communicator
}
private class TcpHandle(
private class PythonTcpHandle(
private val client: TcpClient,
) : ClientHandle {
) : PythonClientHandle {
override val communicator = client.communicator
override suspend fun send(data: TestData) { client.send(data) }
override suspend fun close() { client.close() }
}
private class WsHandle(
private class PythonWsHandle(
private val client: WsClient,
) : ClientHandle {
) : PythonClientHandle {
override val communicator = client.communicator
override suspend fun send(data: TestData) { client.send(data) }
override suspend fun close() { client.close() }
@ -81,14 +81,14 @@ fun main(args: Array<String>) = runBlocking {
if (!ok) exitProcess(1)
}
private suspend fun connectWithRetry(mode: String, port: Int): ClientHandle {
private suspend fun connectWithRetry(mode: String, port: Int): PythonClientHandle {
val deadline = System.nanoTime() + CONNECT_WINDOW_MS * 1_000_000L
var lastError: Throwable? = null
while (System.nanoTime() < deadline) {
try {
return when (mode) {
"tcp" -> TcpHandle(DialTcp(HOST, port, 0, 0, parserMap()))
"ws" -> WsHandle(DialWs(HOST, port, WS_PATH, 0, 0, parserMap()))
"tcp" -> PythonTcpHandle(DialTcp(HOST, port, 0, 0, parserMap()))
"ws" -> PythonWsHandle(DialWs(HOST, port, WS_PATH, 0, 0, parserMap()))
else -> error("unknown mode $mode")
}
} catch (error: Throwable) {
@ -99,7 +99,7 @@ private suspend fun connectWithRetry(mode: String, port: Int): ClientHandle {
error("connect $mode:$port timed out: $lastError")
}
private suspend fun runSendPush(client: ClientHandle): Boolean {
private suspend fun runSendPush(client: PythonClientHandle): Boolean {
val push = CompletableDeferred<TestData>()
addListenerTyped<TestData>(client.communicator) {
push.complete(it)
@ -128,7 +128,7 @@ private suspend fun runSendPush(client: ClientHandle): Boolean {
}
}
private suspend fun runRequests(client: ClientHandle): Boolean =
private suspend fun runRequests(client: PythonClientHandle): Boolean =
try {
val single = sendRequestTyped<TestData, TestData>(
client.communicator,
@ -150,7 +150,7 @@ private suspend fun runRequests(client: ClientHandle): Boolean =
false
}
private suspend fun runConcurrentRequests(client: ClientHandle): Boolean =
private suspend fun runConcurrentRequests(client: PythonClientHandle): Boolean =
coroutineScope {
val jobs = (0 until 5).map { i ->
async {

View file

@ -0,0 +1,197 @@
@file:JvmName("TypescriptKotlinClientKt")
package com.tokilabs.toki_socket.crosstest
import com.tokilabs.toki_socket.Communicator
import com.tokilabs.toki_socket.DialTcp
import com.tokilabs.toki_socket.DialWs
import com.tokilabs.toki_socket.ParserMap
import com.tokilabs.toki_socket.TcpClient
import com.tokilabs.toki_socket.WsClient
import com.tokilabs.toki_socket.addListenerTyped
import com.tokilabs.toki_socket.packets.TestData
import com.tokilabs.toki_socket.sendRequestTyped
import com.tokilabs.toki_socket.typeNameOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlin.system.exitProcess
private const val HOST = "127.0.0.1"
private const val WS_PATH = "/"
private const val CONNECT_WINDOW_MS = 3_000L
private const val REQUEST_WINDOW_MS = 2_000L
private interface TypescriptClientHandle {
suspend fun send(data: TestData)
suspend fun close()
val communicator: Communicator
}
private class TypescriptTcpHandle(
private val client: TcpClient,
) : TypescriptClientHandle {
override val communicator = client.communicator
override suspend fun send(data: TestData) { client.send(data) }
override suspend fun close() { client.close() }
}
private class TypescriptWsHandle(
private val client: WsClient,
) : TypescriptClientHandle {
override val communicator = client.communicator
override suspend fun send(data: TestData) { client.send(data) }
override suspend fun close() { client.close() }
}
fun main(args: Array<String>) = runBlocking {
val mode = argValue(args, "mode") ?: "tcp"
val phase = argValue(args, "phase") ?: "send-push"
val port = argValue(args, "port")?.toIntOrNull()
println("INFO typeName kotlin=${typeNameOf<TestData>()}")
if (port == null) {
fail("setup", "port is required")
exitProcess(1)
}
val client = try {
connectWithRetry(mode, port)
} catch (error: Throwable) {
fail("setup", error.message ?: error.toString())
exitProcess(1)
}
val ok: Boolean = try {
when (phase) {
"send-push" -> runSendPush(client)
"requests" -> runRequests(client)
else -> {
fail("setup", "unknown phase $phase")
false
}
}
} finally {
client.close()
}
if (!ok) exitProcess(1)
}
private suspend fun connectWithRetry(mode: String, port: Int): TypescriptClientHandle {
val deadline = System.nanoTime() + CONNECT_WINDOW_MS * 1_000_000L
var lastError: Throwable? = null
while (System.nanoTime() < deadline) {
try {
return when (mode) {
"tcp" -> TypescriptTcpHandle(DialTcp(HOST, port, 0, 0, parserMap()))
"ws" -> TypescriptWsHandle(DialWs(HOST, port, WS_PATH, 0, 0, parserMap()))
else -> error("unknown mode $mode")
}
} catch (error: Throwable) {
lastError = error
delay(100)
}
}
error("connect $mode:$port timed out: $lastError")
}
private suspend fun runSendPush(client: TypescriptClientHandle): Boolean {
val push = CompletableDeferred<TestData>()
addListenerTyped<TestData>(client.communicator) {
push.complete(it)
}
return try {
client.send(
TestData.newBuilder()
.setIndex(101)
.setMessage("fire from kotlin client")
.build(),
)
pass("1", "fire-and-forget sent")
val msg = withTimeout(REQUEST_WINDOW_MS) { push.await() }
if (msg.index != 200 || msg.message != "push from typescript server") {
fail("2", "unexpected push index=${msg.index} message=${msg.message}")
false
} else {
pass("2", "received push from typescript server")
true
}
} catch (error: Throwable) {
fail("2", error.message ?: error.toString())
false
}
}
private suspend fun runRequests(client: TypescriptClientHandle): Boolean =
try {
val single = sendRequestTyped<TestData, TestData>(
client.communicator,
TestData.newBuilder()
.setIndex(21)
.setMessage("single request from kotlin")
.build(),
REQUEST_WINDOW_MS,
)
if (single.index != 42 || single.message != "echo: single request from kotlin") {
fail("3", "unexpected response index=${single.index} message=${single.message}")
false
} else {
pass("3", "single request response matched")
runConcurrentRequests(client)
}
} catch (error: Throwable) {
fail("3", error.message ?: error.toString())
false
}
private suspend fun runConcurrentRequests(client: TypescriptClientHandle): Boolean =
coroutineScope {
val jobs = (0 until 5).map { i ->
async {
val index = 30 + i
val message = "multi request $i from kotlin"
val res = sendRequestTyped<TestData, TestData>(
client.communicator,
TestData.newBuilder()
.setIndex(index)
.setMessage(message)
.build(),
REQUEST_WINDOW_MS,
)
check(res.index == index * 2 && res.message == "echo: $message") {
"request $i got index=${res.index} message=${res.message}"
}
}
}
try {
jobs.forEach { it.await() }
pass("4", "concurrent request responses matched")
true
} catch (error: Throwable) {
fail("4", error.message ?: error.toString())
false
}
}
private fun parserMap(): ParserMap =
mapOf(typeNameOf<TestData>() to { TestData.parseFrom(it) })
private fun argValue(args: Array<String>, name: String): String? {
val prefix = "--$name="
return args.firstOrNull { it.startsWith(prefix) }?.substring(prefix.length)
}
private fun pass(scenario: String, detail: String) {
println("PASS scenario=$scenario detail=$detail")
}
private fun fail(scenario: String, error: String) {
println("FAIL scenario=$scenario error=$error")
}

View file

@ -0,0 +1,178 @@
from __future__ import annotations
import asyncio
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from toki_socket.communicator import type_name_of
from toki_socket.packets.message_common_pb2 import TestData
from toki_socket.tcp_server import TcpServer
from toki_socket.ws_server import WsServer
HOST = "127.0.0.1"
PYTHON_TYPESCRIPT_TCP_PORT = 29798
PYTHON_TYPESCRIPT_WS_PORT = 29800
WS_PATH = "/"
PROCESS_TIMEOUT = 20.0
SERVER_OBSERVATION_WINDOW = 0.2
def parser_map():
return {type_name_of(TestData): TestData.FromString}
async def main() -> None:
print(f"INFO typeName python={type_name_of(TestData)}")
try:
await run_tcp_send_push()
await asyncio.sleep(0.15)
await run_tcp_requests()
await asyncio.sleep(0.15)
await run_ws_send_push()
await asyncio.sleep(0.15)
await run_ws_requests()
except Exception as exc:
print(f"FAIL crosstest error={exc}", file=sys.stderr)
raise SystemExit(1) from exc
print("PASS all python-server/typescript-client crosstests passed")
async def run_tcp_send_push() -> None:
received = asyncio.get_running_loop().create_future()
server = TcpServer(HOST, PYTHON_TYPESCRIPT_TCP_PORT, interval_sec=0, wait_sec=0, parser_map=parser_map())
def on_connected(client):
def on_message(data):
print(f"SERVER_RECEIVED index={data.index} message={data.message}")
valid = data.index == 101 and data.message == "fire from typescript client"
if not received.done():
received.set_result(valid)
if valid:
asyncio.create_task(
client.communicator.send(TestData(index=200, message="push from python server"))
)
client.communicator.add_listener(type_name_of(TestData), on_message)
server.on_client_connected = on_connected
await server.start()
try:
await run_typescript_client("tcp", PYTHON_TYPESCRIPT_TCP_PORT, "send-push", {"1", "2"})
ok = await asyncio.wait_for(received, SERVER_OBSERVATION_WINDOW)
if not ok:
raise RuntimeError("TCP send-push server received unexpected data")
finally:
await server.stop()
async def run_tcp_requests() -> None:
server = TcpServer(HOST, PYTHON_TYPESCRIPT_TCP_PORT, interval_sec=0, wait_sec=0, parser_map=parser_map())
server.on_client_connected = lambda client: client.communicator.add_request_listener(
type_name_of(TestData),
lambda req: TestData(index=req.index * 2, message="echo: " + req.message),
)
await server.start()
try:
await run_typescript_client("tcp", PYTHON_TYPESCRIPT_TCP_PORT, "requests", {"3", "4"})
finally:
await server.stop()
async def run_ws_send_push() -> None:
received = asyncio.get_running_loop().create_future()
server = WsServer(HOST, PYTHON_TYPESCRIPT_WS_PORT, WS_PATH, interval_sec=0, wait_sec=0, parser_map=parser_map())
def on_connected(client):
def on_message(data):
print(f"SERVER_RECEIVED index={data.index} message={data.message}")
valid = data.index == 101 and data.message == "fire from typescript client"
if not received.done():
received.set_result(valid)
if valid:
asyncio.create_task(
client.communicator.send(TestData(index=200, message="push from python server"))
)
client.communicator.add_listener(type_name_of(TestData), on_message)
server.on_client_connected = on_connected
await server.start()
try:
await run_typescript_client("ws", PYTHON_TYPESCRIPT_WS_PORT, "send-push", {"1", "2"})
ok = await asyncio.wait_for(received, SERVER_OBSERVATION_WINDOW)
if not ok:
raise RuntimeError("WS send-push server received unexpected data")
finally:
await server.stop()
async def run_ws_requests() -> None:
server = WsServer(HOST, PYTHON_TYPESCRIPT_WS_PORT, WS_PATH, interval_sec=0, wait_sec=0, parser_map=parser_map())
server.on_client_connected = lambda client: client.communicator.add_request_listener(
type_name_of(TestData),
lambda req: TestData(index=req.index * 2, message="echo: " + req.message),
)
await server.start()
try:
await run_typescript_client("ws", PYTHON_TYPESCRIPT_WS_PORT, "requests", {"3", "4"})
finally:
await server.stop()
async def run_typescript_client(mode: str, port: int, phase: str, expected: set[str]) -> None:
ts_dir = typescript_package_dir()
process = await asyncio.create_subprocess_exec(
"npx",
"tsx",
"crosstest/python_typescript_client.ts",
f"--mode={mode}",
f"--port={port}",
f"--phase={phase}",
cwd=ts_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), PROCESS_TIMEOUT)
except TimeoutError as exc:
process.kill()
await process.wait()
raise TimeoutError(f"typescript client {mode}/{phase} timed out") from exc
result_lines: list[str] = []
for line in stdout.decode().splitlines():
print(line)
if line.startswith(("PASS ", "FAIL ")):
result_lines.append(line)
for line in stderr.decode().splitlines():
print(line, file=sys.stderr)
validate_result_lines(f"typescript-client {mode}/{phase}", process.returncode, result_lines, expected)
def validate_result_lines(label: str, return_code: int | None, lines: list[str], expected: set[str]) -> None:
failed = [line for line in lines if line.startswith("FAIL ")]
passed: set[str] = set()
for line in lines:
if line.startswith("PASS "):
match = re.search(r"scenario=([^ ]+)", line)
if match:
passed.add(match.group(1))
missing = expected - passed
if return_code or failed or missing:
raise RuntimeError(f"{label} failed returnCode={return_code} failed={failed} missing={sorted(missing)}")
def typescript_package_dir() -> Path:
repo_root = Path(__file__).resolve().parents[2]
candidate = repo_root / "typescript"
if (candidate / "package.json").is_file():
return candidate
raise RuntimeError(f"cannot resolve typescript package directory from {candidate}")
if __name__ == "__main__":
asyncio.run(main())

View file

@ -0,0 +1,139 @@
from __future__ import annotations
import argparse
import asyncio
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from toki_socket.communicator import type_name_of
from toki_socket.packets.message_common_pb2 import TestData
from toki_socket.tcp_client import connect_tcp
from toki_socket.ws_client import connect_ws
HOST = "127.0.0.1"
WS_PATH = "/"
CONNECT_WINDOW = 3.0
REQUEST_WINDOW = 2.0
def parser_map():
return {type_name_of(TestData): TestData.FromString}
async def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["tcp", "ws"], default="tcp")
parser.add_argument("--port", type=int, required=True)
parser.add_argument("--phase", choices=["send-push", "requests"], default="send-push")
args = parser.parse_args()
print(f"INFO typeName python={type_name_of(TestData)}")
try:
client = await dial_with_retry(args.mode, args.port)
except Exception as exc:
fail("setup", str(exc))
raise SystemExit(1) from exc
try:
if args.phase == "send-push":
ok = await run_send_push(client)
else:
ok = await run_requests(client)
finally:
await client.close()
if not ok:
raise SystemExit(1)
async def dial_with_retry(mode: str, port: int):
deadline = asyncio.get_running_loop().time() + CONNECT_WINDOW
last_error: Exception | None = None
while asyncio.get_running_loop().time() < deadline:
try:
return await asyncio.wait_for(dial(mode, port), 0.3)
except Exception as exc:
last_error = exc
await asyncio.sleep(0.1)
raise TimeoutError(f"connect {mode}:{port} timed out: {last_error}")
async def dial(mode: str, port: int):
if mode == "tcp":
return await connect_tcp(HOST, port, 0, 0, parser_map())
if mode == "ws":
return await connect_ws(HOST, port, WS_PATH, 0, 0, parser_map())
raise ValueError(f"unknown mode {mode!r}")
async def run_send_push(client) -> bool:
loop = asyncio.get_running_loop()
push = loop.create_future()
client.communicator.add_listener(
type_name_of(TestData),
lambda message: push.set_result(message) if not push.done() else None,
)
try:
await client.communicator.send(TestData(index=101, message="fire from python client"))
passed("1", "fire-and-forget sent")
message = await asyncio.wait_for(push, REQUEST_WINDOW)
if message.index != 200 or message.message != "push from typescript server":
fail("2", f"unexpected push index={message.index} message={message.message!r}")
return False
passed("2", "received push from typescript server")
return True
except Exception as exc:
fail("2", str(exc))
return False
async def run_requests(client) -> bool:
try:
single = await client.communicator.send_request(
TestData(index=21, message="single request from python"),
TestData,
timeout=REQUEST_WINDOW,
)
if single.index != 42 or single.message != "echo: single request from python":
fail("3", f"unexpected response index={single.index} message={single.message!r}")
return False
passed("3", "single request response matched")
except Exception as exc:
fail("3", str(exc))
return False
async def request_one(i: int) -> None:
index = 30 + i
message = f"multi request {i} from python"
res = await client.communicator.send_request(
TestData(index=index, message=message),
TestData,
timeout=REQUEST_WINDOW,
)
if res.index != index * 2 or res.message != "echo: " + message:
raise AssertionError(f"request {i} got index={res.index} message={res.message!r}")
try:
await asyncio.gather(*(request_one(i) for i in range(5)))
passed("4", "concurrent request responses matched")
return True
except Exception as exc:
fail("4", str(exc))
return False
def passed(scenario: str, detail: str) -> None:
print(f"PASS scenario={scenario} detail={detail}")
def fail(scenario: str, error: str) -> None:
print(f"FAIL scenario={scenario} error={error}")
if __name__ == "__main__":
asyncio.run(main())

2
typescript/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

View file

@ -0,0 +1,27 @@
# Cross-language Test Checklist
## Runner Layout
- [ ] Server-language orchestrator lives under `go/crosstest/`.
- [ ] Client-language subprocess helper lives under `typescript/crosstest/`.
- [ ] No root-level package metadata is added only for crosstests.
- [ ] Subprocess paths resolve from the package root or runner source location.
## Required Output
- [ ] Client helper prints `INFO typeName ts=TestData`.
- [ ] Each scenario prints exactly one `PASS scenario=N detail=...` or `FAIL scenario=N error=...` line.
- [ ] Orchestrator fails on missing scenario, `FAIL`, or non-zero subprocess exit.
## Required Scenarios
- [ ] Scenario 1: client sends fire-and-forget `TestData`; server validates `index` and `message`.
- [ ] Scenario 2: server pushes `TestData(index=200, message=push from go server)` to the client.
- [ ] Scenario 3: client `sendRequest` receives doubled `index` and echoed `message`.
- [ ] Scenario 4: concurrent requests verify nonce and response routing.
## Review Notes
- [ ] Record exact commands used to run TypeScript checks and Go cross tests.
- [ ] Record fixed ports `29490` and `29492`.
- [ ] Record TLS/WSS deferral for this iteration.

View file

@ -0,0 +1,42 @@
# Implementation Checklist
Target protocol version: `0.1`
## Protocol Core
- [ ] Generated protobuf bindings from the canonical schema.
- [ ] Parser map registers application messages by protocol-compatible `typeName`.
- [ ] Built-in `HeartBeat` registration is owned by the framework.
- [ ] `PacketBase.typeName`, `nonce`, `data`, and `responseNonce` semantics match `PROTOCOL.md`.
- [ ] Same `typeName` cannot be registered for both normal listener and request listener on one connection.
## Transport
- [ ] `Transport` abstraction exposes only packet write and close operations.
- [ ] `Communicator` does not know whether the connection is TCP or WS.
- [ ] TCP framing writes a 4-byte big-endian length followed by one `PacketBase` protobuf payload.
- [ ] TCP reader rejects zero-length packets as no-op and closes on invalid or oversized lengths.
- [ ] WebSocket reader/writer uses one binary frame per `PacketBase` payload.
- [ ] TLS+TCP and WSS are unsupported in this iteration and documented as deferred.
## Lifecycle
- [ ] Close is idempotent.
- [ ] Pending requests complete with an error when the connection closes.
- [ ] Writes are serialized so packet bytes cannot interleave.
- [ ] Read, write, heartbeat, and close errors converge on the same disconnect path.
## Features
- [ ] Fire-and-forget send.
- [ ] Request-response send with timeout.
- [ ] Concurrent requests route by `responseNonce`.
- [ ] Heartbeat sends after inactivity and closes after missing response.
- [ ] Server broadcast.
## Verification
- [ ] TypeScript compile passes.
- [ ] Same-language TCP tests pass.
- [ ] Same-language WS tests pass.
- [ ] Go server / TypeScript client cross-language tests pass.

43
typescript/README.md Normal file
View file

@ -0,0 +1,43 @@
# TypeScript Toki Socket
Status: planned
This package implements Toki Socket protocol version `0.1` for TypeScript.
## Scope
- Runtime targets: Node.js TCP, Node.js WebSocket, browser-friendly core types.
- Currently in scope: `Communicator`, `BaseClient`, TCP/WS client and server helpers, Go cross-language tests.
- Out of scope for this iteration: TLS+TCP, WSS, browser WebSocket transport wrapper.
## Proto Generation
Generated bindings live in `src/packets/`.
```bash
cd typescript
PATH="$PWD/node_modules/.bin:$PATH" protoc \
--proto_path ../dart/lib/src/packets \
--es_out src/packets \
--es_opt target=ts \
../dart/lib/src/packets/message_common.proto
```
After generation, run from the repository root:
```bash
tools/check_proto_sync.sh
```
## Validation
```bash
cd typescript
npx tsc --noEmit
npx vitest run
```
```bash
cd go
go run ./crosstest/go_typescript.go
```

View file

@ -0,0 +1,209 @@
import { create } from "@bufbuild/protobuf";
import type { BaseClient } from "../src/base_client.js";
import {
addListenerTyped,
parserFromSchema,
sendRequestTyped,
} from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { connectTcp } from "../src/tcp_client.js";
import { connectWs } from "../src/ws_client.js";
const HOST = "127.0.0.1";
const WS_PATH = "/";
const CONNECT_WINDOW_MS = 3000;
const REQUEST_WINDOW_MS = 2000;
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
console.log(`INFO typeName ts=${TestDataSchema.typeName}`);
let client: BaseClient;
try {
client = await dialWithRetry(args.mode, args.port);
} catch (err) {
fail("setup", errorMessage(err));
process.exitCode = 1;
return;
}
let ok = false;
try {
ok = args.phase === "send-push" ? await runSendPush(client) : await runRequests(client);
} finally {
await client.close();
}
if (!ok) {
process.exitCode = 1;
}
}
function parseArgs(argv: string[]): { mode: "tcp" | "ws"; port: number; phase: "send-push" | "requests" } {
let mode: "tcp" | "ws" = "tcp";
let port: number | null = null;
let phase: "send-push" | "requests" = "send-push";
for (const arg of argv) {
if (arg.startsWith("--mode=")) {
const value = arg.slice("--mode=".length);
if (value === "tcp" || value === "ws") {
mode = value;
}
continue;
}
if (arg.startsWith("--port=")) {
port = Number(arg.slice("--port=".length));
continue;
}
if (arg.startsWith("--phase=")) {
const value = arg.slice("--phase=".length);
if (value === "send-push" || value === "requests") {
phase = value;
}
}
}
if (port === null || Number.isNaN(port)) {
throw new Error("missing required --port");
}
return { mode, port, phase };
}
async function dialWithRetry(mode: "tcp" | "ws", port: number): Promise<BaseClient> {
const deadline = Date.now() + CONNECT_WINDOW_MS;
let lastError: unknown;
while (Date.now() < deadline) {
try {
return await dial(mode, port);
} catch (err) {
lastError = err;
await sleep(100);
}
}
throw new Error(`connect ${mode}:${port} timed out: ${errorMessage(lastError)}`);
}
async function dial(mode: "tcp" | "ws", port: number): Promise<BaseClient> {
const parserMap = new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
if (mode === "tcp") {
return connectTcp(HOST, port, 0, 0, parserMap);
}
return connectWs(HOST, port, WS_PATH, 0, 0, parserMap);
}
async function runSendPush(client: BaseClient): Promise<boolean> {
const pushed = new Promise<TestData>((resolve) => {
addListenerTyped(client.communicator, TestDataSchema, (message) => {
resolve(message);
});
});
try {
await client.communicator.send(
create(TestDataSchema, {
index: 101,
message: "fire from typescript client",
}),
);
passed("1", "fire-and-forget sent");
const message = await withTimeout(pushed, REQUEST_WINDOW_MS, "push from dart server timed out");
if (message.index !== 200 || message.message !== "push from dart server") {
fail("2", `unexpected push index=${message.index} message=${JSON.stringify(message.message)}`);
return false;
}
passed("2", "received push from dart server");
return true;
} catch (err) {
fail("2", errorMessage(err));
return false;
}
}
async function runRequests(client: BaseClient): Promise<boolean> {
try {
const single = await sendRequestTyped(
client.communicator,
create(TestDataSchema, {
index: 21,
message: "single request from typescript",
}),
TestDataSchema,
REQUEST_WINDOW_MS,
);
if (single.index !== 42 || single.message !== "echo: single request from typescript") {
fail("3", `unexpected response index=${single.index} message=${JSON.stringify(single.message)}`);
return false;
}
passed("3", "single request response matched");
} catch (err) {
fail("3", errorMessage(err));
return false;
}
try {
await Promise.all(
Array.from({ length: 5 }, async (_, idx) => {
const index = 30 + idx;
const message = `multi request ${idx} from typescript`;
const response = await sendRequestTyped(
client.communicator,
create(TestDataSchema, { index, message }),
TestDataSchema,
REQUEST_WINDOW_MS,
);
if (response.index !== index * 2 || response.message !== `echo: ${message}`) {
throw new Error(
`request ${idx} got index=${response.index} message=${JSON.stringify(response.message)}`,
);
}
}),
);
passed("4", "concurrent request responses matched");
return true;
} catch (err) {
fail("4", errorMessage(err));
return false;
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
function passed(scenario: string, detail: string): void {
console.log(`PASS scenario=${scenario} detail=${detail}`);
}
function fail(scenario: string, error: string): void {
console.log(`FAIL scenario=${scenario} error=${error}`);
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
void main().catch((err) => {
fail("setup", errorMessage(err));
process.exitCode = 1;
});

View file

@ -0,0 +1,209 @@
import { create } from "@bufbuild/protobuf";
import type { BaseClient } from "../src/base_client.js";
import {
addListenerTyped,
parserFromSchema,
sendRequestTyped,
} from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { connectTcp } from "../src/tcp_client.js";
import { connectWs } from "../src/ws_client.js";
const HOST = "127.0.0.1";
const WS_PATH = "/";
const CONNECT_WINDOW_MS = 3000;
const REQUEST_WINDOW_MS = 2000;
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
console.log(`INFO typeName ts=${TestDataSchema.typeName}`);
let client: BaseClient;
try {
client = await dialWithRetry(args.mode, args.port);
} catch (err) {
fail("setup", errorMessage(err));
process.exitCode = 1;
return;
}
let ok = false;
try {
ok = args.phase === "send-push" ? await runSendPush(client) : await runRequests(client);
} finally {
await client.close();
}
if (!ok) {
process.exitCode = 1;
}
}
function parseArgs(argv: string[]): { mode: "tcp" | "ws"; port: number; phase: "send-push" | "requests" } {
let mode: "tcp" | "ws" = "tcp";
let port: number | null = null;
let phase: "send-push" | "requests" = "send-push";
for (const arg of argv) {
if (arg.startsWith("--mode=")) {
const value = arg.slice("--mode=".length);
if (value === "tcp" || value === "ws") {
mode = value;
}
continue;
}
if (arg.startsWith("--port=")) {
port = Number(arg.slice("--port=".length));
continue;
}
if (arg.startsWith("--phase=")) {
const value = arg.slice("--phase=".length);
if (value === "send-push" || value === "requests") {
phase = value;
}
}
}
if (port === null || Number.isNaN(port)) {
throw new Error("missing required --port");
}
return { mode, port, phase };
}
async function dialWithRetry(mode: "tcp" | "ws", port: number): Promise<BaseClient> {
const deadline = Date.now() + CONNECT_WINDOW_MS;
let lastError: unknown;
while (Date.now() < deadline) {
try {
return await dial(mode, port);
} catch (err) {
lastError = err;
await sleep(100);
}
}
throw new Error(`connect ${mode}:${port} timed out: ${errorMessage(lastError)}`);
}
async function dial(mode: "tcp" | "ws", port: number): Promise<BaseClient> {
const parserMap = new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
if (mode === "tcp") {
return connectTcp(HOST, port, 0, 0, parserMap);
}
return connectWs(HOST, port, WS_PATH, 0, 0, parserMap);
}
async function runSendPush(client: BaseClient): Promise<boolean> {
const pushed = new Promise<TestData>((resolve) => {
addListenerTyped(client.communicator, TestDataSchema, (message) => {
resolve(message);
});
});
try {
await client.communicator.send(
create(TestDataSchema, {
index: 101,
message: "fire from typescript client",
}),
);
passed("1", "fire-and-forget sent");
const message = await withTimeout(pushed, REQUEST_WINDOW_MS, "push from go server timed out");
if (message.index !== 200 || message.message !== "push from go server") {
fail("2", `unexpected push index=${message.index} message=${JSON.stringify(message.message)}`);
return false;
}
passed("2", "received push from go server");
return true;
} catch (err) {
fail("2", errorMessage(err));
return false;
}
}
async function runRequests(client: BaseClient): Promise<boolean> {
try {
const single = await sendRequestTyped(
client.communicator,
create(TestDataSchema, {
index: 21,
message: "single request from typescript",
}),
TestDataSchema,
REQUEST_WINDOW_MS,
);
if (single.index !== 42 || single.message !== "echo: single request from typescript") {
fail("3", `unexpected response index=${single.index} message=${JSON.stringify(single.message)}`);
return false;
}
passed("3", "single request response matched");
} catch (err) {
fail("3", errorMessage(err));
return false;
}
try {
await Promise.all(
Array.from({ length: 5 }, async (_, idx) => {
const index = 30 + idx;
const message = `multi request ${idx} from typescript`;
const response = await sendRequestTyped(
client.communicator,
create(TestDataSchema, { index, message }),
TestDataSchema,
REQUEST_WINDOW_MS,
);
if (response.index !== index * 2 || response.message !== `echo: ${message}`) {
throw new Error(
`request ${idx} got index=${response.index} message=${JSON.stringify(response.message)}`,
);
}
}),
);
passed("4", "concurrent request responses matched");
return true;
} catch (err) {
fail("4", errorMessage(err));
return false;
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
function passed(scenario: string, detail: string): void {
console.log(`PASS scenario=${scenario} detail=${detail}`);
}
function fail(scenario: string, error: string): void {
console.log(`FAIL scenario=${scenario} error=${error}`);
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
void main().catch((err) => {
fail("setup", errorMessage(err));
process.exitCode = 1;
});

View file

@ -0,0 +1,211 @@
import { create } from "@bufbuild/protobuf";
import type { BaseClient } from "../src/base_client.js";
import {
addListenerTyped,
parserFromSchema,
sendRequestTyped,
} from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { connectTcp } from "../src/tcp_client.js";
import { connectWs } from "../src/ws_client.js";
const HOST = "127.0.0.1";
const WS_PATH = "/";
const CONNECT_WINDOW_MS = 10000;
const REQUEST_WINDOW_MS = 2000;
const SERVER_READY_DELAY_MS = 50;
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
console.log(`INFO typeName ts=${TestDataSchema.typeName}`);
let client: BaseClient;
try {
client = await dialWithRetry(args.mode, args.port);
} catch (err) {
fail("setup", errorMessage(err));
process.exitCode = 1;
return;
}
let ok = false;
try {
await sleep(SERVER_READY_DELAY_MS);
ok = args.phase === "send-push" ? await runSendPush(client) : await runRequests(client);
} finally {
await client.close();
}
if (!ok) {
process.exitCode = 1;
}
}
function parseArgs(argv: string[]): { mode: "tcp" | "ws"; port: number; phase: "send-push" | "requests" } {
let mode: "tcp" | "ws" = "tcp";
let port: number | null = null;
let phase: "send-push" | "requests" = "send-push";
for (const arg of argv) {
if (arg.startsWith("--mode=")) {
const value = arg.slice("--mode=".length);
if (value === "tcp" || value === "ws") {
mode = value;
}
continue;
}
if (arg.startsWith("--port=")) {
port = Number(arg.slice("--port=".length));
continue;
}
if (arg.startsWith("--phase=")) {
const value = arg.slice("--phase=".length);
if (value === "send-push" || value === "requests") {
phase = value;
}
}
}
if (port === null || Number.isNaN(port)) {
throw new Error("missing required --port");
}
return { mode, port, phase };
}
async function dialWithRetry(mode: "tcp" | "ws", port: number): Promise<BaseClient> {
const deadline = Date.now() + CONNECT_WINDOW_MS;
let lastError: unknown;
while (Date.now() < deadline) {
try {
return await dial(mode, port);
} catch (err) {
lastError = err;
await sleep(100);
}
}
throw new Error(`connect ${mode}:${port} timed out: ${errorMessage(lastError)}`);
}
async function dial(mode: "tcp" | "ws", port: number): Promise<BaseClient> {
const parserMap = new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
if (mode === "tcp") {
return connectTcp(HOST, port, 0, 0, parserMap);
}
return connectWs(HOST, port, WS_PATH, 0, 0, parserMap);
}
async function runSendPush(client: BaseClient): Promise<boolean> {
const pushed = new Promise<TestData>((resolve) => {
addListenerTyped(client.communicator, TestDataSchema, (message) => {
resolve(message);
});
});
try {
await client.communicator.send(
create(TestDataSchema, {
index: 101,
message: "fire from typescript client",
}),
);
passed("1", "fire-and-forget sent");
const message = await withTimeout(pushed, REQUEST_WINDOW_MS, "push from kotlin server timed out");
if (message.index !== 200 || message.message !== "push from kotlin server") {
fail("2", `unexpected push index=${message.index} message=${JSON.stringify(message.message)}`);
return false;
}
passed("2", "received push from kotlin server");
return true;
} catch (err) {
fail("2", errorMessage(err));
return false;
}
}
async function runRequests(client: BaseClient): Promise<boolean> {
try {
const single = await sendRequestTyped(
client.communicator,
create(TestDataSchema, {
index: 21,
message: "single request from typescript",
}),
TestDataSchema,
REQUEST_WINDOW_MS,
);
if (single.index !== 42 || single.message !== "echo: single request from typescript") {
fail("3", `unexpected response index=${single.index} message=${JSON.stringify(single.message)}`);
return false;
}
passed("3", "single request response matched");
} catch (err) {
fail("3", errorMessage(err));
return false;
}
try {
await Promise.all(
Array.from({ length: 5 }, async (_, idx) => {
const index = 30 + idx;
const message = `multi request ${idx} from typescript`;
const response = await sendRequestTyped(
client.communicator,
create(TestDataSchema, { index, message }),
TestDataSchema,
REQUEST_WINDOW_MS,
);
if (response.index !== index * 2 || response.message !== `echo: ${message}`) {
throw new Error(
`request ${idx} got index=${response.index} message=${JSON.stringify(response.message)}`,
);
}
}),
);
passed("4", "concurrent request responses matched");
return true;
} catch (err) {
fail("4", errorMessage(err));
return false;
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
function passed(scenario: string, detail: string): void {
console.log(`PASS scenario=${scenario} detail=${detail}`);
}
function fail(scenario: string, error: string): void {
console.log(`FAIL scenario=${scenario} error=${error}`);
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
void main().catch((err) => {
fail("setup", errorMessage(err));
process.exitCode = 1;
});

View file

@ -0,0 +1,209 @@
import { create } from "@bufbuild/protobuf";
import type { BaseClient } from "../src/base_client.js";
import {
addListenerTyped,
parserFromSchema,
sendRequestTyped,
} from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { connectTcp } from "../src/tcp_client.js";
import { connectWs } from "../src/ws_client.js";
const HOST = "127.0.0.1";
const WS_PATH = "/";
const CONNECT_WINDOW_MS = 3000;
const REQUEST_WINDOW_MS = 2000;
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
console.log(`INFO typeName ts=${TestDataSchema.typeName}`);
let client: BaseClient;
try {
client = await dialWithRetry(args.mode, args.port);
} catch (err) {
fail("setup", errorMessage(err));
process.exitCode = 1;
return;
}
let ok = false;
try {
ok = args.phase === "send-push" ? await runSendPush(client) : await runRequests(client);
} finally {
await client.close();
}
if (!ok) {
process.exitCode = 1;
}
}
function parseArgs(argv: string[]): { mode: "tcp" | "ws"; port: number; phase: "send-push" | "requests" } {
let mode: "tcp" | "ws" = "tcp";
let port: number | null = null;
let phase: "send-push" | "requests" = "send-push";
for (const arg of argv) {
if (arg.startsWith("--mode=")) {
const value = arg.slice("--mode=".length);
if (value === "tcp" || value === "ws") {
mode = value;
}
continue;
}
if (arg.startsWith("--port=")) {
port = Number(arg.slice("--port=".length));
continue;
}
if (arg.startsWith("--phase=")) {
const value = arg.slice("--phase=".length);
if (value === "send-push" || value === "requests") {
phase = value;
}
}
}
if (port === null || Number.isNaN(port)) {
throw new Error("missing required --port");
}
return { mode, port, phase };
}
async function dialWithRetry(mode: "tcp" | "ws", port: number): Promise<BaseClient> {
const deadline = Date.now() + CONNECT_WINDOW_MS;
let lastError: unknown;
while (Date.now() < deadline) {
try {
return await dial(mode, port);
} catch (err) {
lastError = err;
await sleep(100);
}
}
throw new Error(`connect ${mode}:${port} timed out: ${errorMessage(lastError)}`);
}
async function dial(mode: "tcp" | "ws", port: number): Promise<BaseClient> {
const parserMap = new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
if (mode === "tcp") {
return connectTcp(HOST, port, 0, 0, parserMap);
}
return connectWs(HOST, port, WS_PATH, 0, 0, parserMap);
}
async function runSendPush(client: BaseClient): Promise<boolean> {
const pushed = new Promise<TestData>((resolve) => {
addListenerTyped(client.communicator, TestDataSchema, (message) => {
resolve(message);
});
});
try {
await client.communicator.send(
create(TestDataSchema, {
index: 101,
message: "fire from typescript client",
}),
);
passed("1", "fire-and-forget sent");
const message = await withTimeout(pushed, REQUEST_WINDOW_MS, "push from python server timed out");
if (message.index !== 200 || message.message !== "push from python server") {
fail("2", `unexpected push index=${message.index} message=${JSON.stringify(message.message)}`);
return false;
}
passed("2", "received push from python server");
return true;
} catch (err) {
fail("2", errorMessage(err));
return false;
}
}
async function runRequests(client: BaseClient): Promise<boolean> {
try {
const single = await sendRequestTyped(
client.communicator,
create(TestDataSchema, {
index: 21,
message: "single request from typescript",
}),
TestDataSchema,
REQUEST_WINDOW_MS,
);
if (single.index !== 42 || single.message !== "echo: single request from typescript") {
fail("3", `unexpected response index=${single.index} message=${JSON.stringify(single.message)}`);
return false;
}
passed("3", "single request response matched");
} catch (err) {
fail("3", errorMessage(err));
return false;
}
try {
await Promise.all(
Array.from({ length: 5 }, async (_, idx) => {
const index = 30 + idx;
const message = `multi request ${idx} from typescript`;
const response = await sendRequestTyped(
client.communicator,
create(TestDataSchema, { index, message }),
TestDataSchema,
REQUEST_WINDOW_MS,
);
if (response.index !== index * 2 || response.message !== `echo: ${message}`) {
throw new Error(
`request ${idx} got index=${response.index} message=${JSON.stringify(response.message)}`,
);
}
}),
);
passed("4", "concurrent request responses matched");
return true;
} catch (err) {
fail("4", errorMessage(err));
return false;
}
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
function passed(scenario: string, detail: string): void {
console.log(`PASS scenario=${scenario} detail=${detail}`);
}
function fail(scenario: string, error: string): void {
console.log(`FAIL scenario=${scenario} error=${error}`);
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
void main().catch((err) => {
fail("setup", errorMessage(err));
process.exitCode = 1;
});

View file

@ -0,0 +1,288 @@
import * as childProcess from "node:child_process";
import * as path from "node:path";
import * as readline from "node:readline";
import { fileURLToPath } from "node:url";
import { create } from "@bufbuild/protobuf";
import {
addListenerTyped,
addRequestListenerTyped,
parserFromSchema,
} from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { TcpClient } from "../src/tcp_client.js";
import { TcpServer } from "../src/tcp_server.js";
import { WsClient } from "../src/ws_client.js";
import { WsServer } from "../src/ws_server.js";
const __filename = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(__filename), "../..");
const dartDir = path.join(repoRoot, "dart");
const HOST = "127.0.0.1";
const TCP_PORT = 29802;
const WS_PORT = 29804;
const WS_PATH = "/";
const PROCESS_TIMEOUT_MS = 20_000;
const SERVER_OBSERVATION_MS = 200;
async function main(): Promise<void> {
console.log(`INFO typeName ts=${TestDataSchema.typeName}`);
try {
await runTcp();
await runWs();
console.log("PASS all typescript-server/dart-client crosstests passed");
} catch (err) {
console.error(`FAIL crosstest error=${errorMessage(err)}`);
process.exitCode = 1;
}
}
async function runTcp(): Promise<void> {
await runTcpSendPush();
await sleep(150);
await runTcpRequests();
}
async function runWs(): Promise<void> {
await runWsSendPush();
await sleep(150);
await runWsRequests();
}
async function runTcpSendPush(): Promise<void> {
let resolveReceived: ((value: boolean) => void) | null = null;
const received = new Promise<boolean>((resolve) => {
resolveReceived = resolve;
});
const server = new TcpServer(HOST, TCP_PORT, (socket) => new TcpClient(socket, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, (data) => {
console.log(`SERVER_RECEIVED index=${data.index} message=${data.message}`);
const valid = data.index === 101 && data.message === "fire from dart client";
if (resolveReceived !== null) {
resolveReceived(valid);
resolveReceived = null;
}
if (valid) {
void client.communicator.send(
create(TestDataSchema, {
index: 200,
message: "push from typescript server",
}),
);
}
});
};
await withServer(server, async () => {
await runDartClient("tcp", TCP_PORT, "send-push", new Set(["1", "2"]));
const ok = await withTimeout(received, SERVER_OBSERVATION_MS, "TCP send-push server did not receive expected data");
if (!ok) {
throw new Error("TCP send-push server received unexpected data");
}
});
}
async function runTcpRequests(): Promise<void> {
const server = new TcpServer(HOST, TCP_PORT, (socket) => new TcpClient(socket, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
await withServer(server, () => runDartClient("tcp", TCP_PORT, "requests", new Set(["3", "4"])));
}
async function runWsSendPush(): Promise<void> {
let resolveReceived: ((value: boolean) => void) | null = null;
const received = new Promise<boolean>((resolve) => {
resolveReceived = resolve;
});
const server = new WsServer(HOST, WS_PORT, WS_PATH, (ws) => new WsClient(ws, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, (data) => {
console.log(`SERVER_RECEIVED index=${data.index} message=${data.message}`);
const valid = data.index === 101 && data.message === "fire from dart client";
if (resolveReceived !== null) {
resolveReceived(valid);
resolveReceived = null;
}
if (valid) {
void client.communicator.send(
create(TestDataSchema, {
index: 200,
message: "push from typescript server",
}),
);
}
});
};
await withServer(server, async () => {
await runDartClient("ws", WS_PORT, "send-push", new Set(["1", "2"]));
const ok = await withTimeout(received, SERVER_OBSERVATION_MS, "WS send-push server did not receive expected data");
if (!ok) {
throw new Error("WS send-push server received unexpected data");
}
});
}
async function runWsRequests(): Promise<void> {
const server = new WsServer(HOST, WS_PORT, WS_PATH, (ws) => new WsClient(ws, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
await withServer(server, () => runDartClient("ws", WS_PORT, "requests", new Set(["3", "4"])));
}
async function withServer(
server: { start: () => Promise<void>; stop: () => Promise<void> },
body: () => Promise<void>,
): Promise<void> {
await server.start();
try {
await body();
} finally {
await server.stop();
}
}
async function runDartClient(
mode: "tcp" | "ws",
port: number,
phase: "send-push" | "requests",
expected: Set<string>,
): Promise<void> {
await runClientProcess(
"dart",
[
"run",
"crosstest/typescript_dart_client.dart",
`--mode=${mode}`,
`--port=${port}`,
`--phase=${phase}`,
],
dartDir,
`dart-client ${mode}/${phase}`,
expected,
);
}
async function runClientProcess(
command: string,
args: string[],
cwd: string,
label: string,
expected: Set<string>,
): Promise<void> {
const child = childProcess.spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
if (child.stdout === null || child.stderr === null) {
throw new Error(`${label} stdio is not piped`);
}
const resultLines: string[] = [];
const stdoutDone = collectLines(child.stdout, (line) => {
console.log(line);
if (line.startsWith("PASS ") || line.startsWith("FAIL ")) {
resultLines.push(line);
}
});
const stderrDone = collectLines(child.stderr, (line) => {
console.error(line);
});
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, PROCESS_TIMEOUT_MS);
let exitCode: number;
try {
exitCode = await new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code ?? -1));
});
} finally {
clearTimeout(timer);
}
await Promise.all([stdoutDone, stderrDone]);
if (timedOut) {
throw new Error(`${label} timed out`);
}
validateResultLines(label, exitCode, resultLines, expected);
}
function collectLines(stream: NodeJS.ReadableStream, onLine: (line: string) => void): Promise<void> {
const rl = readline.createInterface({ input: stream });
return new Promise((resolve, reject) => {
rl.on("line", onLine);
rl.once("close", resolve);
rl.once("error", reject);
});
}
function validateResultLines(label: string, exitCode: number, lines: string[], expected: Set<string>): void {
const failed = lines.filter((line) => line.startsWith("FAIL "));
const passed = new Set<string>();
for (const line of lines) {
if (!line.startsWith("PASS ")) {
continue;
}
const match = /scenario=([^ ]+)/.exec(line);
if (match !== null) {
passed.add(match[1]);
}
}
const missing = [...expected].filter((scenario) => !passed.has(scenario));
if (exitCode !== 0 || failed.length > 0 || missing.length > 0) {
throw new Error(`${label} failed exitCode=${exitCode} failed=${JSON.stringify(failed)} missing=${JSON.stringify(missing)}`);
}
}
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
void main();

View file

@ -0,0 +1,288 @@
import * as childProcess from "node:child_process";
import * as path from "node:path";
import * as readline from "node:readline";
import { fileURLToPath } from "node:url";
import { create } from "@bufbuild/protobuf";
import {
addListenerTyped,
addRequestListenerTyped,
parserFromSchema,
} from "../src/communicator.js";
import { TestDataSchema } from "../src/packets/message_common_pb.js";
import { TcpClient } from "../src/tcp_client.js";
import { TcpServer } from "../src/tcp_server.js";
import { WsClient } from "../src/ws_client.js";
import { WsServer } from "../src/ws_server.js";
const __filename = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(__filename), "../..");
const goDir = path.join(repoRoot, "go");
const HOST = "127.0.0.1";
const TCP_PORT = 29806;
const WS_PORT = 29808;
const WS_PATH = "/";
const PROCESS_TIMEOUT_MS = 20_000;
const SERVER_OBSERVATION_MS = 200;
async function main(): Promise<void> {
console.log(`INFO typeName ts=${TestDataSchema.typeName}`);
try {
await runTcp();
await runWs();
console.log("PASS all typescript-server/go-client crosstests passed");
} catch (err) {
console.error(`FAIL crosstest error=${errorMessage(err)}`);
process.exitCode = 1;
}
}
async function runTcp(): Promise<void> {
await runTcpSendPush();
await sleep(150);
await runTcpRequests();
}
async function runWs(): Promise<void> {
await runWsSendPush();
await sleep(150);
await runWsRequests();
}
async function runTcpSendPush(): Promise<void> {
let resolveReceived: ((value: boolean) => void) | null = null;
const received = new Promise<boolean>((resolve) => {
resolveReceived = resolve;
});
const server = new TcpServer(HOST, TCP_PORT, (socket) => new TcpClient(socket, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, (data) => {
console.log(`SERVER_RECEIVED index=${data.index} message=${data.message}`);
const valid = data.index === 101 && data.message === "fire from go client";
if (resolveReceived !== null) {
resolveReceived(valid);
resolveReceived = null;
}
if (valid) {
void client.communicator.send(
create(TestDataSchema, {
index: 200,
message: "push from typescript server",
}),
);
}
});
};
await withServer(server, async () => {
await runGoClient("tcp", TCP_PORT, "send-push", new Set(["1", "2"]));
const ok = await withTimeout(received, SERVER_OBSERVATION_MS, "TCP send-push server did not receive expected data");
if (!ok) {
throw new Error("TCP send-push server received unexpected data");
}
});
}
async function runTcpRequests(): Promise<void> {
const server = new TcpServer(HOST, TCP_PORT, (socket) => new TcpClient(socket, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
await withServer(server, () => runGoClient("tcp", TCP_PORT, "requests", new Set(["3", "4"])));
}
async function runWsSendPush(): Promise<void> {
let resolveReceived: ((value: boolean) => void) | null = null;
const received = new Promise<boolean>((resolve) => {
resolveReceived = resolve;
});
const server = new WsServer(HOST, WS_PORT, WS_PATH, (ws) => new WsClient(ws, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, (data) => {
console.log(`SERVER_RECEIVED index=${data.index} message=${data.message}`);
const valid = data.index === 101 && data.message === "fire from go client";
if (resolveReceived !== null) {
resolveReceived(valid);
resolveReceived = null;
}
if (valid) {
void client.communicator.send(
create(TestDataSchema, {
index: 200,
message: "push from typescript server",
}),
);
}
});
};
await withServer(server, async () => {
await runGoClient("ws", WS_PORT, "send-push", new Set(["1", "2"]));
const ok = await withTimeout(received, SERVER_OBSERVATION_MS, "WS send-push server did not receive expected data");
if (!ok) {
throw new Error("WS send-push server received unexpected data");
}
});
}
async function runWsRequests(): Promise<void> {
const server = new WsServer(HOST, WS_PORT, WS_PATH, (ws) => new WsClient(ws, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
await withServer(server, () => runGoClient("ws", WS_PORT, "requests", new Set(["3", "4"])));
}
async function withServer(
server: { start: () => Promise<void>; stop: () => Promise<void> },
body: () => Promise<void>,
): Promise<void> {
await server.start();
try {
await body();
} finally {
await server.stop();
}
}
async function runGoClient(
mode: "tcp" | "ws",
port: number,
phase: "send-push" | "requests",
expected: Set<string>,
): Promise<void> {
await runClientProcess(
"go",
[
"run",
"./crosstest/typescript_go_client",
`--mode=${mode}`,
`--port=${port}`,
`--phase=${phase}`,
],
goDir,
`go-client ${mode}/${phase}`,
expected,
);
}
async function runClientProcess(
command: string,
args: string[],
cwd: string,
label: string,
expected: Set<string>,
): Promise<void> {
const child = childProcess.spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
if (child.stdout === null || child.stderr === null) {
throw new Error(`${label} stdio is not piped`);
}
const resultLines: string[] = [];
const stdoutDone = collectLines(child.stdout, (line) => {
console.log(line);
if (line.startsWith("PASS ") || line.startsWith("FAIL ")) {
resultLines.push(line);
}
});
const stderrDone = collectLines(child.stderr, (line) => {
console.error(line);
});
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, PROCESS_TIMEOUT_MS);
let exitCode: number;
try {
exitCode = await new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code ?? -1));
});
} finally {
clearTimeout(timer);
}
await Promise.all([stdoutDone, stderrDone]);
if (timedOut) {
throw new Error(`${label} timed out`);
}
validateResultLines(label, exitCode, resultLines, expected);
}
function collectLines(stream: NodeJS.ReadableStream, onLine: (line: string) => void): Promise<void> {
const rl = readline.createInterface({ input: stream });
return new Promise((resolve, reject) => {
rl.on("line", onLine);
rl.once("close", resolve);
rl.once("error", reject);
});
}
function validateResultLines(label: string, exitCode: number, lines: string[], expected: Set<string>): void {
const failed = lines.filter((line) => line.startsWith("FAIL "));
const passed = new Set<string>();
for (const line of lines) {
if (!line.startsWith("PASS ")) {
continue;
}
const match = /scenario=([^ ]+)/.exec(line);
if (match !== null) {
passed.add(match[1]);
}
}
const missing = [...expected].filter((scenario) => !passed.has(scenario));
if (exitCode !== 0 || failed.length > 0 || missing.length > 0) {
throw new Error(`${label} failed exitCode=${exitCode} failed=${JSON.stringify(failed)} missing=${JSON.stringify(missing)}`);
}
}
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
void main();

View file

@ -0,0 +1,286 @@
import * as childProcess from "node:child_process";
import * as path from "node:path";
import * as readline from "node:readline";
import { fileURLToPath } from "node:url";
import { create } from "@bufbuild/protobuf";
import {
addListenerTyped,
addRequestListenerTyped,
parserFromSchema,
} from "../src/communicator.js";
import { TestDataSchema } from "../src/packets/message_common_pb.js";
import { TcpClient } from "../src/tcp_client.js";
import { TcpServer } from "../src/tcp_server.js";
import { WsClient } from "../src/ws_client.js";
import { WsServer } from "../src/ws_server.js";
const __filename = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(__filename), "../..");
const kotlinDir = path.join(repoRoot, "kotlin");
const HOST = "127.0.0.1";
const TCP_PORT = 29810;
const WS_PORT = 29812;
const WS_PATH = "/";
const PROCESS_TIMEOUT_MS = 20_000;
const SERVER_OBSERVATION_MS = 200;
async function main(): Promise<void> {
console.log(`INFO typeName ts=${TestDataSchema.typeName}`);
try {
await runTcp();
await runWs();
console.log("PASS all typescript-server/kotlin-client crosstests passed");
} catch (err) {
console.error(`FAIL crosstest error=${errorMessage(err)}`);
process.exitCode = 1;
}
}
async function runTcp(): Promise<void> {
await runTcpSendPush();
await sleep(150);
await runTcpRequests();
}
async function runWs(): Promise<void> {
await runWsSendPush();
await sleep(150);
await runWsRequests();
}
async function runTcpSendPush(): Promise<void> {
let resolveReceived: ((value: boolean) => void) | null = null;
const received = new Promise<boolean>((resolve) => {
resolveReceived = resolve;
});
const server = new TcpServer(HOST, TCP_PORT, (socket) => new TcpClient(socket, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, (data) => {
console.log(`SERVER_RECEIVED index=${data.index} message=${data.message}`);
const valid = data.index === 101 && data.message === "fire from kotlin client";
if (resolveReceived !== null) {
resolveReceived(valid);
resolveReceived = null;
}
if (valid) {
void client.communicator.send(
create(TestDataSchema, {
index: 200,
message: "push from typescript server",
}),
);
}
});
};
await withServer(server, async () => {
await runKotlinClient("tcp", TCP_PORT, "send-push", new Set(["1", "2"]));
const ok = await withTimeout(received, SERVER_OBSERVATION_MS, "TCP send-push server did not receive expected data");
if (!ok) {
throw new Error("TCP send-push server received unexpected data");
}
});
}
async function runTcpRequests(): Promise<void> {
const server = new TcpServer(HOST, TCP_PORT, (socket) => new TcpClient(socket, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
await withServer(server, () => runKotlinClient("tcp", TCP_PORT, "requests", new Set(["3", "4"])));
}
async function runWsSendPush(): Promise<void> {
let resolveReceived: ((value: boolean) => void) | null = null;
const received = new Promise<boolean>((resolve) => {
resolveReceived = resolve;
});
const server = new WsServer(HOST, WS_PORT, WS_PATH, (ws) => new WsClient(ws, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, (data) => {
console.log(`SERVER_RECEIVED index=${data.index} message=${data.message}`);
const valid = data.index === 101 && data.message === "fire from kotlin client";
if (resolveReceived !== null) {
resolveReceived(valid);
resolveReceived = null;
}
if (valid) {
void client.communicator.send(
create(TestDataSchema, {
index: 200,
message: "push from typescript server",
}),
);
}
});
};
await withServer(server, async () => {
await runKotlinClient("ws", WS_PORT, "send-push", new Set(["1", "2"]));
const ok = await withTimeout(received, SERVER_OBSERVATION_MS, "WS send-push server did not receive expected data");
if (!ok) {
throw new Error("WS send-push server received unexpected data");
}
});
}
async function runWsRequests(): Promise<void> {
const server = new WsServer(HOST, WS_PORT, WS_PATH, (ws) => new WsClient(ws, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
await withServer(server, () => runKotlinClient("ws", WS_PORT, "requests", new Set(["3", "4"])));
}
async function withServer(
server: { start: () => Promise<void>; stop: () => Promise<void> },
body: () => Promise<void>,
): Promise<void> {
await server.start();
try {
await body();
} finally {
await server.stop();
}
}
async function runKotlinClient(
mode: "tcp" | "ws",
port: number,
phase: "send-push" | "requests",
expected: Set<string>,
): Promise<void> {
await runClientProcess(
"./gradlew",
[
"run",
"-PmainClass=com.tokilabs.toki_socket.crosstest.TypescriptKotlinClientKt",
`--args=--mode=${mode} --port=${port} --phase=${phase}`,
],
kotlinDir,
`kotlin-client ${mode}/${phase}`,
expected,
);
}
async function runClientProcess(
command: string,
args: string[],
cwd: string,
label: string,
expected: Set<string>,
): Promise<void> {
const child = childProcess.spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
if (child.stdout === null || child.stderr === null) {
throw new Error(`${label} stdio is not piped`);
}
const resultLines: string[] = [];
const stdoutDone = collectLines(child.stdout, (line) => {
console.log(line);
if (line.startsWith("PASS ") || line.startsWith("FAIL ")) {
resultLines.push(line);
}
});
const stderrDone = collectLines(child.stderr, (line) => {
console.error(line);
});
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, PROCESS_TIMEOUT_MS);
let exitCode: number;
try {
exitCode = await new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code ?? -1));
});
} finally {
clearTimeout(timer);
}
await Promise.all([stdoutDone, stderrDone]);
if (timedOut) {
throw new Error(`${label} timed out`);
}
validateResultLines(label, exitCode, resultLines, expected);
}
function collectLines(stream: NodeJS.ReadableStream, onLine: (line: string) => void): Promise<void> {
const rl = readline.createInterface({ input: stream });
return new Promise((resolve, reject) => {
rl.on("line", onLine);
rl.once("close", resolve);
rl.once("error", reject);
});
}
function validateResultLines(label: string, exitCode: number, lines: string[], expected: Set<string>): void {
const failed = lines.filter((line) => line.startsWith("FAIL "));
const passed = new Set<string>();
for (const line of lines) {
if (!line.startsWith("PASS ")) {
continue;
}
const match = /scenario=([^ ]+)/.exec(line);
if (match !== null) {
passed.add(match[1]);
}
}
const missing = [...expected].filter((scenario) => !passed.has(scenario));
if (exitCode !== 0 || failed.length > 0 || missing.length > 0) {
throw new Error(`${label} failed exitCode=${exitCode} failed=${JSON.stringify(failed)} missing=${JSON.stringify(missing)}`);
}
}
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
void main();

View file

@ -0,0 +1,288 @@
import * as childProcess from "node:child_process";
import * as path from "node:path";
import * as readline from "node:readline";
import { fileURLToPath } from "node:url";
import { create } from "@bufbuild/protobuf";
import {
addListenerTyped,
addRequestListenerTyped,
parserFromSchema,
} from "../src/communicator.js";
import { TestDataSchema } from "../src/packets/message_common_pb.js";
import { TcpClient } from "../src/tcp_client.js";
import { TcpServer } from "../src/tcp_server.js";
import { WsClient } from "../src/ws_client.js";
import { WsServer } from "../src/ws_server.js";
const __filename = fileURLToPath(import.meta.url);
const repoRoot = path.resolve(path.dirname(__filename), "../..");
const pythonDir = path.join(repoRoot, "python");
const HOST = "127.0.0.1";
const TCP_PORT = 29814;
const WS_PORT = 29816;
const WS_PATH = "/";
const PROCESS_TIMEOUT_MS = 20_000;
const SERVER_OBSERVATION_MS = 200;
async function main(): Promise<void> {
console.log(`INFO typeName ts=${TestDataSchema.typeName}`);
try {
await runTcp();
await runWs();
console.log("PASS all typescript-server/python-client crosstests passed");
} catch (err) {
console.error(`FAIL crosstest error=${errorMessage(err)}`);
process.exitCode = 1;
}
}
async function runTcp(): Promise<void> {
await runTcpSendPush();
await sleep(150);
await runTcpRequests();
}
async function runWs(): Promise<void> {
await runWsSendPush();
await sleep(150);
await runWsRequests();
}
async function runTcpSendPush(): Promise<void> {
let resolveReceived: ((value: boolean) => void) | null = null;
const received = new Promise<boolean>((resolve) => {
resolveReceived = resolve;
});
const server = new TcpServer(HOST, TCP_PORT, (socket) => new TcpClient(socket, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, (data) => {
console.log(`SERVER_RECEIVED index=${data.index} message=${data.message}`);
const valid = data.index === 101 && data.message === "fire from python client";
if (resolveReceived !== null) {
resolveReceived(valid);
resolveReceived = null;
}
if (valid) {
void client.communicator.send(
create(TestDataSchema, {
index: 200,
message: "push from typescript server",
}),
);
}
});
};
await withServer(server, async () => {
await runPythonClient("tcp", TCP_PORT, "send-push", new Set(["1", "2"]));
const ok = await withTimeout(received, SERVER_OBSERVATION_MS, "TCP send-push server did not receive expected data");
if (!ok) {
throw new Error("TCP send-push server received unexpected data");
}
});
}
async function runTcpRequests(): Promise<void> {
const server = new TcpServer(HOST, TCP_PORT, (socket) => new TcpClient(socket, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
await withServer(server, () => runPythonClient("tcp", TCP_PORT, "requests", new Set(["3", "4"])));
}
async function runWsSendPush(): Promise<void> {
let resolveReceived: ((value: boolean) => void) | null = null;
const received = new Promise<boolean>((resolve) => {
resolveReceived = resolve;
});
const server = new WsServer(HOST, WS_PORT, WS_PATH, (ws) => new WsClient(ws, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, (data) => {
console.log(`SERVER_RECEIVED index=${data.index} message=${data.message}`);
const valid = data.index === 101 && data.message === "fire from python client";
if (resolveReceived !== null) {
resolveReceived(valid);
resolveReceived = null;
}
if (valid) {
void client.communicator.send(
create(TestDataSchema, {
index: 200,
message: "push from typescript server",
}),
);
}
});
};
await withServer(server, async () => {
await runPythonClient("ws", WS_PORT, "send-push", new Set(["1", "2"]));
const ok = await withTimeout(received, SERVER_OBSERVATION_MS, "WS send-push server did not receive expected data");
if (!ok) {
throw new Error("WS send-push server received unexpected data");
}
});
}
async function runWsRequests(): Promise<void> {
const server = new WsServer(HOST, WS_PORT, WS_PATH, (ws) => new WsClient(ws, 0, 0, parserMap()));
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
await withServer(server, () => runPythonClient("ws", WS_PORT, "requests", new Set(["3", "4"])));
}
async function withServer(
server: { start: () => Promise<void>; stop: () => Promise<void> },
body: () => Promise<void>,
): Promise<void> {
await server.start();
try {
await body();
} finally {
await server.stop();
}
}
async function runPythonClient(
mode: "tcp" | "ws",
port: number,
phase: "send-push" | "requests",
expected: Set<string>,
): Promise<void> {
await runClientProcess(
"python3",
[
"-m",
"crosstest.typescript_python_client",
`--mode=${mode}`,
`--port=${port}`,
`--phase=${phase}`,
],
pythonDir,
`python-client ${mode}/${phase}`,
expected,
);
}
async function runClientProcess(
command: string,
args: string[],
cwd: string,
label: string,
expected: Set<string>,
): Promise<void> {
const child = childProcess.spawn(command, args, {
cwd,
stdio: ["ignore", "pipe", "pipe"],
});
if (child.stdout === null || child.stderr === null) {
throw new Error(`${label} stdio is not piped`);
}
const resultLines: string[] = [];
const stdoutDone = collectLines(child.stdout, (line) => {
console.log(line);
if (line.startsWith("PASS ") || line.startsWith("FAIL ")) {
resultLines.push(line);
}
});
const stderrDone = collectLines(child.stderr, (line) => {
console.error(line);
});
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
child.kill("SIGKILL");
}, PROCESS_TIMEOUT_MS);
let exitCode: number;
try {
exitCode = await new Promise<number>((resolve, reject) => {
child.once("error", reject);
child.once("close", (code) => resolve(code ?? -1));
});
} finally {
clearTimeout(timer);
}
await Promise.all([stdoutDone, stderrDone]);
if (timedOut) {
throw new Error(`${label} timed out`);
}
validateResultLines(label, exitCode, resultLines, expected);
}
function collectLines(stream: NodeJS.ReadableStream, onLine: (line: string) => void): Promise<void> {
const rl = readline.createInterface({ input: stream });
return new Promise((resolve, reject) => {
rl.on("line", onLine);
rl.once("close", resolve);
rl.once("error", reject);
});
}
function validateResultLines(label: string, exitCode: number, lines: string[], expected: Set<string>): void {
const failed = lines.filter((line) => line.startsWith("FAIL "));
const passed = new Set<string>();
for (const line of lines) {
if (!line.startsWith("PASS ")) {
continue;
}
const match = /scenario=([^ ]+)/.exec(line);
if (match !== null) {
passed.add(match[1]);
}
}
const missing = [...expected].filter((scenario) => !passed.has(scenario));
if (exitCode !== 0 || failed.length > 0 || missing.length > 0) {
throw new Error(`${label} failed exitCode=${exitCode} failed=${JSON.stringify(failed)} missing=${JSON.stringify(missing)}`);
}
}
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_, reject) => {
timer = setTimeout(() => reject(new Error(label)), timeoutMs);
}),
]);
} finally {
if (timer !== undefined) {
clearTimeout(timer);
}
}
}
function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err);
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
void main();

1769
typescript/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

23
typescript/package.json Normal file
View file

@ -0,0 +1,23 @@
{
"name": "toki-socket",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc -p tsconfig.json",
"test": "vitest run",
"check": "tsc --noEmit"
},
"dependencies": {
"@bufbuild/protobuf": "^2.2.5",
"ws": "^8.18.1"
},
"devDependencies": {
"@bufbuild/protoc-gen-es": "^2.2.5",
"@types/node": "^22.15.21",
"@types/ws": "^8.18.1",
"tsx": "^4.19.3",
"typescript": "^5.8.3",
"vitest": "^3.1.3"
}
}

View file

@ -0,0 +1,145 @@
import { create } from "@bufbuild/protobuf";
import { addListenerTyped, Communicator, type ParserMap, type Transport } from "./communicator.js";
import { HeartBeatSchema, type PacketBase } from "./packets/message_common_pb.js";
export abstract class BaseClient implements Transport {
readonly communicator = new Communicator();
private readonly intervalMs: number;
private readonly waitMs: number;
private readonly doClose: () => Promise<void>;
private isClosed = false;
private closePromise: Promise<void> | null = null;
private hbTimer: ReturnType<typeof setTimeout> | null = null;
private waitingHbResponse = false;
private disconnectListeners: Array<(self: BaseClient) => void | Promise<void>> = [];
protected constructor(intervalSec: number, waitSec: number, doClose: () => Promise<void>) {
this.intervalMs = intervalSec * 1000;
this.waitMs = waitSec * 1000;
this.doClose = doClose;
}
abstract writePacket(base: PacketBase): Promise<void>;
protected initBase(parserMap: ParserMap): void {
this.communicator.initialize(this, parserMap);
this.communicator.setWriteErrorHandler(() => {
void this.onDisconnected();
});
addListenerTyped(this.communicator, HeartBeatSchema, () => {
void this.onHeartbeat();
});
void this.sendHeartbeat();
}
async close(): Promise<void> {
if (this.closePromise !== null) {
return this.closePromise;
}
this.closePromise = (async () => {
if (this.isClosed) {
return;
}
this.isClosed = true;
this.communicator.shutdown();
this.stopHeartbeat();
let closeError: unknown;
try {
await this.doClose();
} catch (err) {
closeError = err;
}
await this.notifyDisconnected();
if (closeError !== undefined) {
throw closeError;
}
})();
return this.closePromise;
}
addDisconnectListener(fn: (self: this) => void | Promise<void>): void {
this.disconnectListeners.push(fn as (self: BaseClient) => void | Promise<void>);
}
removeDisconnectListeners(): void {
this.disconnectListeners = [];
}
protected async sendHeartbeat(): Promise<void> {
if (!this.communicator.isAlive() || this.intervalMs <= 0) {
return;
}
this.stopHeartbeat();
this.hbTimer = setTimeout(() => {
void this.onHeartbeatIntervalElapsed();
}, this.intervalMs);
}
protected async onHeartbeat(): Promise<void> {
if (this.waitingHbResponse) {
this.waitingHbResponse = false;
return;
}
try {
await this.communicator.send(create(HeartBeatSchema));
} catch {
return;
}
}
protected async onDisconnected(): Promise<void> {
try {
await this.close();
} catch {
return;
}
}
private async onHeartbeatIntervalElapsed(): Promise<void> {
if (!this.communicator.isAlive()) {
return;
}
this.waitingHbResponse = true;
try {
await this.communicator.send(create(HeartBeatSchema));
} catch {
return;
}
this.stopHeartbeat();
if (this.waitMs > 0) {
this.hbTimer = setTimeout(() => {
void this.onHeartbeatWaitElapsed();
}, this.waitMs);
}
}
private async onHeartbeatWaitElapsed(): Promise<void> {
if (this.communicator.isAlive()) {
await this.onDisconnected();
}
}
private stopHeartbeat(): void {
if (this.hbTimer !== null) {
clearTimeout(this.hbTimer);
this.hbTimer = null;
}
}
private async notifyDisconnected(): Promise<void> {
const listeners = [...this.disconnectListeners];
this.disconnectListeners = [];
for (const listener of listeners) {
try {
await listener(this);
} catch {
continue;
}
}
}
}

View file

@ -0,0 +1,428 @@
import { create, fromBinary, toBinary, type Message } from "@bufbuild/protobuf";
import type { GenMessage } from "@bufbuild/protobuf/codegenv2";
import {
HeartBeatSchema,
PacketBaseSchema,
type PacketBase,
} from "./packets/message_common_pb.js";
export class NotConnectedError extends Error {
constructor() {
super("not connected");
this.name = "NotConnectedError";
}
}
export type MessageType<T extends Message = Message> = GenMessage<T>;
export type MessageParser<T extends Message = Message> = ((data: Uint8Array) => T) & {
schema?: MessageType<T>;
};
export type ParserMap = Map<string, MessageParser>;
export interface Transport {
writePacket(base: PacketBase): Promise<void>;
close(): Promise<void>;
}
type RequestHandler = (
msg: Message,
nonce: number,
) => Message | Promise<Message | null | undefined> | null | undefined | void;
interface PendingRequest {
expectedTypeName: string;
timer: ReturnType<typeof setTimeout>;
resolve: (msg: Message) => void;
reject: (err: Error) => void;
}
interface QueueItem {
base: PacketBase;
resolve: () => void;
reject: (err: Error) => void;
}
export function parserFromSchema<T extends Message>(schema: MessageType<T>): MessageParser<T> {
const parser = ((data: Uint8Array) => fromBinary(schema, data)) as MessageParser<T>;
parser.schema = schema;
return parser;
}
export class Communicator {
private nonce = 0;
private isAliveFlag = false;
private parserMap: ParserMap = new Map();
private schemaMap = new Map<string, MessageType>();
private handlers = new Map<string, Array<(msg: Message) => void>>();
private reqHandlers = new Map<string, RequestHandler>();
private pendingRequests = new Map<number, PendingRequest>();
private writeQueue: QueueItem[] = [];
private writeQueueRunning = false;
private transport: Transport | null = null;
private writeErrorHandler: ((err: Error) => void) | null = null;
private closedPromise: Promise<void> = Promise.resolve();
private resolveClosed: (() => void) | null = null;
initialize(transport: Transport, parserMap: ParserMap): void {
this.transport = transport;
this.parserMap = new Map();
this.schemaMap = new Map();
this.handlers = new Map();
this.reqHandlers = new Map();
this.pendingRequests = new Map();
this.writeQueue = [];
this.writeQueueRunning = false;
this.writeErrorHandler = null;
this.nonce = 0;
this.isAliveFlag = true;
this.resetClosedPromise();
for (const [typeName, parser] of parserMap) {
this.parserMap.set(typeName, parser);
if (parser.schema !== undefined) {
this.schemaMap.set(typeName, parser.schema);
}
}
const heartBeatParser = parserFromSchema(HeartBeatSchema);
this.parserMap.set(HeartBeatSchema.typeName, heartBeatParser);
this.schemaMap.set(HeartBeatSchema.typeName, HeartBeatSchema);
}
isAlive(): boolean {
return this.isAliveFlag;
}
waitClosed(): Promise<void> {
return this.closedPromise;
}
setWriteErrorHandler(fn: (err: Error) => void): void {
this.writeErrorHandler = fn;
}
private nextNonce(): number {
this.nonce += 1;
return this.nonce;
}
shutdown(): void {
if (!this.isAliveFlag) {
this.finishClosed();
return;
}
this.isAliveFlag = false;
const pendingRequests = Array.from(this.pendingRequests.values());
this.pendingRequests.clear();
for (const pending of pendingRequests) {
clearTimeout(pending.timer);
pending.reject(new NotConnectedError());
}
while (this.writeQueue.length > 0) {
this.writeQueue.shift()?.reject(new NotConnectedError());
}
this.finishClosed();
}
async close(): Promise<void> {
this.shutdown();
if (this.transport !== null) {
await this.transport.close();
}
}
async queuePacket(base: PacketBase): Promise<void> {
if (!this.isAliveFlag || this.transport === null) {
throw new NotConnectedError();
}
const promise = new Promise<void>((resolve, reject) => {
this.writeQueue.push({ base, resolve, reject });
});
if (!this.writeQueueRunning) {
void this.runWriteQueue();
}
return promise;
}
async send(msg: Message): Promise<void> {
if (!this.isAliveFlag) {
throw new NotConnectedError();
}
const encoded = this.encodeMessage(msg);
await this.queuePacket(
create(PacketBaseSchema, {
typeName: encoded.typeName,
nonce: this.nextNonce(),
data: encoded.data,
}),
);
}
async sendRequest<TRes extends Message>(
req: Message,
resType: MessageType<TRes>,
timeoutMs = 30000,
): Promise<TRes> {
if (!this.isAliveFlag) {
throw new NotConnectedError();
}
const requestNonce = this.nextNonce();
const encoded = this.encodeMessage(req);
const effectiveTimeout = timeoutMs > 0 ? timeoutMs : 30000;
return new Promise<TRes>((resolve, reject) => {
const timer = setTimeout(() => {
const pending = this.removePending(requestNonce);
if (pending !== undefined) {
pending.reject(new Error(`request timeout for nonce ${requestNonce}`));
}
}, effectiveTimeout);
this.pendingRequests.set(requestNonce, {
expectedTypeName: resType.typeName,
timer,
resolve: (msg) => resolve(msg as TRes),
reject,
});
void this.queuePacket(
create(PacketBaseSchema, {
typeName: encoded.typeName,
nonce: requestNonce,
data: encoded.data,
}),
).catch((err: unknown) => {
const pending = this.removePending(requestNonce);
if (pending !== undefined) {
pending.reject(toError(err));
}
});
});
}
addListener(typeName: string, fn: (msg: Message) => void): void {
if (this.reqHandlers.has(typeName)) {
throw new Error(`type ${typeName} is already registered with addRequestListener`);
}
const handlers = this.handlers.get(typeName) ?? [];
handlers.push(fn);
this.handlers.set(typeName, handlers);
}
removeListeners(typeName: string): void {
this.handlers.delete(typeName);
}
addRequestListener(typeName: string, fn: RequestHandler): void {
if ((this.handlers.get(typeName) ?? []).length > 0) {
throw new Error(`type ${typeName} is already registered with addListener`);
}
if (this.reqHandlers.has(typeName)) {
throw new Error(`type ${typeName} is already registered with addRequestListener`);
}
this.reqHandlers.set(typeName, fn);
}
onReceivedData(typeName: string, data: Uint8Array, nonce = 0, responseNonce = 0): void {
if (responseNonce > 0) {
this.handleResponse(typeName, data, responseNonce);
return;
}
const reqHandler = this.reqHandlers.get(typeName);
const listeners = [...(this.handlers.get(typeName) ?? [])];
if (reqHandler !== undefined) {
let message: Message;
try {
message = this.parse(typeName, data);
} catch {
return;
}
void this.runRequestHandler(reqHandler, message, nonce);
return;
}
if (listeners.length === 0) {
return;
}
let message: Message;
try {
message = this.parse(typeName, data);
} catch {
return;
}
for (const listener of listeners) {
listener(message);
}
}
private async runRequestHandler(
handler: RequestHandler,
message: Message,
requestNonce: number,
): Promise<void> {
try {
const result = await handler(message, requestNonce);
if (result == null || !this.isAliveFlag) {
return;
}
const encoded = this.encodeMessage(result);
await this.queuePacket(
create(PacketBaseSchema, {
typeName: encoded.typeName,
nonce: this.nextNonce(),
responseNonce: requestNonce,
data: encoded.data,
}),
);
} catch {
return;
}
}
private handleResponse(typeName: string, data: Uint8Array, responseNonce: number): void {
const pending = this.removePending(responseNonce);
if (pending === undefined) {
return;
}
if (typeName !== pending.expectedTypeName) {
pending.reject(
new Error(
`response type mismatch for nonce ${responseNonce}: expected ${pending.expectedTypeName}, got ${typeName}`,
),
);
return;
}
try {
pending.resolve(this.parse(typeName, data));
} catch (err) {
pending.reject(toError(err));
}
}
private parse(typeName: string, data: Uint8Array): Message {
const parser = this.parserMap.get(typeName);
if (parser === undefined) {
throw new Error(`protobuf parser is not registered for type ${typeName}`);
}
return parser(data);
}
private removePending(nonce: number): PendingRequest | undefined {
const pending = this.pendingRequests.get(nonce);
if (pending === undefined) {
return undefined;
}
clearTimeout(pending.timer);
this.pendingRequests.delete(nonce);
return pending;
}
private encodeMessage(msg: Message): { typeName: string; data: Uint8Array } {
const typeName = typeNameOf(msg);
const schema = this.schemaMap.get(typeName);
if (schema === undefined) {
throw new Error(`protobuf schema is not registered for type ${typeName}`);
}
return {
typeName,
data: toBinary(schema, msg),
};
}
private async runWriteQueue(): Promise<void> {
if (this.writeQueueRunning) {
return;
}
this.writeQueueRunning = true;
try {
while (this.writeQueue.length > 0) {
const item = this.writeQueue.shift();
if (item === undefined) {
continue;
}
if (!this.isAliveFlag || this.transport === null) {
item.reject(new NotConnectedError());
continue;
}
try {
await this.transport.writePacket(item.base);
item.resolve();
} catch (err) {
const error = toError(err);
item.reject(error);
this.writeErrorHandler?.(error);
}
}
} finally {
this.writeQueueRunning = false;
if (this.writeQueue.length > 0 && this.isAliveFlag) {
void this.runWriteQueue();
}
}
}
private resetClosedPromise(): void {
this.closedPromise = new Promise<void>((resolve) => {
this.resolveClosed = resolve;
});
}
private finishClosed(): void {
if (this.resolveClosed !== null) {
this.resolveClosed();
this.resolveClosed = null;
}
}
}
export function typeNameOf(msgOrType: Message | MessageType): string {
if ("$typeName" in msgOrType) {
return msgOrType.$typeName;
}
return msgOrType.typeName;
}
export function addListenerTyped<T extends Message>(
comm: Communicator,
msgType: MessageType<T>,
fn: (msg: T) => void,
): void {
comm.addListener(msgType.typeName, (msg) => fn(msg as T));
}
export function addRequestListenerTyped<TReq extends Message, TRes extends Message>(
comm: Communicator,
reqType: MessageType<TReq>,
fn: (req: TReq) => TRes | Promise<TRes | null | undefined> | null | undefined,
): void {
comm.addRequestListener(reqType.typeName, (req) => fn(req as TReq));
}
export function sendRequestTyped<TReq extends Message, TRes extends Message>(
comm: Communicator,
req: TReq,
resType: MessageType<TRes>,
timeoutMs?: number,
): Promise<TRes> {
return comm.sendRequest(req, resType, timeoutMs);
}
function toError(err: unknown): Error {
return err instanceof Error ? err : new Error(String(err));
}

7
typescript/src/index.ts Normal file
View file

@ -0,0 +1,7 @@
export * from "./base_client.js";
export * from "./communicator.js";
export * from "./packets/message_common_pb.js";
export * from "./tcp_client.js";
export * from "./tcp_server.js";
export * from "./ws_client.js";
export * from "./ws_server.js";

View file

@ -0,0 +1,81 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts"
// @generated from file message_common.proto (syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc } from "@bufbuild/protobuf/codegenv2";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file message_common.proto.
*/
export const file_message_common: GenFile = /*@__PURE__*/
fileDesc("ChRtZXNzYWdlX2NvbW1vbi5wcm90byJSCgpQYWNrZXRCYXNlEhAKCHR5cGVOYW1lGAEgASgJEg0KBW5vbmNlGAIgASgFEgwKBGRhdGEYAyABKAwSFQoNcmVzcG9uc2VOb25jZRgEIAEoBSILCglIZWFydEJlYXQiKgoIVGVzdERhdGESDQoFaW5kZXgYASABKAUSDwoHbWVzc2FnZRgCIAEoCWIGcHJvdG8z");
/**
* @generated from message PacketBase
*/
export type PacketBase = Message<"PacketBase"> & {
/**
* @generated from field: string typeName = 1;
*/
typeName: string;
/**
* @generated from field: int32 nonce = 2;
*/
nonce: number;
/**
* @generated from field: bytes data = 3;
*/
data: Uint8Array;
/**
* @generated from field: int32 responseNonce = 4;
*/
responseNonce: number;
};
/**
* Describes the message PacketBase.
* Use `create(PacketBaseSchema)` to create a new message.
*/
export const PacketBaseSchema: GenMessage<PacketBase> = /*@__PURE__*/
messageDesc(file_message_common, 0);
/**
* @generated from message HeartBeat
*/
export type HeartBeat = Message<"HeartBeat"> & {
};
/**
* Describes the message HeartBeat.
* Use `create(HeartBeatSchema)` to create a new message.
*/
export const HeartBeatSchema: GenMessage<HeartBeat> = /*@__PURE__*/
messageDesc(file_message_common, 1);
/**
* @generated from message TestData
*/
export type TestData = Message<"TestData"> & {
/**
* @generated from field: int32 index = 1;
*/
index: number;
/**
* @generated from field: string message = 2;
*/
message: string;
};
/**
* Describes the message TestData.
* Use `create(TestDataSchema)` to create a new message.
*/
export const TestDataSchema: GenMessage<TestData> = /*@__PURE__*/
messageDesc(file_message_common, 2);

View file

@ -0,0 +1,106 @@
import * as net from "node:net";
import { fromBinary, toBinary } from "@bufbuild/protobuf";
import { BaseClient } from "./base_client.js";
import { type ParserMap } from "./communicator.js";
import { PacketBaseSchema, type PacketBase } from "./packets/message_common_pb.js";
export const MAX_PACKET_SIZE = 64 << 20;
export class TcpClient extends BaseClient {
private readonly socket: net.Socket;
private readBuf = Buffer.alloc(0);
constructor(socket: net.Socket, intervalSec: number, waitSec: number, parserMap: ParserMap) {
super(intervalSec, waitSec, async () => {
if (!socket.destroyed) {
socket.destroy();
}
});
this.socket = socket;
this.initBase(parserMap);
socket.on("data", (chunk) => {
this.onData(chunk);
});
socket.on("error", () => {
void this.onDisconnected();
});
socket.on("close", () => {
void this.onDisconnected();
});
}
async writePacket(base: PacketBase): Promise<void> {
const data = toBinary(PacketBaseSchema, base);
if (data.byteLength > MAX_PACKET_SIZE) {
throw new Error("packet exceeds maximum size");
}
const header = Buffer.allocUnsafe(4);
header.writeUInt32BE(data.byteLength, 0);
await new Promise<void>((resolve, reject) => {
this.socket.write(Buffer.concat([header, Buffer.from(data)]), (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
private onData(chunk: Buffer): void {
this.readBuf = Buffer.concat([this.readBuf, chunk]);
while (this.readBuf.length >= 4) {
const length = this.readBuf.readUInt32BE(0);
if (length === 0) {
this.readBuf = this.readBuf.subarray(4);
continue;
}
if (length > MAX_PACKET_SIZE) {
void this.onDisconnected();
return;
}
if (this.readBuf.length < 4 + length) {
return;
}
const packetBytes = this.readBuf.subarray(4, 4 + length);
this.readBuf = this.readBuf.subarray(4 + length);
try {
const base = fromBinary(PacketBaseSchema, packetBytes);
this.communicator.onReceivedData(base.typeName, base.data, base.nonce, base.responseNonce);
void this.sendHeartbeat();
} catch {
void this.onDisconnected();
return;
}
}
}
}
export async function connectTcp(
host: string,
port: number,
intervalSec: number,
waitSec: number,
parserMap: ParserMap,
): Promise<TcpClient> {
return new Promise<TcpClient>((resolve, reject) => {
const socket = net.createConnection({ host, port });
const onError = (err: Error) => {
socket.off("connect", onConnect);
reject(err);
};
const onConnect = () => {
socket.off("error", onError);
resolve(new TcpClient(socket, intervalSec, waitSec, parserMap));
};
socket.once("error", onError);
socket.once("connect", onConnect);
});
}

View file

@ -0,0 +1,92 @@
import * as net from "node:net";
import { type Message } from "@bufbuild/protobuf";
import { TcpClient } from "./tcp_client.js";
export class TcpServer {
private readonly server: net.Server;
private readonly clients = new Set<TcpClient>();
private started = false;
onClientConnected: (client: TcpClient) => void = () => {};
constructor(
private readonly host: string,
private readonly listenPort: number,
private readonly newClient: (socket: net.Socket) => TcpClient,
) {
this.server = net.createServer((socket) => {
const client = this.newClient(socket);
this.clients.add(client);
client.addDisconnectListener(() => {
this.removeClient(client);
});
this.onClientConnected(client);
});
}
get port(): number {
const address = this.server.address();
if (address !== null && typeof address === "object") {
return address.port;
}
return this.listenPort;
}
start(): Promise<void> {
if (this.started) {
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const onError = (err: Error) => {
cleanup();
reject(err);
};
const onListening = () => {
cleanup();
this.started = true;
resolve();
};
const cleanup = () => {
this.server.off("error", onError);
this.server.off("listening", onListening);
};
this.server.once("error", onError);
this.server.once("listening", onListening);
this.server.listen(this.listenPort, this.host);
});
}
async stop(): Promise<void> {
if (!this.started) {
return;
}
this.started = false;
const clients = [...this.clients];
this.clients.clear();
const closeServer = new Promise<void>((resolve, reject) => {
this.server.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
await Promise.allSettled(clients.map(async (client) => client.close()));
await closeServer;
}
async broadcast(msg: Message): Promise<void> {
await Promise.all([...this.clients].map(async (client) => client.communicator.send(msg)));
}
private removeClient(client: TcpClient): void {
this.clients.delete(client);
}
}

View file

@ -0,0 +1,92 @@
import WebSocket, { type RawData } from "ws";
import { fromBinary, toBinary } from "@bufbuild/protobuf";
import { BaseClient } from "./base_client.js";
import { type ParserMap } from "./communicator.js";
import { PacketBaseSchema, type PacketBase } from "./packets/message_common_pb.js";
export class WsClient extends BaseClient {
private readonly ws: WebSocket;
constructor(ws: WebSocket, intervalSec: number, waitSec: number, parserMap: ParserMap) {
super(intervalSec, waitSec, async () => {
if (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING) {
ws.close();
}
});
this.ws = ws;
this.initBase(parserMap);
ws.on("message", (data) => {
this.onMessage(data);
});
ws.on("close", () => {
void this.onDisconnected();
});
ws.on("error", () => {
void this.onDisconnected();
});
}
async writePacket(base: PacketBase): Promise<void> {
const data = toBinary(PacketBaseSchema, base);
await new Promise<void>((resolve, reject) => {
this.ws.send(data, { binary: true }, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
}
private onMessage(data: RawData): void {
try {
const base = fromBinary(PacketBaseSchema, toUint8Array(data));
this.communicator.onReceivedData(base.typeName, base.data, base.nonce, base.responseNonce);
void this.sendHeartbeat();
} catch {
void this.onDisconnected();
}
}
}
export async function connectWs(
host: string,
port: number,
path: string,
intervalSec: number,
waitSec: number,
parserMap: ParserMap,
): Promise<WsClient> {
return new Promise<WsClient>((resolve, reject) => {
const ws = new WebSocket(`ws://${host}:${port}${path}`);
const onOpen = () => {
cleanup();
resolve(new WsClient(ws, intervalSec, waitSec, parserMap));
};
const onError = (err: Error) => {
cleanup();
reject(err);
};
const cleanup = () => {
ws.off("open", onOpen);
ws.off("error", onError);
};
ws.once("open", onOpen);
ws.once("error", onError);
});
}
function toUint8Array(data: RawData): Uint8Array {
if (Array.isArray(data)) {
return Buffer.concat(data.map((chunk) => Buffer.from(chunk)));
}
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
return new Uint8Array(data);
}

View file

@ -0,0 +1,95 @@
import { type Message } from "@bufbuild/protobuf";
import WebSocket, { WebSocketServer } from "ws";
import { WsClient } from "./ws_client.js";
export class WsServer {
private wss: WebSocketServer | null = null;
private readonly clients = new Set<WsClient>();
onClientConnected: (client: WsClient) => void = () => {};
constructor(
private readonly host: string,
private readonly listenPort: number,
private readonly path: string,
private readonly newClient: (ws: WebSocket) => WsClient,
) {}
get port(): number {
const address = this.wss?.address();
if (address !== undefined && address !== null && typeof address === "object") {
return address.port;
}
return this.listenPort;
}
start(): Promise<void> {
if (this.wss !== null) {
return Promise.resolve();
}
return new Promise<void>((resolve, reject) => {
const wss = new WebSocketServer({
host: this.host,
path: this.path,
port: this.listenPort,
});
const onError = (err: Error) => {
cleanup();
reject(err);
};
const onListening = () => {
cleanup();
this.wss = wss;
resolve();
};
const cleanup = () => {
wss.off("error", onError);
wss.off("listening", onListening);
};
wss.once("error", onError);
wss.once("listening", onListening);
wss.on("connection", (ws) => {
const client = this.newClient(ws);
this.clients.add(client);
client.addDisconnectListener(() => {
this.removeClient(client);
});
this.onClientConnected(client);
});
});
}
async stop(): Promise<void> {
if (this.wss === null) {
return;
}
const wss = this.wss;
this.wss = null;
const clients = [...this.clients];
this.clients.clear();
const closeServer = new Promise<void>((resolve, reject) => {
wss.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
await Promise.allSettled(clients.map(async (client) => client.close()));
await closeServer;
}
async broadcast(msg: Message): Promise<void> {
await Promise.all([...this.clients].map(async (client) => client.communicator.send(msg)));
}
private removeClient(client: WsClient): void {
this.clients.delete(client);
}
}

View file

@ -0,0 +1,128 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { BaseClient } from "../src/base_client.js";
import { parserFromSchema } from "../src/communicator.js";
import {
HeartBeatSchema,
TestDataSchema,
type PacketBase,
} from "../src/packets/message_common_pb.js";
class TestClient extends BaseClient {
closeCalls = 0;
packets: PacketBase[] = [];
constructor(intervalSec: number, waitSec: number) {
super(intervalSec, waitSec, async () => {
this.closeCalls += 1;
});
this.initBase(new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]));
}
async writePacket(base: PacketBase): Promise<void> {
this.packets.push(base);
}
}
class FailingCloseClient extends BaseClient {
constructor() {
super(0, 0, async () => {
throw new Error("close failed");
});
this.initBase(new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]));
}
async writePacket(_base: PacketBase): Promise<void> {}
async triggerDisconnect(): Promise<void> {
await this.onDisconnected();
}
}
describe("BaseClient", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.useRealTimers();
});
test("close is idempotent", async () => {
const client = new TestClient(0, 0);
await Promise.all([client.close(), client.close(), client.close()]);
expect(client.closeCalls).toBe(1);
});
test("disconnect listeners called on close", async () => {
const client = new TestClient(0, 0);
const listener = vi.fn(async () => {});
client.addDisconnectListener(listener);
await client.close();
expect(listener).toHaveBeenCalledTimes(1);
});
test("disconnect listener failure does not block remaining listeners", async () => {
const client = new TestClient(0, 0);
const first = vi.fn(async () => {
throw new Error("listener failed");
});
const second = vi.fn(async () => {});
client.addDisconnectListener(first);
client.addDisconnectListener(second);
await expect(client.close()).resolves.toBeUndefined();
expect(first).toHaveBeenCalledTimes(1);
expect(second).toHaveBeenCalledTimes(1);
});
test("heartbeat sends HeartBeat after interval", async () => {
const client = new TestClient(2, 1);
await vi.advanceTimersByTimeAsync(2000);
expect(client.packets).toHaveLength(1);
expect(client.packets[0]?.typeName).toBe(HeartBeatSchema.typeName);
});
test("heartbeat timeout triggers disconnect", async () => {
const client = new TestClient(2, 1);
await vi.advanceTimersByTimeAsync(3000);
expect(client.closeCalls).toBe(1);
});
test("onHeartbeat resets waiting flag", async () => {
const client = new TestClient(2, 1);
await vi.advanceTimersByTimeAsync(2000);
client.communicator.onReceivedData(
HeartBeatSchema.typeName,
toBinary(HeartBeatSchema, create(HeartBeatSchema)),
);
await vi.advanceTimersByTimeAsync(0);
expect(client.packets).toHaveLength(1);
client.communicator.onReceivedData(
HeartBeatSchema.typeName,
toBinary(HeartBeatSchema, create(HeartBeatSchema)),
);
await vi.advanceTimersByTimeAsync(0);
expect(client.packets).toHaveLength(2);
expect(client.packets[1]?.typeName).toBe(HeartBeatSchema.typeName);
});
test("onDisconnected swallows internal close errors", async () => {
const client = new FailingCloseClient();
await expect(client.triggerDisconnect()).resolves.toBeUndefined();
});
});

View file

@ -0,0 +1,159 @@
import { create, toBinary } from "@bufbuild/protobuf";
import { describe, expect, test } from "vitest";
import {
Communicator,
NotConnectedError,
type Transport,
parserFromSchema,
} from "../src/communicator.js";
import {
PacketBaseSchema,
TestDataSchema,
type PacketBase,
} from "../src/packets/message_common_pb.js";
class MockTransport implements Transport {
packets: PacketBase[] = [];
async writePacket(base: PacketBase): Promise<void> {
this.packets.push(base);
}
async close(): Promise<void> {}
}
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
describe("Communicator", () => {
test("addListener/addRequestListener mutual exclusion", () => {
const comm = new Communicator();
comm.initialize(new MockTransport(), parserMap());
comm.addListener(TestDataSchema.typeName, () => {});
expect(() =>
comm.addRequestListener(TestDataSchema.typeName, () =>
create(TestDataSchema, { index: 1, message: "x" }),
),
).toThrow(/already registered with addListener/);
const other = new Communicator();
other.initialize(new MockTransport(), parserMap());
other.addRequestListener(TestDataSchema.typeName, () =>
create(TestDataSchema, { index: 1, message: "x" }),
);
expect(() => other.addListener(TestDataSchema.typeName, () => {})).toThrow(
/already registered with addRequestListener/,
);
});
test("send increments nonce", async () => {
const transport = new MockTransport();
const comm = new Communicator();
comm.initialize(transport, parserMap());
await comm.send(create(TestDataSchema, { index: 1, message: "first" }));
await comm.send(create(TestDataSchema, { index: 2, message: "second" }));
expect(transport.packets).toHaveLength(2);
expect(transport.packets[0]?.nonce).toBe(1);
expect(transport.packets[1]?.nonce).toBe(2);
});
test("sendRequest resolves on response", async () => {
const transport = new MockTransport();
const comm = new Communicator();
comm.initialize(transport, parserMap());
const pending = comm.sendRequest(
create(TestDataSchema, { index: 21, message: "hello" }),
TestDataSchema,
1000,
);
const request = transport.packets[0];
expect(request?.nonce).toBe(1);
comm.onReceivedData(
TestDataSchema.typeName,
toBinary(TestDataSchema, create(TestDataSchema, { index: 42, message: "echo: hello" })),
0,
request?.nonce ?? 0,
);
await expect(pending).resolves.toMatchObject({
index: 42,
message: "echo: hello",
});
});
test("sendRequest rejects on timeout", async () => {
const comm = new Communicator();
comm.initialize(new MockTransport(), parserMap());
await expect(
comm.sendRequest(create(TestDataSchema, { index: 1, message: "wait" }), TestDataSchema, 10),
).rejects.toThrow(/request timeout for nonce 1/);
});
test("shutdown cancels pending requests", async () => {
const comm = new Communicator();
comm.initialize(new MockTransport(), parserMap());
const pending = comm.sendRequest(
create(TestDataSchema, { index: 1, message: "wait" }),
TestDataSchema,
1000,
);
comm.shutdown();
await expect(pending).rejects.toBeInstanceOf(NotConnectedError);
});
test("queuePacket preserves order for concurrent sends", async () => {
const transport = new MockTransport();
const comm = new Communicator();
comm.initialize(transport, parserMap());
await Promise.all([
comm.send(create(TestDataSchema, { index: 1, message: "one" })),
comm.send(create(TestDataSchema, { index: 2, message: "two" })),
comm.send(create(TestDataSchema, { index: 3, message: "three" })),
]);
expect(transport.packets.map((packet) => packet.nonce)).toEqual([1, 2, 3]);
expect(transport.packets.map((packet) => packet.typeName)).toEqual([
TestDataSchema.typeName,
TestDataSchema.typeName,
TestDataSchema.typeName,
]);
});
test("sendRequest stores protobuf packet payload", async () => {
const transport = new MockTransport();
const comm = new Communicator();
comm.initialize(transport, parserMap());
const promise = comm.sendRequest(
create(TestDataSchema, { index: 7, message: "payload" }),
TestDataSchema,
1000,
);
const packet = transport.packets[0];
expect(packet).toMatchObject({
typeName: TestDataSchema.typeName,
nonce: 1,
responseNonce: 0,
});
const request = packet
? toBinary(PacketBaseSchema, packet)
: new Uint8Array();
expect(request.byteLength).toBeGreaterThan(0);
comm.shutdown();
await expect(promise).rejects.toBeInstanceOf(NotConnectedError);
});
});

View file

@ -0,0 +1,92 @@
import { create } from "@bufbuild/protobuf";
import { afterEach, describe, expect, test } from "vitest";
import {
addListenerTyped,
addRequestListenerTyped,
parserFromSchema,
sendRequestTyped,
} from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { connectTcp, TcpClient } from "../src/tcp_client.js";
import { TcpServer } from "../src/tcp_server.js";
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
async function waitForClientMessage(client: TcpClient): Promise<TestData> {
return new Promise<TestData>((resolve) => {
addListenerTyped(client.communicator, TestDataSchema, (msg) => resolve(msg));
});
}
describe("TCP", () => {
const cleanup: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanup.length > 0) {
const fn = cleanup.pop();
if (fn) {
await fn();
}
}
});
test("connectTcp sends and receives TestData", async () => {
const server = new TcpServer("127.0.0.1", 0, (socket) => new TcpClient(socket, 0, 0, parserMap()));
cleanup.push(async () => server.stop());
await server.start();
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, async (msg) => {
if (msg.index === 11 && msg.message === "hello over tcp") {
await client.communicator.send(
create(TestDataSchema, { index: 200, message: "push from tcp server" }),
);
}
});
};
const client = await connectTcp("127.0.0.1", server.port, 0, 0, parserMap());
cleanup.push(async () => client.close());
const pushed = waitForClientMessage(client);
await client.communicator.send(create(TestDataSchema, { index: 11, message: "hello over tcp" }));
await expect(pushed).resolves.toMatchObject({
index: 200,
message: "push from tcp server",
});
});
test("sendRequest/response roundtrip", async () => {
const server = new TcpServer("127.0.0.1", 0, (socket) => new TcpClient(socket, 0, 0, parserMap()));
cleanup.push(async () => server.stop());
await server.start();
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
const client = await connectTcp("127.0.0.1", server.port, 0, 0, parserMap());
cleanup.push(async () => client.close());
await expect(
sendRequestTyped(
client.communicator,
create(TestDataSchema, { index: 21, message: "request over tcp" }),
TestDataSchema,
500,
),
).resolves.toMatchObject({
index: 42,
message: "echo: request over tcp",
});
});
});

View file

@ -0,0 +1,92 @@
import { create } from "@bufbuild/protobuf";
import { afterEach, describe, expect, test } from "vitest";
import {
addListenerTyped,
addRequestListenerTyped,
parserFromSchema,
sendRequestTyped,
} from "../src/communicator.js";
import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js";
import { connectWs, WsClient } from "../src/ws_client.js";
import { WsServer } from "../src/ws_server.js";
function parserMap() {
return new Map([[TestDataSchema.typeName, parserFromSchema(TestDataSchema)]]);
}
async function waitForClientMessage(client: WsClient): Promise<TestData> {
return new Promise<TestData>((resolve) => {
addListenerTyped(client.communicator, TestDataSchema, (msg) => resolve(msg));
});
}
describe("WS", () => {
const cleanup: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanup.length > 0) {
const fn = cleanup.pop();
if (fn) {
await fn();
}
}
});
test("connectWs sends and receives TestData", async () => {
const server = new WsServer("127.0.0.1", 0, "/", (ws) => new WsClient(ws, 0, 0, parserMap()));
cleanup.push(async () => server.stop());
await server.start();
server.onClientConnected = (client) => {
addListenerTyped(client.communicator, TestDataSchema, async (msg) => {
if (msg.index === 11 && msg.message === "hello over ws") {
await client.communicator.send(
create(TestDataSchema, { index: 200, message: "push from ws server" }),
);
}
});
};
const client = await connectWs("127.0.0.1", server.port, "/", 0, 0, parserMap());
cleanup.push(async () => client.close());
const pushed = waitForClientMessage(client);
await client.communicator.send(create(TestDataSchema, { index: 11, message: "hello over ws" }));
await expect(pushed).resolves.toMatchObject({
index: 200,
message: "push from ws server",
});
});
test("sendRequest/response roundtrip over WS", async () => {
const server = new WsServer("127.0.0.1", 0, "/", (ws) => new WsClient(ws, 0, 0, parserMap()));
cleanup.push(async () => server.stop());
await server.start();
server.onClientConnected = (client) => {
addRequestListenerTyped(client.communicator, TestDataSchema, (req) =>
create(TestDataSchema, {
index: req.index * 2,
message: `echo: ${req.message}`,
}),
);
};
const client = await connectWs("127.0.0.1", server.port, "/", 0, 0, parserMap());
cleanup.push(async () => client.close());
await expect(
sendRequestTyped(
client.communicator,
create(TestDataSchema, { index: 21, message: "request over ws" }),
TestDataSchema,
500,
),
).resolves.toMatchObject({
index: 42,
message: "echo: request over ws",
});
});
});

16
typescript/tsconfig.json Normal file
View file

@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"noEmitOnError": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"rootDir": ".",
"outDir": "dist",
"types": ["node"]
},
"include": ["src/**/*.ts", "test/**/*.ts", "crosstest/**/*.ts"]
}