feat: add typescript implementation and crosstest files
This commit is contained in:
parent
bf0157d35d
commit
ef0fa5e8bc
50 changed files with 10262 additions and 11 deletions
263
agent-task/crosstest_typescript/CODE_REVIEW.md
Normal file
263
agent-task/crosstest_typescript/CODE_REVIEW.md
Normal 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)
|
||||
```
|
||||
511
agent-task/crosstest_typescript/PLAN.md
Normal file
511
agent-task/crosstest_typescript/PLAN.md
Normal 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 ...` 출력.
|
||||
263
agent-task/crosstest_typescript/code_review_0.log
Normal file
263
agent-task/crosstest_typescript/code_review_0.log
Normal 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)
|
||||
```
|
||||
22
agent-task/crosstest_typescript/complete.log
Normal file
22
agent-task/crosstest_typescript/complete.log
Normal 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)
|
||||
511
agent-task/crosstest_typescript/plan_0.log
Normal file
511
agent-task/crosstest_typescript/plan_0.log
Normal 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 ...` 출력.
|
||||
90
agent-task/typescript_impl/CODE_REVIEW.md
Normal file
90
agent-task/typescript_impl/CODE_REVIEW.md
Normal 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)
|
||||
```
|
||||
115
agent-task/typescript_impl/PLAN.md
Normal file
115
agent-task/typescript_impl/PLAN.md
Normal 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.
|
||||
217
agent-task/typescript_impl/code_review_0.log
Normal file
217
agent-task/typescript_impl/code_review_0.log
Normal 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 스텁을 작성한다.
|
||||
55
agent-task/typescript_impl/code_review_1.log
Normal file
55
agent-task/typescript_impl/code_review_1.log
Normal 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)
|
||||
```
|
||||
90
agent-task/typescript_impl/code_review_2.log
Normal file
90
agent-task/typescript_impl/code_review_2.log
Normal 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)
|
||||
```
|
||||
18
agent-task/typescript_impl/complete.log
Normal file
18
agent-task/typescript_impl/complete.log
Normal 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)
|
||||
742
agent-task/typescript_impl/plan_0.log
Normal file
742
agent-task/typescript_impl/plan_0.log
Normal 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`
|
||||
91
agent-task/typescript_impl/plan_1.log
Normal file
91
agent-task/typescript_impl/plan_1.log
Normal 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.
|
||||
115
agent-task/typescript_impl/plan_2.log
Normal file
115
agent-task/typescript_impl/plan_2.log
Normal 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.
|
||||
238
dart/crosstest/dart_typescript.dart
Normal file
238
dart/crosstest/dart_typescript.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
193
dart/crosstest/typescript_dart_client.dart
Normal file
193
dart/crosstest/typescript_dart_client.dart
Normal 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');
|
||||
}
|
||||
330
go/crosstest/go_typescript.go
Normal file
330
go/crosstest/go_typescript.go
Normal 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 ")
|
||||
}
|
||||
207
go/crosstest/typescript_go_client/main.go
Normal file
207
go/crosstest/typescript_go_client/main.go
Normal 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)
|
||||
}
|
||||
239
kotlin/crosstest/kotlin_typescript.kt
Normal file
239
kotlin/crosstest/kotlin_typescript.kt
Normal 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")
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
197
kotlin/crosstest/typescript_kotlin_client.kt
Normal file
197
kotlin/crosstest/typescript_kotlin_client.kt
Normal 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")
|
||||
}
|
||||
178
python/crosstest/python_typescript.py
Normal file
178
python/crosstest/python_typescript.py
Normal 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())
|
||||
139
python/crosstest/typescript_python_client.py
Normal file
139
python/crosstest/typescript_python_client.py
Normal 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
2
typescript/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
node_modules/
|
||||
dist/
|
||||
27
typescript/CROSSTEST_CHECKLIST.md
Normal file
27
typescript/CROSSTEST_CHECKLIST.md
Normal 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.
|
||||
42
typescript/IMPLEMENTATION_CHECKLIST.md
Normal file
42
typescript/IMPLEMENTATION_CHECKLIST.md
Normal 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
43
typescript/README.md
Normal 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
|
||||
```
|
||||
209
typescript/crosstest/dart_typescript_client.ts
Normal file
209
typescript/crosstest/dart_typescript_client.ts
Normal 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;
|
||||
});
|
||||
209
typescript/crosstest/go_typescript_client.ts
Normal file
209
typescript/crosstest/go_typescript_client.ts
Normal 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;
|
||||
});
|
||||
211
typescript/crosstest/kotlin_typescript_client.ts
Normal file
211
typescript/crosstest/kotlin_typescript_client.ts
Normal 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;
|
||||
});
|
||||
209
typescript/crosstest/python_typescript_client.ts
Normal file
209
typescript/crosstest/python_typescript_client.ts
Normal 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;
|
||||
});
|
||||
288
typescript/crosstest/typescript_dart.ts
Normal file
288
typescript/crosstest/typescript_dart.ts
Normal 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();
|
||||
288
typescript/crosstest/typescript_go.ts
Normal file
288
typescript/crosstest/typescript_go.ts
Normal 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();
|
||||
286
typescript/crosstest/typescript_kotlin.ts
Normal file
286
typescript/crosstest/typescript_kotlin.ts
Normal 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();
|
||||
288
typescript/crosstest/typescript_python.ts
Normal file
288
typescript/crosstest/typescript_python.ts
Normal 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
1769
typescript/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
23
typescript/package.json
Normal file
23
typescript/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
145
typescript/src/base_client.ts
Normal file
145
typescript/src/base_client.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
428
typescript/src/communicator.ts
Normal file
428
typescript/src/communicator.ts
Normal 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
7
typescript/src/index.ts
Normal 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";
|
||||
81
typescript/src/packets/message_common_pb.ts
Normal file
81
typescript/src/packets/message_common_pb.ts
Normal 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);
|
||||
|
||||
106
typescript/src/tcp_client.ts
Normal file
106
typescript/src/tcp_client.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
92
typescript/src/tcp_server.ts
Normal file
92
typescript/src/tcp_server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
92
typescript/src/ws_client.ts
Normal file
92
typescript/src/ws_client.ts
Normal 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);
|
||||
}
|
||||
95
typescript/src/ws_server.ts
Normal file
95
typescript/src/ws_server.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
128
typescript/test/base_client.test.ts
Normal file
128
typescript/test/base_client.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
159
typescript/test/communicator.test.ts
Normal file
159
typescript/test/communicator.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
92
typescript/test/tcp.test.ts
Normal file
92
typescript/test/tcp.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
92
typescript/test/ws.test.ts
Normal file
92
typescript/test/ws.test.ts
Normal 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
16
typescript/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Loading…
Reference in a new issue