diff --git a/PORTING_GUIDE.md b/PORTING_GUIDE.md index 4c544ac..7233679 100644 --- a/PORTING_GUIDE.md +++ b/PORTING_GUIDE.md @@ -6,6 +6,10 @@ - 동시성·close-once 구조는 Go 구현체를 우선 참고하고, Dart 구현체는 public API와 parser map 사용 예시를 함께 참고한다 - 프로토콜 정의는 `PROTOCOL.md`가 단일 기준이다 - 각 언어의 관용적 패턴을 따르되, 아래 핵심 구조는 반드시 유지한다 +- Protocol Buffers는 이 프로젝트의 핵심 프로토콜 의존성으로 취급한다. 그 외 런타임 구현은 가능한 한 native platform API와 standard library를 우선한다 +- protobuf 외 외부 의존성은 구현하려는 기능에 정확히 대응하는 작고 focused된 라이브러리일 때만 허용한다. 넓은 프레임워크, 대형 런타임 계층, 부가 기능이 과도한 패키지를 좁은 기능 하나 때문에 추가하지 않는다 +- protobuf 외 외부 의존성을 추가해야 한다면 해당 언어에서 실용적인 native 대안이 없는지 먼저 검토하고, README에 이유와 범위를 명시한다 +- TCP/TLS/WebSocket, 바이너리 버퍼, 타이머, 동시성/비동기 처리처럼 플랫폼이 기본 제공하는 기능은 외부 패키지보다 기본 API를 우선한다 ### 반드시 유지해야 하는 구조 @@ -27,7 +31,7 @@ 3. canonical proto인 `proto/message_common.proto`에서 언어별 protobuf binding을 생성한다. Go처럼 generator 전용 option이 필요하면 언어 패키지 안에 copy를 둘 수 있지만, message schema는 `tools/check_proto_sync.sh`로 검증 가능해야 한다. 4. `Transport`와 `Communicator`를 먼저 구현하고, 그 위에 TCP/TLS+TCP/WS/WSS client/server를 올린다. 5. 같은 언어 단위 테스트를 작성한 뒤 `agent-ops/skills/project/add-proto-socket-crosstest-language/SKILL.md`의 runner 배치 규칙에 맞춰 양방향 crosstest를 추가한다. -6. README의 Implementations 표를 `Available`로 바꾸기 전에 formatter/linter, 단위 테스트, cross-language tests를 모두 통과시킨다. +6. README의 Implementations 표를 `Available`로 바꾸기 전에 formatter/linter, 단위 테스트, cross-language tests를 모두 통과시키고, protobuf 외 런타임 의존성이 있다면 native 대안 검토 결과, 기능 범위와 라이브러리 범위가 맞는 이유, 필요성을 문서화한다. 템플릿은 시작점과 완료 조건만 고정한다. 실제 소스 구조, 패키지 매니저, async runtime은 각 언어의 관용적 선택을 따른다. @@ -149,8 +153,8 @@ ### 주의사항 -- **크로스 환경 (Browser & Node.js)**: 코어 로직(`Communicator`, `BaseClient`)은 런타임 환경에 독립적으로 작성한다. 브라우저에서는 내장 `WebSocket`을, Node.js에서는 `net.Socket`(TCP)이나 `ws` 패키지를 사용하는 Transport 구현체를 주입받도록 설계한다 -- **protobuf**: `@bufbuild/protobuf` (protobuf-es) 패키지 사용을 권장한다. `typeName`은 `MessageType.typeName`에서 추출한다. `PROTOCOL.md` 표에서 TypeScript 행을 확인한다 +- **크로스 환경 (Browser & Node.js)**: 코어 로직(`Communicator`, `BaseClient`)은 런타임 환경에 독립적으로 작성한다. 브라우저에서는 내장 `WebSocket`을 사용하고, Node.js에서는 `net`, `tls`, `http`, `https` 등 Node.js 내장 모듈 기반 Transport를 사용한다. TypeScript 런타임에 외부 WebSocket 패키지를 추가하지 않는다 +- **protobuf**: protobuf는 핵심 프로토콜 계층이므로 예외로 취급한다. 현재 TypeScript 구현은 canonical schema에 맞춘 native codec을 사용하지만, protobuf binding/runtime을 도입해야 한다면 프로토콜 계층 용도로만 제한한다. `typeName`은 로컬 `MessageType.typeName` 값이 `PROTOCOL.md` 표와 일치해야 한다 - **단일 스레드 (Event Loop)**: JS/TS는 단일 스레드 기반이므로 메모리 동시 접근에 대한 Mutex/Lock(`sync.RWMutex`)은 불필요하다. 단, 비동기 컨텍스트(await) 간의 논리적 상태 오염은 주의한다 - **바이너리 처리**: Node.js의 `Buffer` 대신 표준 웹 API인 `Uint8Array`를 기준으로 작성하여 브라우저 환경 호환성을 확보한다 - **`connCloseOnce` 패턴**: 단순 `isClosed: boolean` 플래그로 멱등성을 보장한다. 만약 `Close()` 내에 `await`가 포함될 경우 중복 실행(re-entrancy) 방지에 유의한다 diff --git a/README.md b/README.md index 564d0de..397bb44 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Built on Protocol Buffers with TCP length-prefixed framing, WebSocket binary fra - Keep the core transport layer thin and stable - Provide only the minimum common foundation for cross-language communication - Standardize framing, serialization, routing, request-response correlation, and heartbeat +- Treat Protocol Buffers as the core protocol dependency. Other runtime dependencies should be native-first, narrowly scoped, and proportional to the feature they implement - Do not embed application semantics into the protocol core - Domain-specific concerns such as auth, session, agent workflow, chat features, and game logic belong in upper-layer implementations @@ -44,6 +45,8 @@ Protocol compatibility is tracked separately from language package versions. See New language implementations should start from [PORTING_GUIDE.md](PORTING_GUIDE.md) and the templates in [agent-ops/skills/project/add-proto-socket-crosstest-language/templates/](agent-ops/skills/project/add-proto-socket-crosstest-language/templates/). Mark an implementation available only after its same-language tests and cross-language tests pass. +Roadmap direction: every implementation should stay as close to native platform behavior as possible outside the protobuf layer. Prefer built-in TCP/TLS/WebSocket, concurrency, timer, and binary APIs over external packages. A focused library that directly matches a protocol need is acceptable when native support is missing or impractical, but broad frameworks or runtime layers must not be introduced just to cover a narrow feature such as WebSocket transport. Runtime dependencies beyond protobuf must be explicitly justified, protocol-relevant, and kept minimal. + --- ## Quick Start (Dart) diff --git a/agent-ops/skills/project/add-proto-socket-crosstest-language/templates/IMPLEMENTATION_CHECKLIST.md b/agent-ops/skills/project/add-proto-socket-crosstest-language/templates/IMPLEMENTATION_CHECKLIST.md index 50fc5a7..bb7ef3d 100644 --- a/agent-ops/skills/project/add-proto-socket-crosstest-language/templates/IMPLEMENTATION_CHECKLIST.md +++ b/agent-ops/skills/project/add-proto-socket-crosstest-language/templates/IMPLEMENTATION_CHECKLIST.md @@ -9,6 +9,7 @@ Target protocol version: `0.1` - [ ] 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. +- [ ] Runtime dependencies beyond protobuf are avoided where practical; any required non-protobuf dependency is focused, proportional to the implemented feature, and documented with its purpose and native alternative review. ## Transport @@ -17,6 +18,8 @@ Target protocol version: `0.1` - [ ] 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. +- [ ] TCP/TLS/WebSocket implementations prefer native platform APIs or the standard library over external packages. +- [ ] No broad framework or runtime layer is introduced solely to cover TCP/TLS/WebSocket transport. - [ ] TLS+TCP and WSS are supported or explicitly marked unsupported with a reason. ## Lifecycle diff --git a/agent-ops/skills/project/add-proto-socket-crosstest-language/templates/README.md b/agent-ops/skills/project/add-proto-socket-crosstest-language/templates/README.md index 0ef7281..c2e32aa 100644 --- a/agent-ops/skills/project/add-proto-socket-crosstest-language/templates/README.md +++ b/agent-ops/skills/project/add-proto-socket-crosstest-language/templates/README.md @@ -14,6 +14,10 @@ Use this package README to document the concrete files for: - Generated protobuf bindings from `proto/message_common.proto`. - Unit tests and cross-language tests. +## Runtime Dependencies + +Protocol Buffers is the core protocol dependency. For everything else, prefer native platform APIs and the standard library for transport, binary handling, timers, and concurrency. A non-protobuf runtime dependency must be focused and proportional to the feature it implements. Do not introduce a broad framework or runtime layer solely to cover a narrow feature such as WebSocket transport. If a non-protobuf dependency is required, document why the native alternative is insufficient and keep the dependency surface minimal. + ## Proto Generation Document the package-local proto generation command here. The generated schema must match `proto/message_common.proto` except for language-specific generator options. diff --git a/typescript/README.md b/typescript/README.md index e323913..57f11f6 100644 --- a/typescript/README.md +++ b/typescript/README.md @@ -8,7 +8,7 @@ This package implements Proto Socket protocol version `0.1` for TypeScript. - Runtime targets: Node.js TCP, Node.js WebSocket, browser native WebSocket. - Browser entrypoint (`proto-socket`): `Communicator`, `BaseClient`, `BrowserWsClient`, `connectBrowserWs`. Has no `ws`, `node:*`, or `Buffer` dependency. -- Node entrypoint (`proto-socket/node`): all of the above plus `TcpClient`/`TcpServer`, `NodeWsClient`/`NodeWsServer`, `connectNodeWs`/`connectNodeWss`. +- Node entrypoint (`proto-socket/node`): all of the above plus `TcpClient`/`TcpServer`, `NodeWsClient`/`NodeWsServer`, `connectNodeWs`/`connectNodeWss` using Node.js built-ins. ## Import @@ -22,28 +22,15 @@ import { connectNodeWs, NodeWsServer, TcpClient } from "proto-socket/node"; ## Runtime Dependencies -`ws` is declared as an optional peer dependency. Install it only when using the Node WebSocket entrypoint (`NodeWsClient`/`NodeWsServer`/`connectNodeWs`/`connectNodeWss`): +The TypeScript runtime has no non-protobuf package dependencies. The current canonical message codec +and Node.js TCP/TLS/WebSocket transports are implemented with native platform APIs, so this package +has no runtime dependencies at all. -```bash -npm install ws -``` +## Proto Sync -The browser entrypoint and Node TCP-only consumers do not need `ws`. - -## Proto Generation - -Generated bindings live in `src/packets/`. - -```bash -cd typescript -PATH="$PWD/node_modules/.bin:$PATH" protoc \ - --proto_path ../proto \ - --es_out src/packets \ - --es_opt target=ts \ - message_common.proto -``` - -After generation, run from the repository root: +The canonical schema lives in `../proto/message_common.proto`. The TypeScript packet codec in +`src/packets/` must stay wire-compatible with that schema. After proto changes, run from the +repository root: ```bash tools/check_proto_sync.sh diff --git a/typescript/crosstest/dart_typescript_client.ts b/typescript/crosstest/dart_typescript_client.ts index 4a19ebe..e99cb53 100644 --- a/typescript/crosstest/dart_typescript_client.ts +++ b/typescript/crosstest/dart_typescript_client.ts @@ -1,14 +1,12 @@ import * as fs from "node:fs"; -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 { create, TestDataSchema, type TestData } from "../src/packets/message_common_pb.js"; import { connectTcp, connectTcpTls } from "../src/tcp_client.js"; import { connectNodeWs, connectNodeWss } from "../src/node_ws_client.js"; diff --git a/typescript/crosstest/go_typescript_client.ts b/typescript/crosstest/go_typescript_client.ts index b4267ec..86bdcfb 100644 --- a/typescript/crosstest/go_typescript_client.ts +++ b/typescript/crosstest/go_typescript_client.ts @@ -1,14 +1,12 @@ import * as fs from "node:fs"; -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 { create, TestDataSchema, type TestData } from "../src/packets/message_common_pb.js"; import { connectTcp, connectTcpTls } from "../src/tcp_client.js"; import { connectNodeWs, connectNodeWss } from "../src/node_ws_client.js"; diff --git a/typescript/crosstest/kotlin_typescript_client.ts b/typescript/crosstest/kotlin_typescript_client.ts index 4b7b743..c971bf0 100644 --- a/typescript/crosstest/kotlin_typescript_client.ts +++ b/typescript/crosstest/kotlin_typescript_client.ts @@ -1,14 +1,12 @@ import * as fs from "node:fs"; -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 { create, TestDataSchema, type TestData } from "../src/packets/message_common_pb.js"; import { connectTcp, connectTcpTls } from "../src/tcp_client.js"; import { connectNodeWs, connectNodeWss } from "../src/node_ws_client.js"; diff --git a/typescript/crosstest/python_typescript_client.ts b/typescript/crosstest/python_typescript_client.ts index 736f7d4..ea40da5 100644 --- a/typescript/crosstest/python_typescript_client.ts +++ b/typescript/crosstest/python_typescript_client.ts @@ -1,14 +1,12 @@ import * as fs from "node:fs"; -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 { create, TestDataSchema, type TestData } from "../src/packets/message_common_pb.js"; import { connectTcp, connectTcpTls } from "../src/tcp_client.js"; import { connectNodeWs, connectNodeWss } from "../src/node_ws_client.js"; diff --git a/typescript/crosstest/typescript_dart.ts b/typescript/crosstest/typescript_dart.ts index 2604666..10e6edd 100644 --- a/typescript/crosstest/typescript_dart.ts +++ b/typescript/crosstest/typescript_dart.ts @@ -4,14 +4,12 @@ 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 { create, 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 { NodeWsClient } from "../src/node_ws_client.js"; diff --git a/typescript/crosstest/typescript_go.ts b/typescript/crosstest/typescript_go.ts index d15d2b4..cb06327 100644 --- a/typescript/crosstest/typescript_go.ts +++ b/typescript/crosstest/typescript_go.ts @@ -4,14 +4,12 @@ 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 { create, TestDataSchema } from "../src/packets/message_common_pb.js"; import { TcpClient } from "../src/tcp_client.js"; import { TcpServer } from "../src/tcp_server.js"; import { NodeWsClient } from "../src/node_ws_client.js"; diff --git a/typescript/crosstest/typescript_kotlin.ts b/typescript/crosstest/typescript_kotlin.ts index 5b649de..3b496f6 100644 --- a/typescript/crosstest/typescript_kotlin.ts +++ b/typescript/crosstest/typescript_kotlin.ts @@ -4,14 +4,12 @@ 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 { create, TestDataSchema } from "../src/packets/message_common_pb.js"; import { TcpClient } from "../src/tcp_client.js"; import { TcpServer } from "../src/tcp_server.js"; import { NodeWsClient } from "../src/node_ws_client.js"; diff --git a/typescript/crosstest/typescript_python.ts b/typescript/crosstest/typescript_python.ts index d2af1a9..e0eb5db 100644 --- a/typescript/crosstest/typescript_python.ts +++ b/typescript/crosstest/typescript_python.ts @@ -4,14 +4,12 @@ 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 { create, TestDataSchema } from "../src/packets/message_common_pb.js"; import { TcpClient } from "../src/tcp_client.js"; import { TcpServer } from "../src/tcp_server.js"; import { NodeWsClient } from "../src/node_ws_client.js"; diff --git a/typescript/package-lock.json b/typescript/package-lock.json index d2c2103..7c39905 100644 --- a/typescript/package-lock.json +++ b/typescript/package-lock.json @@ -7,82 +7,11 @@ "": { "name": "proto-socket", "version": "1.0.5", - "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" - }, - "peerDependencies": { - "ws": "^8.18.1" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - } - } - }, - "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" + "vitest": "^3.1.3" } }, "node_modules/@esbuild/aix-ppc64": { @@ -919,29 +848,6 @@ "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", @@ -1751,28 +1657,6 @@ "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==", - "dev": true, - "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 index 5c1dfbb..afbc94d 100644 --- a/typescript/package.json +++ b/typescript/package.json @@ -18,24 +18,10 @@ "test": "vitest run", "check": "tsc --noEmit" }, - "dependencies": { - "@bufbuild/protobuf": "^2.2.5" - }, - "peerDependencies": { - "ws": "^8.18.1" - }, - "peerDependenciesMeta": { - "ws": { - "optional": true - } - }, "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" } } diff --git a/typescript/src/base_client.ts b/typescript/src/base_client.ts index 8e56f5d..fb8d9c4 100644 --- a/typescript/src/base_client.ts +++ b/typescript/src/base_client.ts @@ -1,7 +1,5 @@ -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"; +import { create, HeartBeatSchema, type PacketBase } from "./packets/message_common_pb.js"; export abstract class BaseClient implements Transport { readonly communicator = new Communicator(); diff --git a/typescript/src/browser_ws_client.ts b/typescript/src/browser_ws_client.ts index 0e247af..a4e9cdd 100644 --- a/typescript/src/browser_ws_client.ts +++ b/typescript/src/browser_ws_client.ts @@ -1,8 +1,6 @@ -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"; +import { fromBinary, PacketBaseSchema, toBinary, type PacketBase } from "./packets/message_common_pb.js"; export interface BrowserWsConnectOptions { WebSocketCtor?: typeof WebSocket; diff --git a/typescript/src/communicator.ts b/typescript/src/communicator.ts index 7371bee..9495c46 100644 --- a/typescript/src/communicator.ts +++ b/typescript/src/communicator.ts @@ -1,12 +1,16 @@ -import { create, fromBinary, toBinary, type Message } from "@bufbuild/protobuf"; -import type { GenMessage } from "@bufbuild/protobuf/codegenv2"; - import { + create, + fromBinary, HeartBeatSchema, + type Message, + type MessageType, PacketBaseSchema, + toBinary, type PacketBase, } from "./packets/message_common_pb.js"; +export type { Message, MessageType } from "./packets/message_common_pb.js"; + export class NotConnectedError extends Error { constructor() { super("not connected"); @@ -14,8 +18,6 @@ export class NotConnectedError extends Error { } } -export type MessageType = GenMessage; - export type MessageParser = ((data: Uint8Array) => T) & { schema?: MessageType; }; diff --git a/typescript/src/node_ws_client.ts b/typescript/src/node_ws_client.ts index 49e733d..a3ec37a 100644 --- a/typescript/src/node_ws_client.ts +++ b/typescript/src/node_ws_client.ts @@ -1,15 +1,258 @@ -import WebSocket, { type ClientOptions, type RawData } from "ws"; - -import { fromBinary, toBinary } from "@bufbuild/protobuf"; +import * as crypto from "node:crypto"; +import { EventEmitter } from "node:events"; +import * as net from "node:net"; +import * as tls from "node:tls"; import { BaseClient } from "./base_client.js"; import { type ParserMap } from "./communicator.js"; -import { PacketBaseSchema, type PacketBase } from "./packets/message_common_pb.js"; +import { fromBinary, PacketBaseSchema, toBinary, type PacketBase } from "./packets/message_common_pb.js"; + +const WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; +const MAX_WS_PAYLOAD_SIZE = 64 << 20; + +export type NodeWssConnectOptions = tls.ConnectionOptions; + +type NodeSocket = net.Socket | tls.TLSSocket; + +export class NodeWebSocket { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + readyState = NodeWebSocket.OPEN; + + private readonly events = new EventEmitter(); + private readBuf = Buffer.alloc(0); + private fragments: Buffer[] = []; + private fragmentOpcode: number | null = null; + private closeEmitted = false; + + constructor( + private readonly socket: NodeSocket, + private readonly maskOutgoing: boolean, + initialData: Buffer = Buffer.alloc(0), + ) { + this.events.on("error", () => {}); + + socket.on("data", (chunk) => { + this.onData(chunk); + }); + socket.on("error", (err) => { + this.events.emit("error", err); + }); + socket.once("close", () => { + this.emitClose(); + }); + + if (initialData.byteLength > 0) { + queueMicrotask(() => this.onData(initialData)); + } + } + + on(event: "message", listener: (data: Uint8Array) => void): this; + on(event: "close", listener: () => void): this; + on(event: "error", listener: (err: Error) => void): this; + on(event: string, listener: (...args: any[]) => void): this { + this.events.on(event, listener); + return this; + } + + once(event: "close", listener: () => void): this; + once(event: "error", listener: (err: Error) => void): this; + once(event: string, listener: (...args: any[]) => void): this { + this.events.once(event, listener); + return this; + } + + off(event: "message", listener: (data: Uint8Array) => void): this; + off(event: "close", listener: () => void): this; + off(event: "error", listener: (err: Error) => void): this; + off(event: string, listener: (...args: any[]) => void): this { + this.events.off(event, listener); + return this; + } + + send(data: Uint8Array, callback?: (err?: Error | null) => void): void { + if (this.readyState !== NodeWebSocket.OPEN) { + callback?.(new Error("WebSocket is not open")); + return; + } + this.writeFrame(0x2, Buffer.from(data), callback); + } + + close(): void { + if (this.readyState === NodeWebSocket.CLOSED || this.readyState === NodeWebSocket.CLOSING) { + return; + } + + this.readyState = NodeWebSocket.CLOSING; + this.writeFrame(0x8, Buffer.alloc(0), () => { + this.socket.end(); + }); + } + + terminate(): void { + this.readyState = NodeWebSocket.CLOSED; + this.socket.destroy(); + this.emitClose(); + } + + private onData(chunk: Buffer): void { + if (this.readyState === NodeWebSocket.CLOSED) { + return; + } + + this.readBuf = Buffer.concat([this.readBuf, chunk]); + try { + while (this.tryReadFrame()) { + continue; + } + } catch (err) { + this.events.emit("error", err instanceof Error ? err : new Error(String(err))); + this.terminate(); + } + } + + private tryReadFrame(): boolean { + if (this.readBuf.byteLength < 2) { + return false; + } + + const first = this.readBuf[0]!; + const second = this.readBuf[1]!; + const fin = (first & 0x80) !== 0; + const opcode = first & 0x0f; + const masked = (second & 0x80) !== 0; + let payloadLength = second & 0x7f; + let offset = 2; + + if (payloadLength === 126) { + if (this.readBuf.byteLength < offset + 2) { + return false; + } + payloadLength = this.readBuf.readUInt16BE(offset); + offset += 2; + } else if (payloadLength === 127) { + if (this.readBuf.byteLength < offset + 8) { + return false; + } + const longLength = this.readBuf.readBigUInt64BE(offset); + if (longLength > BigInt(MAX_WS_PAYLOAD_SIZE)) { + throw new Error("WebSocket payload exceeds maximum size"); + } + payloadLength = Number(longLength); + offset += 8; + } + + const expectedMasked = !this.maskOutgoing; + if (masked !== expectedMasked) { + throw new Error("invalid WebSocket frame masking"); + } + if (payloadLength > MAX_WS_PAYLOAD_SIZE) { + throw new Error("WebSocket payload exceeds maximum size"); + } + + const maskOffset = masked ? 4 : 0; + const frameLength = offset + maskOffset + payloadLength; + if (this.readBuf.byteLength < frameLength) { + return false; + } + + const mask = masked ? this.readBuf.subarray(offset, offset + 4) : null; + offset += maskOffset; + const rawPayload = this.readBuf.subarray(offset, offset + payloadLength); + const payload = mask === null ? Buffer.from(rawPayload) : unmask(rawPayload, mask); + this.readBuf = this.readBuf.subarray(frameLength); + + this.handleFrame(opcode, fin, payload); + return true; + } + + private handleFrame(opcode: number, fin: boolean, payload: Buffer): void { + if (opcode >= 0x8) { + if (!fin || payload.byteLength > 125) { + throw new Error("invalid WebSocket control frame"); + } + this.handleControlFrame(opcode, payload); + return; + } + + if (opcode === 0x0) { + if (this.fragmentOpcode === null) { + throw new Error("unexpected WebSocket continuation frame"); + } + this.fragments.push(payload); + if (fin) { + const data = Buffer.concat(this.fragments); + const fragmentOpcode = this.fragmentOpcode; + this.fragments = []; + this.fragmentOpcode = null; + this.handleMessageFrame(fragmentOpcode, data); + } + return; + } + + if (opcode !== 0x1 && opcode !== 0x2) { + throw new Error(`unsupported WebSocket opcode: ${opcode}`); + } + + if (!fin) { + this.fragmentOpcode = opcode; + this.fragments = [payload]; + return; + } + + this.handleMessageFrame(opcode, payload); + } + + private handleControlFrame(opcode: number, payload: Buffer): void { + if (opcode === 0x8) { + if (this.readyState === NodeWebSocket.OPEN) { + this.readyState = NodeWebSocket.CLOSING; + this.writeFrame(0x8, payload, () => { + this.socket.end(); + }); + } else { + this.socket.end(); + } + return; + } + if (opcode === 0x9) { + this.writeFrame(0xA, payload); + } + } + + private handleMessageFrame(opcode: number, payload: Buffer): void { + if (opcode !== 0x2) { + throw new Error("unsupported WebSocket text frame"); + } + this.events.emit("message", new Uint8Array(payload)); + } + + private writeFrame(opcode: number, payload: Buffer, callback?: (err?: Error | null) => void): void { + if (this.socket.destroyed) { + callback?.(new Error("socket is closed")); + return; + } + const frame = encodeFrame(opcode, payload, this.maskOutgoing); + this.socket.write(frame, callback); + } + + private emitClose(): void { + if (this.closeEmitted) { + return; + } + this.closeEmitted = true; + this.readyState = NodeWebSocket.CLOSED; + this.events.emit("close"); + } +} export class NodeWsClient extends BaseClient { - private readonly ws: WebSocket; + private readonly ws: NodeWebSocket; - constructor(ws: WebSocket, intervalSec: number, waitSec: number, parserMap: ParserMap) { + constructor(ws: NodeWebSocket, intervalSec: number, waitSec: number, parserMap: ParserMap) { super(intervalSec, waitSec, () => closeWebSocket(ws)); this.ws = ws; this.initBase(parserMap); @@ -28,7 +271,7 @@ export class NodeWsClient extends BaseClient { async writePacket(base: PacketBase): Promise { const data = toBinary(PacketBaseSchema, base); await new Promise((resolve, reject) => { - this.ws.send(data, { binary: true }, (err) => { + this.ws.send(data, (err) => { if (err) { reject(err); return; @@ -38,9 +281,9 @@ export class NodeWsClient extends BaseClient { }); } - private onMessage(data: RawData): void { + private onMessage(data: Uint8Array): void { try { - const base = fromBinary(PacketBaseSchema, toUint8Array(data)); + const base = fromBinary(PacketBaseSchema, data); this.communicator.onReceivedData(base.typeName, base.data, base.nonce, base.responseNonce); void this.sendHeartbeat(); } catch { @@ -57,67 +300,217 @@ export async function connectNodeWs( waitSec: number, parserMap: ParserMap, ): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://${host}:${port}${path}`); - const onOpen = () => { - cleanup(); - resolve(new NodeWsClient(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); - }); + const socket = await connectPlainSocket(host, port); + return connectNodeWebSocket(socket, host, port, path, intervalSec, waitSec, parserMap); } export async function connectNodeWss( host: string, port: number, path: string, - wsOptions: ClientOptions, + wsOptions: NodeWssConnectOptions, intervalSec: number, waitSec: number, parserMap: ParserMap, ): Promise { - return new Promise((resolve, reject) => { - const ws = new WebSocket(`wss://${host}:${port}${path}`, wsOptions); - const onOpen = () => { + const socket = await connectTlsSocket(host, port, wsOptions); + return connectNodeWebSocket(socket, host, port, path, intervalSec, waitSec, parserMap); +} + +export function createWebSocketAccept(key: string): string { + return crypto.createHash("sha1").update(`${key}${WS_GUID}`).digest("base64"); +} + +async function connectPlainSocket(host: string, port: number): Promise { + return new Promise((resolve, reject) => { + const socket = net.createConnection({ host, port }); + const cleanup = () => { + socket.off("connect", onConnect); + socket.off("error", onError); + }; + const onConnect = () => { cleanup(); - resolve(new NodeWsClient(ws, intervalSec, waitSec, parserMap)); + resolve(socket); }; 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); + socket.once("connect", onConnect); + socket.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); +async function connectTlsSocket( + host: string, + port: number, + options: tls.ConnectionOptions, +): Promise { + return new Promise((resolve, reject) => { + const servername = options.servername ?? (net.isIP(host) === 0 ? host : undefined); + const socket = tls.connect({ + ...options, + host, + port, + servername, + }); + const cleanup = () => { + socket.off("secureConnect", onSecureConnect); + socket.off("error", onError); + }; + const onSecureConnect = () => { + cleanup(); + resolve(socket); + }; + const onError = (err: Error) => { + cleanup(); + reject(err); + }; + + socket.once("secureConnect", onSecureConnect); + socket.once("error", onError); + }); } -async function closeWebSocket(ws: WebSocket): Promise { - if (ws.readyState === WebSocket.CLOSED) { +async function connectNodeWebSocket( + socket: NodeSocket, + host: string, + port: number, + path: string, + intervalSec: number, + waitSec: number, + parserMap: ParserMap, +): Promise { + return new Promise((resolve, reject) => { + const key = crypto.randomBytes(16).toString("base64"); + let response = Buffer.alloc(0); + + const cleanup = () => { + socket.off("data", onData); + socket.off("error", onError); + socket.off("close", onClose); + }; + const fail = (err: Error) => { + cleanup(); + socket.destroy(); + reject(err); + }; + const onError = (err: Error) => { + fail(err); + }; + const onClose = () => { + fail(new Error("socket closed during WebSocket handshake")); + }; + const onData = (chunk: Buffer) => { + response = Buffer.concat([response, chunk]); + const headerEnd = response.indexOf("\r\n\r\n"); + if (headerEnd < 0) { + return; + } + + const header = response.subarray(0, headerEnd).toString("latin1"); + const remaining = response.subarray(headerEnd + 4); + try { + verifyHandshakeResponse(header, key); + } catch (err) { + fail(err instanceof Error ? err : new Error(String(err))); + return; + } + + cleanup(); + resolve(new NodeWsClient(new NodeWebSocket(socket, true, remaining), intervalSec, waitSec, parserMap)); + }; + + socket.on("data", onData); + socket.once("error", onError); + socket.once("close", onClose); + socket.write(buildHandshakeRequest(host, port, path, key)); + }); +} + +function buildHandshakeRequest(host: string, port: number, path: string, key: string): string { + const requestPath = path.startsWith("/") ? path : `/${path}`; + return [ + `GET ${requestPath} HTTP/1.1`, + `Host: ${host}:${port}`, + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Key: ${key}`, + "Sec-WebSocket-Version: 13", + "\r\n", + ].join("\r\n"); +} + +function verifyHandshakeResponse(header: string, key: string): void { + const lines = header.split("\r\n"); + const statusLine = lines.shift() ?? ""; + if (!/^HTTP\/1\.[01] 101(?: |$)/.test(statusLine)) { + throw new Error(`invalid WebSocket handshake status: ${statusLine}`); + } + + const headers = new Map(); + for (const line of lines) { + const index = line.indexOf(":"); + if (index <= 0) { + continue; + } + headers.set(line.slice(0, index).trim().toLowerCase(), line.slice(index + 1).trim()); + } + + const accept = headers.get("sec-websocket-accept"); + if (accept !== createWebSocketAccept(key)) { + throw new Error("invalid WebSocket handshake accept header"); + } +} + +function encodeFrame(opcode: number, payload: Buffer, mask: boolean): Buffer { + const payloadLength = payload.byteLength; + const lengthBytes = payloadLength < 126 ? 0 : payloadLength <= 0xffff ? 2 : 8; + const maskBytes = mask ? 4 : 0; + const header = Buffer.allocUnsafe(2 + lengthBytes + maskBytes); + let offset = 0; + + header[offset] = 0x80 | opcode; + offset += 1; + if (payloadLength < 126) { + header[offset] = (mask ? 0x80 : 0) | payloadLength; + offset += 1; + } else if (payloadLength <= 0xffff) { + header[offset] = (mask ? 0x80 : 0) | 126; + offset += 1; + header.writeUInt16BE(payloadLength, offset); + offset += 2; + } else { + header[offset] = (mask ? 0x80 : 0) | 127; + offset += 1; + header.writeBigUInt64BE(BigInt(payloadLength), offset); + offset += 8; + } + + if (!mask) { + return Buffer.concat([header, payload]); + } + + const maskingKey = crypto.randomBytes(4); + maskingKey.copy(header, offset); + return Buffer.concat([header, maskPayload(payload, maskingKey)]); +} + +function unmask(payload: Buffer, mask: Buffer): Buffer { + return maskPayload(payload, mask); +} + +function maskPayload(payload: Buffer, mask: Buffer): Buffer { + const result = Buffer.allocUnsafe(payload.byteLength); + for (let i = 0; i < payload.byteLength; i += 1) { + result[i] = payload[i]! ^ mask[i % 4]!; + } + return result; +} + +async function closeWebSocket(ws: NodeWebSocket): Promise { + if (ws.readyState === NodeWebSocket.CLOSED) { return; } @@ -139,7 +532,7 @@ async function closeWebSocket(ws: WebSocket): Promise { ws.once("close", onDone); ws.once("error", onDone); - if (ws.readyState === WebSocket.CLOSING) { + if (ws.readyState === NodeWebSocket.CLOSING) { return; } try { diff --git a/typescript/src/node_ws_server.ts b/typescript/src/node_ws_server.ts index 312b107..d35e73c 100644 --- a/typescript/src/node_ws_server.ts +++ b/typescript/src/node_ws_server.ts @@ -1,13 +1,12 @@ +import * as http from "node:http"; import * as https from "node:https"; +import * as net from "node:net"; -import { type Message } from "@bufbuild/protobuf"; -import WebSocket, { WebSocketServer } from "ws"; - -import { NodeWsClient } from "./node_ws_client.js"; +import { NodeWebSocket, NodeWsClient, createWebSocketAccept } from "./node_ws_client.js"; +import { type Message } from "./packets/message_common_pb.js"; export class NodeWsServer { - private wss: WebSocketServer | null = null; - private httpsServer: https.Server | null = null; + private server: http.Server | https.Server | null = null; private readonly clients = new Set(); onClientConnected: (client: NodeWsClient) => void = () => {}; @@ -16,12 +15,12 @@ export class NodeWsServer { private readonly host: string, private readonly listenPort: number, private readonly path: string, - private readonly newClient: (ws: WebSocket) => NodeWsClient, + private readonly newClient: (ws: NodeWebSocket) => NodeWsClient, private readonly tlsOptions?: https.ServerOptions, ) {} get port(): number { - const address = this.httpsServer?.address() ?? this.wss?.address(); + const address = this.server?.address(); if (address !== undefined && address !== null && typeof address === "object") { return address.port; } @@ -29,61 +28,46 @@ export class NodeWsServer { } start(): Promise { - if (this.wss !== null) { + if (this.server !== null) { return Promise.resolve(); } return new Promise((resolve, reject) => { - const httpsServer = this.tlsOptions ? https.createServer(this.tlsOptions) : null; - const wss = httpsServer - ? new WebSocketServer({ server: httpsServer, path: this.path }) - : new WebSocketServer({ - host: this.host, - path: this.path, - port: this.listenPort, - }); + const server = this.tlsOptions ? https.createServer(this.tlsOptions) : http.createServer(); const onError = (err: Error) => { cleanup(); reject(err); }; const onListening = () => { cleanup(); - this.wss = wss; - this.httpsServer = httpsServer; + this.server = server; resolve(); }; const cleanup = () => { - wss.off("error", onError); - wss.off("listening", onListening); - httpsServer?.off("error", onError); - httpsServer?.off("listening", onListening); + server.off("error", onError); + server.off("listening", onListening); }; - wss.once("error", onError); - wss.on("connection", (ws) => this.handleConnection(ws)); - if (httpsServer !== null) { - httpsServer.once("error", onError); - httpsServer.once("listening", onListening); - httpsServer.listen(this.listenPort, this.host); - } else { - wss.once("listening", onListening); - } + server.on("upgrade", (req, socket, head) => { + this.handleUpgrade(req, socket as net.Socket, head); + }); + server.once("error", onError); + server.once("listening", onListening); + server.listen(this.listenPort, this.host); }); } async stop(): Promise { - if (this.wss === null) { + if (this.server === null) { return; } - const wss = this.wss; - const httpsServer = this.httpsServer; - this.wss = null; - this.httpsServer = null; + const server = this.server; + this.server = null; const clients = [...this.clients]; this.clients.clear(); const closeServer = new Promise((resolve, reject) => { - wss.close((err) => { + server.close((err) => { if (err) { reject(err); return; @@ -91,22 +75,9 @@ export class NodeWsServer { resolve(); }); }); - const closeHttpsServer = - httpsServer === null - ? Promise.resolve() - : new Promise((resolve, reject) => { - httpsServer.close((err) => { - if (err) { - reject(err); - return; - } - resolve(); - }); - }); await Promise.allSettled(clients.map(async (client) => client.close())); await closeServer; - await closeHttpsServer; } async broadcast(msg: Message): Promise { @@ -117,7 +88,53 @@ export class NodeWsServer { this.clients.delete(client); } - private handleConnection(ws: WebSocket): void { + private handleUpgrade(req: http.IncomingMessage, socket: net.Socket, head: Buffer): void { + const key = this.validateUpgrade(req); + if (key === null) { + socket.write("HTTP/1.1 400 Bad Request\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + + socket.write( + [ + "HTTP/1.1 101 Switching Protocols", + "Upgrade: websocket", + "Connection: Upgrade", + `Sec-WebSocket-Accept: ${createWebSocketAccept(key)}`, + "\r\n", + ].join("\r\n"), + ); + this.handleConnection(new NodeWebSocket(socket, false, head)); + } + + private validateUpgrade(req: http.IncomingMessage): string | null { + if (!this.pathMatches(req)) { + return null; + } + + const upgrade = String(req.headers.upgrade ?? "").toLowerCase(); + const connection = String(req.headers.connection ?? "").toLowerCase(); + const version = String(req.headers["sec-websocket-version"] ?? ""); + const keyHeader = req.headers["sec-websocket-key"]; + const key = Array.isArray(keyHeader) ? keyHeader[0] : keyHeader; + + if (upgrade !== "websocket" || !connection.split(",").map((part) => part.trim()).includes("upgrade")) { + return null; + } + if (version !== "13" || typeof key !== "string" || key.length === 0) { + return null; + } + + return key; + } + + private pathMatches(req: http.IncomingMessage): boolean { + const requestUrl = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`); + return requestUrl.pathname === this.path; + } + + private handleConnection(ws: NodeWebSocket): void { const client = this.newClient(ws); this.clients.add(client); client.addDisconnectListener(() => { diff --git a/typescript/src/packets/message_common_pb.ts b/typescript/src/packets/message_common_pb.ts index 7d2157c..bb89e65 100644 --- a/typescript/src/packets/message_common_pb.ts +++ b/typescript/src/packets/message_common_pb.ts @@ -1,81 +1,343 @@ -// @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"; +export interface Message { + readonly $typeName: TypeName; +} -/** - * Describes the file message_common.proto. - */ -export const file_message_common: GenFile = /*@__PURE__*/ - fileDesc("ChRtZXNzYWdlX2NvbW1vbi5wcm90byJSCgpQYWNrZXRCYXNlEhAKCHR5cGVOYW1lGAEgASgJEg0KBW5vbmNlGAIgASgFEgwKBGRhdGEYAyABKAwSFQoNcmVzcG9uc2VOb25jZRgEIAEoBSILCglIZWFydEJlYXQiKgoIVGVzdERhdGESDQoFaW5kZXgYASABKAUSDwoHbWVzc2FnZRgCIAEoCWIGcHJvdG8z"); +export type MessageInit = Partial>; + +export interface MessageType { + readonly typeName: T["$typeName"]; + create(value?: MessageInit): T; + fromBinary(data: Uint8Array): T; + toBinary(message: T): Uint8Array; +} + +export const file_message_common = { + name: "message_common.proto", + messages: ["PacketBase", "HeartBeat", "TestData"], +} as const; -/** - * @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); +export type HeartBeat = Message<"HeartBeat">; -/** - * @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); +export const PacketBaseSchema: MessageType = { + typeName: "PacketBase", + create(value = {}) { + return { + $typeName: "PacketBase", + typeName: value.typeName ?? "", + nonce: value.nonce ?? 0, + data: cloneBytes(value.data), + responseNonce: value.responseNonce ?? 0, + }; + }, + fromBinary(data) { + return decodePacketBase(data); + }, + toBinary(message) { + return encodePacketBase(message); + }, +}; +export const HeartBeatSchema: MessageType = { + typeName: "HeartBeat", + create() { + return { $typeName: "HeartBeat" }; + }, + fromBinary(data) { + skipUnknownMessage(data); + return this.create(); + }, + toBinary() { + return new Uint8Array(); + }, +}; + +export const TestDataSchema: MessageType = { + typeName: "TestData", + create(value = {}) { + return { + $typeName: "TestData", + index: value.index ?? 0, + message: value.message ?? "", + }; + }, + fromBinary(data) { + return decodeTestData(data); + }, + toBinary(message) { + return encodeTestData(message); + }, +}; + +export function create(schema: MessageType, value?: MessageInit): T { + return schema.create(value); +} + +export function fromBinary(schema: MessageType, data: Uint8Array): T { + return schema.fromBinary(data); +} + +export function toBinary(schema: MessageType, message: T): Uint8Array { + return schema.toBinary(message); +} + +function encodePacketBase(message: PacketBase): Uint8Array { + const chunks: Uint8Array[] = []; + if (message.typeName !== "") { + writeString(chunks, 1, message.typeName); + } + if (message.nonce !== 0) { + writeInt32(chunks, 2, message.nonce); + } + if (message.data.byteLength > 0) { + writeBytes(chunks, 3, message.data); + } + if (message.responseNonce !== 0) { + writeInt32(chunks, 4, message.responseNonce); + } + return concat(chunks); +} + +function decodePacketBase(data: Uint8Array): PacketBase { + const reader = new BinaryReader(data); + const message = PacketBaseSchema.create(); + + while (!reader.done()) { + const tag = reader.readVarintNumber(); + const fieldNumber = tag >>> 3; + const wireType = tag & 7; + switch (fieldNumber) { + case 1: + assertWireType(wireType, 2); + message.typeName = reader.readString(); + break; + case 2: + assertWireType(wireType, 0); + message.nonce = reader.readInt32(); + break; + case 3: + assertWireType(wireType, 2); + message.data = reader.readBytes(); + break; + case 4: + assertWireType(wireType, 0); + message.responseNonce = reader.readInt32(); + break; + default: + reader.skip(wireType); + break; + } + } + + return message; +} + +function encodeTestData(message: TestData): Uint8Array { + const chunks: Uint8Array[] = []; + if (message.index !== 0) { + writeInt32(chunks, 1, message.index); + } + if (message.message !== "") { + writeString(chunks, 2, message.message); + } + return concat(chunks); +} + +function decodeTestData(data: Uint8Array): TestData { + const reader = new BinaryReader(data); + const message = TestDataSchema.create(); + + while (!reader.done()) { + const tag = reader.readVarintNumber(); + const fieldNumber = tag >>> 3; + const wireType = tag & 7; + switch (fieldNumber) { + case 1: + assertWireType(wireType, 0); + message.index = reader.readInt32(); + break; + case 2: + assertWireType(wireType, 2); + message.message = reader.readString(); + break; + default: + reader.skip(wireType); + break; + } + } + + return message; +} + +function skipUnknownMessage(data: Uint8Array): void { + const reader = new BinaryReader(data); + while (!reader.done()) { + const tag = reader.readVarintNumber(); + reader.skip(tag & 7); + } +} + +function writeInt32(chunks: Uint8Array[], fieldNumber: number, value: number): void { + chunks.push(encodeTag(fieldNumber, 0)); + chunks.push(encodeInt32(value)); +} + +function writeString(chunks: Uint8Array[], fieldNumber: number, value: string): void { + writeLengthDelimited(chunks, fieldNumber, textEncoder.encode(value)); +} + +function writeBytes(chunks: Uint8Array[], fieldNumber: number, value: Uint8Array): void { + writeLengthDelimited(chunks, fieldNumber, value); +} + +function writeLengthDelimited(chunks: Uint8Array[], fieldNumber: number, value: Uint8Array): void { + chunks.push(encodeTag(fieldNumber, 2)); + chunks.push(encodeVarint(BigInt(value.byteLength))); + chunks.push(value); +} + +function encodeTag(fieldNumber: number, wireType: number): Uint8Array { + return encodeVarint(BigInt((fieldNumber << 3) | wireType)); +} + +function encodeInt32(value: number): Uint8Array { + if (!Number.isInteger(value) || value < -2147483648 || value > 2147483647) { + throw new Error(`int32 out of range: ${value}`); + } + const normalized = value | 0; + const varint = normalized < 0 ? BigInt.asUintN(64, BigInt(normalized)) : BigInt(normalized); + return encodeVarint(varint); +} + +function encodeVarint(value: bigint): Uint8Array { + if (value < 0n) { + throw new Error("varint cannot be negative"); + } + const bytes: number[] = []; + let current = value; + while (current > 0x7fn) { + bytes.push(Number((current & 0x7fn) | 0x80n)); + current >>= 7n; + } + bytes.push(Number(current)); + return new Uint8Array(bytes); +} + +class BinaryReader { + private offset = 0; + + constructor(private readonly data: Uint8Array) {} + + done(): boolean { + return this.offset >= this.data.byteLength; + } + + readInt32(): number { + return Number(BigInt.asIntN(32, this.readVarintBig())); + } + + readVarintNumber(): number { + const value = this.readVarintBig(); + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error("varint exceeds safe integer range"); + } + return Number(value); + } + + readString(): string { + return textDecoder.decode(this.readBytes()); + } + + readBytes(): Uint8Array { + const length = this.readVarintNumber(); + this.require(length); + const bytes = this.data.subarray(this.offset, this.offset + length); + this.offset += length; + return new Uint8Array(bytes); + } + + skip(wireType: number): void { + switch (wireType) { + case 0: + this.readVarintBig(); + return; + case 1: + this.require(8); + this.offset += 8; + return; + case 2: { + const length = this.readVarintNumber(); + this.require(length); + this.offset += length; + return; + } + case 5: + this.require(4); + this.offset += 4; + return; + default: + throw new Error(`unsupported protobuf wire type: ${wireType}`); + } + } + + private readVarintBig(): bigint { + let shift = 0n; + let result = 0n; + + for (let i = 0; i < 10; i += 1) { + this.require(1); + const byte = this.data[this.offset]!; + this.offset += 1; + result |= BigInt(byte & 0x7f) << shift; + if ((byte & 0x80) === 0) { + return result; + } + shift += 7n; + } + + throw new Error("invalid protobuf varint"); + } + + private require(length: number): void { + if (length < 0 || this.offset + length > this.data.byteLength) { + throw new Error("truncated protobuf message"); + } + } +} + +function assertWireType(actual: number, expected: number): void { + if (actual !== expected) { + throw new Error(`unexpected protobuf wire type ${actual}, expected ${expected}`); + } +} + +function concat(chunks: Uint8Array[]): Uint8Array { + const length = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + const result = new Uint8Array(length); + let offset = 0; + for (const chunk of chunks) { + result.set(chunk, offset); + offset += chunk.byteLength; + } + return result; +} + +function cloneBytes(value: Uint8Array | undefined): Uint8Array { + return value === undefined ? new Uint8Array() : new Uint8Array(value); +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); diff --git a/typescript/src/tcp_client.ts b/typescript/src/tcp_client.ts index c1f76bc..63a4453 100644 --- a/typescript/src/tcp_client.ts +++ b/typescript/src/tcp_client.ts @@ -1,11 +1,9 @@ import * as net from "node:net"; import * as tls from "node:tls"; -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"; +import { fromBinary, PacketBaseSchema, toBinary, type PacketBase } from "./packets/message_common_pb.js"; export const MAX_PACKET_SIZE = 64 << 20; diff --git a/typescript/src/tcp_server.ts b/typescript/src/tcp_server.ts index 9a3b15f..6f752db 100644 --- a/typescript/src/tcp_server.ts +++ b/typescript/src/tcp_server.ts @@ -1,8 +1,7 @@ import * as net from "node:net"; import * as tls from "node:tls"; -import { type Message } from "@bufbuild/protobuf"; - +import { type Message } from "./packets/message_common_pb.js"; import { TcpClient } from "./tcp_client.js"; export class TcpServer { diff --git a/typescript/test/base_client.test.ts b/typescript/test/base_client.test.ts index 5380402..068bec1 100644 --- a/typescript/test/base_client.test.ts +++ b/typescript/test/base_client.test.ts @@ -1,11 +1,12 @@ -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 { + create, HeartBeatSchema, TestDataSchema, + toBinary, type PacketBase, } from "../src/packets/message_common_pb.js"; diff --git a/typescript/test/browser_ws_client.test.ts b/typescript/test/browser_ws_client.test.ts index cceb7dc..abb32de 100644 --- a/typescript/test/browser_ws_client.test.ts +++ b/typescript/test/browser_ws_client.test.ts @@ -1,4 +1,3 @@ -import { create, fromBinary, toBinary } from "@bufbuild/protobuf"; import { afterEach, describe, expect, test } from "vitest"; import { @@ -8,8 +7,11 @@ import { sendRequestTyped, } from "../src/communicator.js"; import { + create, + fromBinary, PacketBaseSchema, TestDataSchema, + toBinary, type TestData, } from "../src/packets/message_common_pb.js"; import { BrowserWsClient, connectBrowserWs } from "../src/browser_ws_client.js"; diff --git a/typescript/test/communicator.test.ts b/typescript/test/communicator.test.ts index 23d61c5..99db43e 100644 --- a/typescript/test/communicator.test.ts +++ b/typescript/test/communicator.test.ts @@ -1,4 +1,3 @@ -import { create, toBinary } from "@bufbuild/protobuf"; import { describe, expect, test } from "vitest"; import { @@ -8,8 +7,10 @@ import { parserFromSchema, } from "../src/communicator.js"; import { + create, PacketBaseSchema, TestDataSchema, + toBinary, type PacketBase, } from "../src/packets/message_common_pb.js"; diff --git a/typescript/test/tcp.test.ts b/typescript/test/tcp.test.ts index 565a5aa..381cb86 100644 --- a/typescript/test/tcp.test.ts +++ b/typescript/test/tcp.test.ts @@ -2,7 +2,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { create } from "@bufbuild/protobuf"; import { afterEach, describe, expect, test } from "vitest"; import { @@ -11,7 +10,7 @@ import { parserFromSchema, sendRequestTyped, } from "../src/communicator.js"; -import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js"; +import { create, TestDataSchema, type TestData } from "../src/packets/message_common_pb.js"; import { connectTcp, connectTcpTls, TcpClient } from "../src/tcp_client.js"; import { TcpServer } from "../src/tcp_server.js"; diff --git a/typescript/test/ws.test.ts b/typescript/test/ws.test.ts index acac6b6..383754b 100644 --- a/typescript/test/ws.test.ts +++ b/typescript/test/ws.test.ts @@ -2,7 +2,6 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { fileURLToPath } from "node:url"; -import { create } from "@bufbuild/protobuf"; import { afterEach, describe, expect, test } from "vitest"; import { @@ -11,7 +10,7 @@ import { parserFromSchema, sendRequestTyped, } from "../src/communicator.js"; -import { TestDataSchema, type TestData } from "../src/packets/message_common_pb.js"; +import { create, TestDataSchema, type TestData } from "../src/packets/message_common_pb.js"; import { connectNodeWs, connectNodeWss, NodeWsClient } from "../src/node_ws_client.js"; import { NodeWsServer } from "../src/node_ws_server.js";