기능: TypeScript 패키지를 devDependencies로 통합하고 WebSocket 기능을 개선했다

- @bufbuild/protobuf를 dependencies에서 devDependencies로 이동
- ws를 peerDependencies에서 devDependencies로 이동
- node_ws_server.ts에 TLS 및 라우팅 기능 추가
- node_ws_client.ts에 TCP 클라이언트 통합 기능 추가
- message_common_pb.ts 프로토콜 버퍼 코드 재생성
- 크로스테스트 클라이언트 파일들 경로 업데이트
- 테스트 파일들 설정 업데이트
- 문서(PORTING_GUIDE, README) 업데이트
This commit is contained in:
toki 2026-05-20 11:22:04 +09:00
parent 002d612e7b
commit 5a2a21e75a
28 changed files with 891 additions and 367 deletions

View file

@ -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) 방지에 유의한다

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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();

View file

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

View file

@ -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<T extends Message = Message> = GenMessage<T>;
export type MessageParser<T extends Message = Message> = ((data: Uint8Array) => T) & {
schema?: MessageType<T>;
};

View file

@ -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<void> {
const data = toBinary(PacketBaseSchema, base);
await new Promise<void>((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<NodeWsClient> {
return new Promise<NodeWsClient>((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<NodeWsClient> {
return new Promise<NodeWsClient>((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<net.Socket> {
return new Promise<net.Socket>((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<tls.TLSSocket> {
return new Promise<tls.TLSSocket>((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<void> {
if (ws.readyState === WebSocket.CLOSED) {
async function connectNodeWebSocket(
socket: NodeSocket,
host: string,
port: number,
path: string,
intervalSec: number,
waitSec: number,
parserMap: ParserMap,
): Promise<NodeWsClient> {
return new Promise<NodeWsClient>((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<string, string>();
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<void> {
if (ws.readyState === NodeWebSocket.CLOSED) {
return;
}
@ -139,7 +532,7 @@ async function closeWebSocket(ws: WebSocket): Promise<void> {
ws.once("close", onDone);
ws.once("error", onDone);
if (ws.readyState === WebSocket.CLOSING) {
if (ws.readyState === NodeWebSocket.CLOSING) {
return;
}
try {

View file

@ -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<NodeWsClient>();
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,73 +28,46 @@ export class NodeWsServer {
}
start(): Promise<void> {
if (this.wss !== null) {
if (this.server !== null) {
return Promise.resolve();
}
return new Promise<void>((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<void> {
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<void>((resolve, reject) => {
wss.close((err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
const closeHttpsServer =
httpsServer === null
? Promise.resolve()
: new Promise<void>((resolve, reject) => {
httpsServer.close((err) => {
server.close((err) => {
if (err) {
reject(err);
return;
@ -106,7 +78,6 @@ export class NodeWsServer {
await Promise.allSettled(clients.map(async (client) => client.close()));
await closeServer;
await closeHttpsServer;
}
async broadcast(msg: Message): Promise<void> {
@ -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(() => {

View file

@ -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<TypeName extends string = string> {
readonly $typeName: TypeName;
}
/**
* Describes the file message_common.proto.
*/
export const file_message_common: GenFile = /*@__PURE__*/
fileDesc("ChRtZXNzYWdlX2NvbW1vbi5wcm90byJSCgpQYWNrZXRCYXNlEhAKCHR5cGVOYW1lGAEgASgJEg0KBW5vbmNlGAIgASgFEgwKBGRhdGEYAyABKAwSFQoNcmVzcG9uc2VOb25jZRgEIAEoBSILCglIZWFydEJlYXQiKgoIVGVzdERhdGESDQoFaW5kZXgYASABKAUSDwoHbWVzc2FnZRgCIAEoCWIGcHJvdG8z");
export type MessageInit<T extends Message> = Partial<Omit<T, "$typeName">>;
export interface MessageType<T extends Message = Message> {
readonly typeName: T["$typeName"];
create(value?: MessageInit<T>): 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<PacketBase> = /*@__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<HeartBeat> = /*@__PURE__*/
messageDesc(file_message_common, 1);
/**
* @generated from message TestData
*/
export type TestData = Message<"TestData"> & {
/**
* @generated from field: int32 index = 1;
*/
index: number;
/**
* @generated from field: string message = 2;
*/
message: string;
};
/**
* Describes the message TestData.
* Use `create(TestDataSchema)` to create a new message.
*/
export const TestDataSchema: GenMessage<TestData> = /*@__PURE__*/
messageDesc(file_message_common, 2);
export const PacketBaseSchema: MessageType<PacketBase> = {
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<HeartBeat> = {
typeName: "HeartBeat",
create() {
return { $typeName: "HeartBeat" };
},
fromBinary(data) {
skipUnknownMessage(data);
return this.create();
},
toBinary() {
return new Uint8Array();
},
};
export const TestDataSchema: MessageType<TestData> = {
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<T extends Message>(schema: MessageType<T>, value?: MessageInit<T>): T {
return schema.create(value);
}
export function fromBinary<T extends Message>(schema: MessageType<T>, data: Uint8Array): T {
return schema.fromBinary(data);
}
export function toBinary<T extends Message>(schema: MessageType<T>, 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();

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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