diff --git a/agent-task/crosstest_typescript/CODE_REVIEW.md b/agent-task/crosstest_typescript/CODE_REVIEW.md new file mode 100644 index 0000000..93537cb --- /dev/null +++ b/agent-task/crosstest_typescript/CODE_REVIEW.md @@ -0,0 +1,263 @@ + + +# 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 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) +``` diff --git a/agent-task/crosstest_typescript/PLAN.md b/agent-task/crosstest_typescript/PLAN.md new file mode 100644 index 0000000..059f42c --- /dev/null +++ b/agent-task/crosstest_typescript/PLAN.md @@ -0,0 +1,511 @@ + + +# 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 _runTypescriptClient(String mode, int port, String phase, Set 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) = + 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): Promise { + 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): Promise { + 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): Promise { + 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 ...` 출력. diff --git a/agent-task/crosstest_typescript/code_review_0.log b/agent-task/crosstest_typescript/code_review_0.log new file mode 100644 index 0000000..93537cb --- /dev/null +++ b/agent-task/crosstest_typescript/code_review_0.log @@ -0,0 +1,263 @@ + + +# 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 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) +``` diff --git a/agent-task/crosstest_typescript/complete.log b/agent-task/crosstest_typescript/complete.log new file mode 100644 index 0000000..5990800 --- /dev/null +++ b/agent-task/crosstest_typescript/complete.log @@ -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) diff --git a/agent-task/crosstest_typescript/plan_0.log b/agent-task/crosstest_typescript/plan_0.log new file mode 100644 index 0000000..059f42c --- /dev/null +++ b/agent-task/crosstest_typescript/plan_0.log @@ -0,0 +1,511 @@ + + +# 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 _runTypescriptClient(String mode, int port, String phase, Set 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) = + 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): Promise { + 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): Promise { + 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): Promise { + 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 ...` 출력. diff --git a/agent-task/typescript_impl/CODE_REVIEW.md b/agent-task/typescript_impl/CODE_REVIEW.md new file mode 100644 index 0000000..0be95f3 --- /dev/null +++ b/agent-task/typescript_impl/CODE_REVIEW.md @@ -0,0 +1,90 @@ + + +# 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) +``` diff --git a/agent-task/typescript_impl/PLAN.md b/agent-task/typescript_impl/PLAN.md new file mode 100644 index 0000000..8a88bf2 --- /dev/null +++ b/agent-task/typescript_impl/PLAN.md @@ -0,0 +1,115 @@ + + +# 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. diff --git a/agent-task/typescript_impl/code_review_0.log b/agent-task/typescript_impl/code_review_0.log new file mode 100644 index 0000000..98ddf9e --- /dev/null +++ b/agent-task/typescript_impl/code_review_0.log @@ -0,0 +1,217 @@ + + +# 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 스텁을 작성한다. diff --git a/agent-task/typescript_impl/code_review_1.log b/agent-task/typescript_impl/code_review_1.log new file mode 100644 index 0000000..8164ebf --- /dev/null +++ b/agent-task/typescript_impl/code_review_1.log @@ -0,0 +1,55 @@ + + +# 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) +``` diff --git a/agent-task/typescript_impl/code_review_2.log b/agent-task/typescript_impl/code_review_2.log new file mode 100644 index 0000000..0be95f3 --- /dev/null +++ b/agent-task/typescript_impl/code_review_2.log @@ -0,0 +1,90 @@ + + +# 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) +``` diff --git a/agent-task/typescript_impl/complete.log b/agent-task/typescript_impl/complete.log new file mode 100644 index 0000000..4cef768 --- /dev/null +++ b/agent-task/typescript_impl/complete.log @@ -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) diff --git a/agent-task/typescript_impl/plan_0.log b/agent-task/typescript_impl/plan_0.log new file mode 100644 index 0000000..e0e75b6 --- /dev/null +++ b/agent-task/typescript_impl/plan_0.log @@ -0,0 +1,742 @@ + + +# 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 Message>; + +export interface Transport { + writePacket(base: PacketBase): Promise; + close(): Promise; +} + +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 void>>(); + private reqHandlers = new Map void>(); + private pendingRequests = new Map(); + 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 { ... } + async queuePacket(base: PacketBase): Promise { ... } + async send(msg: Message): Promise { ... } + async sendRequest(req: Message, resType: MessageType, timeoutMs?: number): Promise { ... } + 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( + comm: Communicator, + msgType: MessageType, + fn: (msg: T) => void +): void { ... } + +export function addRequestListenerTyped( + comm: Communicator, + reqType: MessageType, + fn: (req: TReq) => TRes | Promise | null +): void { ... } + +export async function sendRequestTyped( + comm: Communicator, + req: TReq, + resType: MessageType, + timeoutMs?: number +): Promise { ... } +``` + +**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; + private isClosed = false; + private hbTimer: ReturnType | null = null; + private waitingHbResponse = false; + private disconnectListeners: Array<(self: this) => void | Promise> = []; + + constructor(intervalSec: number, waitSec: number, doClose: () => Promise) { ... } + protected initBase(parserMap: ParserMap): void { ... } + + async close(): Promise { + 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 { ... } + removeDisconnectListeners(): void { ... } + + protected async sendHeartbeat(): Promise { ... } + protected async onHeartbeat(): Promise { ... } + protected async onDisconnected(): Promise { await this.close(); } + private stopHeartbeat(): void { ... } + private async notifyDisconnected(): Promise { ... } +} +``` + +하트비트 타이머는 `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 { + const data = base.toBinary(); + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(data.length, 0); + await new Promise((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 { + 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 { ... } + stop(): Promise { ... } + async broadcast(msg: Message): Promise { ... } + 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 { + 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 { ... } +``` + +#### 수정 파일 및 체크리스트 + +- [ ] `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 { ... } + stop(): Promise { ... } + async broadcast(msg: Message): Promise { ... } + 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` diff --git a/agent-task/typescript_impl/plan_1.log b/agent-task/typescript_impl/plan_1.log new file mode 100644 index 0000000..f597084 --- /dev/null +++ b/agent-task/typescript_impl/plan_1.log @@ -0,0 +1,91 @@ + + +# 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. diff --git a/agent-task/typescript_impl/plan_2.log b/agent-task/typescript_impl/plan_2.log new file mode 100644 index 0000000..8a88bf2 --- /dev/null +++ b/agent-task/typescript_impl/plan_2.log @@ -0,0 +1,115 @@ + + +# 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. diff --git a/dart/crosstest/dart_typescript.dart b/dart/crosstest/dart_typescript.dart new file mode 100644 index 0000000..6aa17e1 --- /dev/null +++ b/dart/crosstest/dart_typescript.dart @@ -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 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 _runTcp() async { + await _runTcpSendPush(); + await Future.delayed(const Duration(milliseconds: 150)); + await _runTcpRequests(); +} + +Future _runWs() async { + await _runWsSendPush(); + await Future.delayed(const Duration(milliseconds: 150)); + await _runWsRequests(); +} + +Future _runTcpSendPush() async { + final received = Completer(); + final server = _DartTcpServer(_tcpPort, (client) { + client.addListener((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 _runTcpRequests() async { + final server = _DartTcpServer(_tcpPort, (client) { + client.addRequestListener((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 _runWsSendPush() async { + final received = Completer(); + final server = _DartWsServer(_wsPort, (client) { + client.addListener((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 _runWsRequests() async { + final server = _DartWsServer(_wsPort, (client) { + client.addRequestListener((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 _withServer(Future Function() start, + Future Function() stop, Future Function() body) async { + await start(); + try { + await body(); + } finally { + await stop(); + } +} + +Future _runTypescriptClient( + String mode, + int port, + String phase, + Set expectedScenarios, +) async { + final process = await Process.start( + 'npx', + [ + 'tsx', + 'crosstest/dart_typescript_client.ts', + '--mode=$mode', + '--port=$port', + '--phase=$phase', + ], + workingDirectory: _typescriptDir, + ); + + final resultLines = []; + 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(); + + final stderrDone = process.stderr + .transform(utf8.decoder) + .transform(const LineSplitter()) + .listen(stderr.writeln) + .asFuture(); + + 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 lines, + Set expectedScenarios, +) { + final failed = lines.where((line) => line.startsWith('FAIL ')).toList(); + final passed = {}; + 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'); + } +} diff --git a/dart/crosstest/typescript_dart_client.dart b/dart/crosstest/typescript_dart_client.dart new file mode 100644 index 0000000..b71a753 --- /dev/null +++ b/dart/crosstest/typescript_dart_client.dart @@ -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 Function() close; + + _ClientHandle(this.client, this.close); +} + +Future main(List 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.delayed(const Duration(milliseconds: 100)); + } + } + + throw TimeoutException( + 'connect $mode:$port timed out: $lastError', _connectWindow); +} + +Future _runSendPush(Communicator client) async { + final pushCompleter = Completer(); + client.addListener((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 _runRequests(Communicator client) async { + try { + final single = await client + .sendRequest(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 = >[]; + 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() + ..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 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'); +} diff --git a/go/crosstest/go_typescript.go b/go/crosstest/go_typescript.go new file mode 100644 index 0000000..8214ade --- /dev/null +++ b/go/crosstest/go_typescript.go @@ -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 ") +} diff --git a/go/crosstest/typescript_go_client/main.go b/go/crosstest/typescript_go_client/main.go new file mode 100644 index 0000000..16775c9 --- /dev/null +++ b/go/crosstest/typescript_go_client/main.go @@ -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) +} diff --git a/kotlin/crosstest/kotlin_typescript.kt b/kotlin/crosstest/kotlin_typescript.kt new file mode 100644 index 0000000..768bfc1 --- /dev/null +++ b/kotlin/crosstest/kotlin_typescript.kt @@ -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()}") + 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() + val server = TcpServer(HOST, TCP_PORT) { socket -> + TcpClient.fromSocket(socket, 0, 0, parserMap()) + } + server.onClientConnected = { client -> + addListenerTyped(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(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() + server.onClientConnected = { client -> + addListenerTyped(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(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, +) = 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() + 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, + expectedScenarios: Set, +) { + 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() 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") +} diff --git a/kotlin/crosstest/python_kotlin_client.kt b/kotlin/crosstest/python_kotlin_client.kt index 8df5398..139243d 100644 --- a/kotlin/crosstest/python_kotlin_client.kt +++ b/kotlin/crosstest/python_kotlin_client.kt @@ -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) = 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() addListenerTyped(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( 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 { diff --git a/kotlin/crosstest/typescript_kotlin_client.kt b/kotlin/crosstest/typescript_kotlin_client.kt new file mode 100644 index 0000000..de09319 --- /dev/null +++ b/kotlin/crosstest/typescript_kotlin_client.kt @@ -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) = 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()}") + + 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() + addListenerTyped(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( + 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( + 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() to { TestData.parseFrom(it) }) + +private fun argValue(args: Array, 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") +} diff --git a/python/crosstest/python_typescript.py b/python/crosstest/python_typescript.py new file mode 100644 index 0000000..41122ca --- /dev/null +++ b/python/crosstest/python_typescript.py @@ -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()) diff --git a/python/crosstest/typescript_python_client.py b/python/crosstest/typescript_python_client.py new file mode 100644 index 0000000..c83a120 --- /dev/null +++ b/python/crosstest/typescript_python_client.py @@ -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()) diff --git a/typescript/.gitignore b/typescript/.gitignore new file mode 100644 index 0000000..b947077 --- /dev/null +++ b/typescript/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/typescript/CROSSTEST_CHECKLIST.md b/typescript/CROSSTEST_CHECKLIST.md new file mode 100644 index 0000000..71d6b30 --- /dev/null +++ b/typescript/CROSSTEST_CHECKLIST.md @@ -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. diff --git a/typescript/IMPLEMENTATION_CHECKLIST.md b/typescript/IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 0000000..77da230 --- /dev/null +++ b/typescript/IMPLEMENTATION_CHECKLIST.md @@ -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. diff --git a/typescript/README.md b/typescript/README.md new file mode 100644 index 0000000..398326e --- /dev/null +++ b/typescript/README.md @@ -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 +``` diff --git a/typescript/crosstest/dart_typescript_client.ts b/typescript/crosstest/dart_typescript_client.ts new file mode 100644 index 0000000..86e11cf --- /dev/null +++ b/typescript/crosstest/dart_typescript_client.ts @@ -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 { + 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 { + 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 { + 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 { + const pushed = new Promise((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 { + 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(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, 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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +void main().catch((err) => { + fail("setup", errorMessage(err)); + process.exitCode = 1; +}); diff --git a/typescript/crosstest/go_typescript_client.ts b/typescript/crosstest/go_typescript_client.ts new file mode 100644 index 0000000..66a715f --- /dev/null +++ b/typescript/crosstest/go_typescript_client.ts @@ -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 { + 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 { + 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 { + 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 { + const pushed = new Promise((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 { + 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(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, 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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +void main().catch((err) => { + fail("setup", errorMessage(err)); + process.exitCode = 1; +}); diff --git a/typescript/crosstest/kotlin_typescript_client.ts b/typescript/crosstest/kotlin_typescript_client.ts new file mode 100644 index 0000000..2489f4a --- /dev/null +++ b/typescript/crosstest/kotlin_typescript_client.ts @@ -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 { + 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 { + 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 { + 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 { + const pushed = new Promise((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 { + 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(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, 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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +void main().catch((err) => { + fail("setup", errorMessage(err)); + process.exitCode = 1; +}); diff --git a/typescript/crosstest/python_typescript_client.ts b/typescript/crosstest/python_typescript_client.ts new file mode 100644 index 0000000..f9a2fac --- /dev/null +++ b/typescript/crosstest/python_typescript_client.ts @@ -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 { + 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 { + 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 { + 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 { + const pushed = new Promise((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 { + 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(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, 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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +void main().catch((err) => { + fail("setup", errorMessage(err)); + process.exitCode = 1; +}); diff --git a/typescript/crosstest/typescript_dart.ts b/typescript/crosstest/typescript_dart.ts new file mode 100644 index 0000000..4cb8d6e --- /dev/null +++ b/typescript/crosstest/typescript_dart.ts @@ -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 { + 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 { + await runTcpSendPush(); + await sleep(150); + await runTcpRequests(); +} + +async function runWs(): Promise { + await runWsSendPush(); + await sleep(150); + await runWsRequests(); +} + +async function runTcpSendPush(): Promise { + let resolveReceived: ((value: boolean) => void) | null = null; + const received = new Promise((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 { + 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 { + let resolveReceived: ((value: boolean) => void) | null = null; + const received = new Promise((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 { + 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; stop: () => Promise }, + body: () => Promise, +): Promise { + await server.start(); + try { + await body(); + } finally { + await server.stop(); + } +} + +async function runDartClient( + mode: "tcp" | "ws", + port: number, + phase: "send-push" | "requests", + expected: Set, +): Promise { + 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, +): Promise { + 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((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 { + 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): void { + const failed = lines.filter((line) => line.startsWith("FAIL ")); + const passed = new Set(); + 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(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, 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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +void main(); diff --git a/typescript/crosstest/typescript_go.ts b/typescript/crosstest/typescript_go.ts new file mode 100644 index 0000000..7c8559f --- /dev/null +++ b/typescript/crosstest/typescript_go.ts @@ -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 { + 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 { + await runTcpSendPush(); + await sleep(150); + await runTcpRequests(); +} + +async function runWs(): Promise { + await runWsSendPush(); + await sleep(150); + await runWsRequests(); +} + +async function runTcpSendPush(): Promise { + let resolveReceived: ((value: boolean) => void) | null = null; + const received = new Promise((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 { + 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 { + let resolveReceived: ((value: boolean) => void) | null = null; + const received = new Promise((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 { + 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; stop: () => Promise }, + body: () => Promise, +): Promise { + await server.start(); + try { + await body(); + } finally { + await server.stop(); + } +} + +async function runGoClient( + mode: "tcp" | "ws", + port: number, + phase: "send-push" | "requests", + expected: Set, +): Promise { + 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, +): Promise { + 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((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 { + 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): void { + const failed = lines.filter((line) => line.startsWith("FAIL ")); + const passed = new Set(); + 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(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, 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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +void main(); diff --git a/typescript/crosstest/typescript_kotlin.ts b/typescript/crosstest/typescript_kotlin.ts new file mode 100644 index 0000000..5e6de1e --- /dev/null +++ b/typescript/crosstest/typescript_kotlin.ts @@ -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 { + 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 { + await runTcpSendPush(); + await sleep(150); + await runTcpRequests(); +} + +async function runWs(): Promise { + await runWsSendPush(); + await sleep(150); + await runWsRequests(); +} + +async function runTcpSendPush(): Promise { + let resolveReceived: ((value: boolean) => void) | null = null; + const received = new Promise((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 { + 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 { + let resolveReceived: ((value: boolean) => void) | null = null; + const received = new Promise((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 { + 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; stop: () => Promise }, + body: () => Promise, +): Promise { + await server.start(); + try { + await body(); + } finally { + await server.stop(); + } +} + +async function runKotlinClient( + mode: "tcp" | "ws", + port: number, + phase: "send-push" | "requests", + expected: Set, +): Promise { + 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, +): Promise { + 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((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 { + 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): void { + const failed = lines.filter((line) => line.startsWith("FAIL ")); + const passed = new Set(); + 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(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, 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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +void main(); diff --git a/typescript/crosstest/typescript_python.ts b/typescript/crosstest/typescript_python.ts new file mode 100644 index 0000000..c829b3d --- /dev/null +++ b/typescript/crosstest/typescript_python.ts @@ -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 { + 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 { + await runTcpSendPush(); + await sleep(150); + await runTcpRequests(); +} + +async function runWs(): Promise { + await runWsSendPush(); + await sleep(150); + await runWsRequests(); +} + +async function runTcpSendPush(): Promise { + let resolveReceived: ((value: boolean) => void) | null = null; + const received = new Promise((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 { + 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 { + let resolveReceived: ((value: boolean) => void) | null = null; + const received = new Promise((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 { + 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; stop: () => Promise }, + body: () => Promise, +): Promise { + await server.start(); + try { + await body(); + } finally { + await server.stop(); + } +} + +async function runPythonClient( + mode: "tcp" | "ws", + port: number, + phase: "send-push" | "requests", + expected: Set, +): Promise { + 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, +): Promise { + 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((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 { + 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): void { + const failed = lines.filter((line) => line.startsWith("FAIL ")); + const passed = new Set(); + 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(promise: Promise, timeoutMs: number, label: string): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, 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 { + return new Promise((resolve) => { + setTimeout(resolve, ms); + }); +} + +void main(); diff --git a/typescript/package-lock.json b/typescript/package-lock.json new file mode 100644 index 0000000..eac2a65 --- /dev/null +++ b/typescript/package-lock.json @@ -0,0 +1,1769 @@ +{ + "name": "toki-socket", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "toki-socket", + "version": "0.1.0", + "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" + } + }, + "node_modules/@bufbuild/protobuf": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz", + "integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==", + "license": "(Apache-2.0 AND BSD-3-Clause)" + }, + "node_modules/@bufbuild/protoc-gen-es": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoc-gen-es/-/protoc-gen-es-2.12.0.tgz", + "integrity": "sha512-d9htF6jEkSwPbp9d/vSmZOBF7eeG18AvTMKmVg4I23afnrQOxL2w3WOXa9TaufMCyu24QakEUb4vux8apI5e7A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@bufbuild/protoplugin": "2.12.0" + }, + "bin": { + "protoc-gen-es": "bin/protoc-gen-es" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@bufbuild/protobuf": "2.12.0" + }, + "peerDependenciesMeta": { + "@bufbuild/protobuf": { + "optional": true + } + } + }, + "node_modules/@bufbuild/protoplugin": { + "version": "2.12.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protoplugin/-/protoplugin-2.12.0.tgz", + "integrity": "sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@bufbuild/protobuf": "2.12.0", + "@typescript/vfs": "^1.6.2", + "typescript": "5.4.5" + } + }, + "node_modules/@bufbuild/protoplugin/node_modules/typescript": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", + "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.7.tgz", + "integrity": "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.7.tgz", + "integrity": "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.7.tgz", + "integrity": "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.7.tgz", + "integrity": "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.7.tgz", + "integrity": "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.7.tgz", + "integrity": "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.7.tgz", + "integrity": "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.7.tgz", + "integrity": "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.7.tgz", + "integrity": "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.7.tgz", + "integrity": "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.7.tgz", + "integrity": "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.7.tgz", + "integrity": "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.7.tgz", + "integrity": "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.7.tgz", + "integrity": "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.7.tgz", + "integrity": "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.7.tgz", + "integrity": "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.7.tgz", + "integrity": "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.7.tgz", + "integrity": "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.7.tgz", + "integrity": "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.7.tgz", + "integrity": "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.7.tgz", + "integrity": "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.7.tgz", + "integrity": "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.7.tgz", + "integrity": "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.7.tgz", + "integrity": "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.7.tgz", + "integrity": "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.7.tgz", + "integrity": "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/vfs": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/@typescript/vfs/-/vfs-1.6.4.tgz", + "integrity": "sha512-PJFXFS4ZJKiJ9Qiuix6Dz/OwEIqHD7Dme1UwZhTK11vR+5dqW2ACbdndWQexBzCx+CPuMe5WBYQWCsFyGlQLlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.3" + }, + "peerDependencies": { + "typescript": "*" + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tsx": { + "version": "4.21.0", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", + "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "~0.27.0", + "get-tsconfig": "^4.7.5" + }, + "bin": { + "tsx": "dist/cli.mjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.2.tgz", + "integrity": "sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", + "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/typescript/package.json b/typescript/package.json new file mode 100644 index 0000000..3734bd1 --- /dev/null +++ b/typescript/package.json @@ -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" + } +} diff --git a/typescript/src/base_client.ts b/typescript/src/base_client.ts new file mode 100644 index 0000000..8e56f5d --- /dev/null +++ b/typescript/src/base_client.ts @@ -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; + private isClosed = false; + private closePromise: Promise | null = null; + private hbTimer: ReturnType | null = null; + private waitingHbResponse = false; + private disconnectListeners: Array<(self: BaseClient) => void | Promise> = []; + + protected constructor(intervalSec: number, waitSec: number, doClose: () => Promise) { + this.intervalMs = intervalSec * 1000; + this.waitMs = waitSec * 1000; + this.doClose = doClose; + } + + abstract writePacket(base: PacketBase): Promise; + + 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 { + 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 { + this.disconnectListeners.push(fn as (self: BaseClient) => void | Promise); + } + + removeDisconnectListeners(): void { + this.disconnectListeners = []; + } + + protected async sendHeartbeat(): Promise { + if (!this.communicator.isAlive() || this.intervalMs <= 0) { + return; + } + this.stopHeartbeat(); + this.hbTimer = setTimeout(() => { + void this.onHeartbeatIntervalElapsed(); + }, this.intervalMs); + } + + protected async onHeartbeat(): Promise { + if (this.waitingHbResponse) { + this.waitingHbResponse = false; + return; + } + try { + await this.communicator.send(create(HeartBeatSchema)); + } catch { + return; + } + } + + protected async onDisconnected(): Promise { + try { + await this.close(); + } catch { + return; + } + } + + private async onHeartbeatIntervalElapsed(): Promise { + 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 { + if (this.communicator.isAlive()) { + await this.onDisconnected(); + } + } + + private stopHeartbeat(): void { + if (this.hbTimer !== null) { + clearTimeout(this.hbTimer); + this.hbTimer = null; + } + } + + private async notifyDisconnected(): Promise { + const listeners = [...this.disconnectListeners]; + this.disconnectListeners = []; + for (const listener of listeners) { + try { + await listener(this); + } catch { + continue; + } + } + } +} diff --git a/typescript/src/communicator.ts b/typescript/src/communicator.ts new file mode 100644 index 0000000..26c0c03 --- /dev/null +++ b/typescript/src/communicator.ts @@ -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 = GenMessage; + +export type MessageParser = ((data: Uint8Array) => T) & { + schema?: MessageType; +}; + +export type ParserMap = Map; + +export interface Transport { + writePacket(base: PacketBase): Promise; + close(): Promise; +} + +type RequestHandler = ( + msg: Message, + nonce: number, +) => Message | Promise | null | undefined | void; + +interface PendingRequest { + expectedTypeName: string; + timer: ReturnType; + resolve: (msg: Message) => void; + reject: (err: Error) => void; +} + +interface QueueItem { + base: PacketBase; + resolve: () => void; + reject: (err: Error) => void; +} + +export function parserFromSchema(schema: MessageType): MessageParser { + const parser = ((data: Uint8Array) => fromBinary(schema, data)) as MessageParser; + parser.schema = schema; + return parser; +} + +export class Communicator { + private nonce = 0; + private isAliveFlag = false; + private parserMap: ParserMap = new Map(); + private schemaMap = new Map(); + private handlers = new Map void>>(); + private reqHandlers = new Map(); + private pendingRequests = new Map(); + private writeQueue: QueueItem[] = []; + private writeQueueRunning = false; + private transport: Transport | null = null; + private writeErrorHandler: ((err: Error) => void) | null = null; + private closedPromise: Promise = 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 { + 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 { + this.shutdown(); + if (this.transport !== null) { + await this.transport.close(); + } + } + + async queuePacket(base: PacketBase): Promise { + if (!this.isAliveFlag || this.transport === null) { + throw new NotConnectedError(); + } + + const promise = new Promise((resolve, reject) => { + this.writeQueue.push({ base, resolve, reject }); + }); + if (!this.writeQueueRunning) { + void this.runWriteQueue(); + } + return promise; + } + + async send(msg: Message): Promise { + 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( + req: Message, + resType: MessageType, + timeoutMs = 30000, + ): Promise { + if (!this.isAliveFlag) { + throw new NotConnectedError(); + } + + const requestNonce = this.nextNonce(); + const encoded = this.encodeMessage(req); + const effectiveTimeout = timeoutMs > 0 ? timeoutMs : 30000; + + return new Promise((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 { + 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 { + 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((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( + comm: Communicator, + msgType: MessageType, + fn: (msg: T) => void, +): void { + comm.addListener(msgType.typeName, (msg) => fn(msg as T)); +} + +export function addRequestListenerTyped( + comm: Communicator, + reqType: MessageType, + fn: (req: TReq) => TRes | Promise | null | undefined, +): void { + comm.addRequestListener(reqType.typeName, (req) => fn(req as TReq)); +} + +export function sendRequestTyped( + comm: Communicator, + req: TReq, + resType: MessageType, + timeoutMs?: number, +): Promise { + return comm.sendRequest(req, resType, timeoutMs); +} + +function toError(err: unknown): Error { + return err instanceof Error ? err : new Error(String(err)); +} diff --git a/typescript/src/index.ts b/typescript/src/index.ts new file mode 100644 index 0000000..93ae180 --- /dev/null +++ b/typescript/src/index.ts @@ -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"; diff --git a/typescript/src/packets/message_common_pb.ts b/typescript/src/packets/message_common_pb.ts new file mode 100644 index 0000000..7d2157c --- /dev/null +++ b/typescript/src/packets/message_common_pb.ts @@ -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 = /*@__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 = /*@__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 = /*@__PURE__*/ + messageDesc(file_message_common, 2); + diff --git a/typescript/src/tcp_client.ts b/typescript/src/tcp_client.ts new file mode 100644 index 0000000..0c64592 --- /dev/null +++ b/typescript/src/tcp_client.ts @@ -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 { + 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((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 { + return new Promise((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); + }); +} diff --git a/typescript/src/tcp_server.ts b/typescript/src/tcp_server.ts new file mode 100644 index 0000000..963e385 --- /dev/null +++ b/typescript/src/tcp_server.ts @@ -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(); + 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 { + if (this.started) { + return Promise.resolve(); + } + + return new Promise((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 { + if (!this.started) { + return; + } + this.started = false; + + const clients = [...this.clients]; + this.clients.clear(); + const closeServer = new Promise((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 { + await Promise.all([...this.clients].map(async (client) => client.communicator.send(msg))); + } + + private removeClient(client: TcpClient): void { + this.clients.delete(client); + } +} diff --git a/typescript/src/ws_client.ts b/typescript/src/ws_client.ts new file mode 100644 index 0000000..d096b65 --- /dev/null +++ b/typescript/src/ws_client.ts @@ -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 { + const data = toBinary(PacketBaseSchema, base); + await new Promise((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 { + return new Promise((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); +} diff --git a/typescript/src/ws_server.ts b/typescript/src/ws_server.ts new file mode 100644 index 0000000..d4b72f6 --- /dev/null +++ b/typescript/src/ws_server.ts @@ -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(); + + 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 { + if (this.wss !== null) { + return Promise.resolve(); + } + + return new Promise((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 { + if (this.wss === null) { + return; + } + + const wss = this.wss; + this.wss = null; + const clients = [...this.clients]; + this.clients.clear(); + const closeServer = new Promise((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 { + await Promise.all([...this.clients].map(async (client) => client.communicator.send(msg))); + } + + private removeClient(client: WsClient): void { + this.clients.delete(client); + } +} diff --git a/typescript/test/base_client.test.ts b/typescript/test/base_client.test.ts new file mode 100644 index 0000000..5380402 --- /dev/null +++ b/typescript/test/base_client.test.ts @@ -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 { + 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 {} + + async triggerDisconnect(): Promise { + 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(); + }); +}); diff --git a/typescript/test/communicator.test.ts b/typescript/test/communicator.test.ts new file mode 100644 index 0000000..319b272 --- /dev/null +++ b/typescript/test/communicator.test.ts @@ -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 { + this.packets.push(base); + } + + async close(): Promise {} +} + +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); + }); +}); diff --git a/typescript/test/tcp.test.ts b/typescript/test/tcp.test.ts new file mode 100644 index 0000000..fa926a2 --- /dev/null +++ b/typescript/test/tcp.test.ts @@ -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 { + return new Promise((resolve) => { + addListenerTyped(client.communicator, TestDataSchema, (msg) => resolve(msg)); + }); +} + +describe("TCP", () => { + const cleanup: Array<() => Promise> = []; + + 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", + }); + }); +}); diff --git a/typescript/test/ws.test.ts b/typescript/test/ws.test.ts new file mode 100644 index 0000000..87458ee --- /dev/null +++ b/typescript/test/ws.test.ts @@ -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 { + return new Promise((resolve) => { + addListenerTyped(client.communicator, TestDataSchema, (msg) => resolve(msg)); + }); +} + +describe("WS", () => { + const cleanup: Array<() => Promise> = []; + + 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", + }); + }); +}); diff --git a/typescript/tsconfig.json b/typescript/tsconfig.json new file mode 100644 index 0000000..edf4487 --- /dev/null +++ b/typescript/tsconfig.json @@ -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"] +}