due-client-cocos 是面向 Cocos Creator 3.8(最新稳定版)的客户端库,实现了 due 游戏服务器框架的 WS 二进制通信协议与会话安全(security),方便在 Cocos Creator 项目中快速接入 due 网关服务器。
- 💡 简单性:核心仅
packer、client与security三个模块,无任何第三方依赖,密码学算法均为纯 TypeScript 实现。 - 🚀 高性能:采用原生
ArrayBuffer+DataView直接读写二进制,无多余拷贝与序列化开销。 - 🧊 标准化:遵循 due 框架标准打包协议,服务端无需改动。
✈️ 跨平台:兼容浏览器、微信小游戏、原生(Android/iOS)等 Cocos Creator 支持的全部平台。- 🔑 平滑性:内置心跳保活、断线重连,连接状态管理完善。
- 🔒 安全性:适配 due 会话安全(security)握手,支持服务端签名校验、X25519 密钥交换与 AEAD 加密。
- 🧰 易调试:附带 due 协议模拟服务器、TCP 桥接器与端到端测试脚本,无需真实网关即可联调。
- 连接:基于 WebSocket 的客户端连接,支持自定义连接地址与打包器。
- 打包:支持大小端字节序、可变长度的 route/seq 编码,以及心跳帧、握手帧编解码。
- 消息:支持异步消息
send与同步请求响应request(需服务器开启 seq 支持)。 - 心跳:可配置心跳间隔,接收服务器心跳携带的时间戳。
- 安全:可开启会话安全,握手后所有业务消息自动加解密,并校验服务端签名与消息重放。
- 回调:提供连接、断开、接收、错误、心跳五类钩子函数。
- 重连:断开后可一键重连,演示组件内置自动重连逻辑。
将本仓库的 assets/scripts 目录拷贝到你的 Cocos Creator 3.8 项目的 assets 下即可,无需额外安装依赖:
cp -r assets/scripts /path/to/your-cocos-project/assets/scripts只拷贝
assets/scripts。仓库根目录的tsconfig.json、types/cc.d.ts仅用于本仓库独立类型检查,不要拷贝到业务工程(业务工程使用 Cocos 编辑器自带的cc类型)。
目录结构:
assets/scripts/
├── network/
│ ├── packer.ts # 二进制打包器(size/header/route/seq/buffer,含握手帧)
│ ├── client.ts # WebSocket 客户端(连接/心跳/请求响应/安全会话)
│ ├── security/ # 会话安全(按需动态加载)
│ │ ├── handshake.ts # ClientHello / ServerHello / Finished、会话加解密
│ │ ├── verifier.ts # RSA / ECDSA 服务端签名校验
│ │ ├── pem.ts # PEM/DER 公钥解析
│ │ ├── aead.ts # AES-256-GCM、ChaCha20-Poly1305
│ │ ├── x25519.ts # X25519 密钥交换
│ │ ├── digest.ts # 签名哈希(SHA-1 / SHA-224 / SHA-256 / SHA-384 / SHA-512)
│ │ ├── sha256.ts # SHA-256 / HMAC / HKDF
│ │ └── bytes.ts # 字节工具
│ └── index.ts # 模块导出
└── NetworkTest.ts # 演示组件(挂载到节点即可使用)
| 项目 | 要求 | 说明 |
|---|---|---|
| Cocos Creator | 3.8.x | 使用原生 WebSocket、ArrayBuffer、DataView |
| TypeScript target | ES2020 及以上 | 会话安全使用 BigInt 字面量;低于 ES2020 会报 TS2737 |
| 装饰器 | experimentalDecorators: true |
Cocos 默认已开启 |
| 随机数 | crypto.getRandomValues 或注入 security.random |
仅开启会话安全时需要 |
| 文本编解码 | TextEncoder / TextDecoder |
原生平台建议提供 polyfill(见第 10 节) |
未开启
security时,security模块不会被加载,因此不会触发 BigInt 运行时依赖;但 TypeScript 仍会编译该目录,所以 target 需为 ES2020。
在场景中创建空节点并挂载 NetworkTest 组件(可选择性拖入一个 Label 用于显示状态),或在代码中直接调用:
import { Client, Packer } from './network';
// 请求数据包
const buffer = new TextEncoder().encode(JSON.stringify({
message: 'hello server'
}));
// 创建客户端
const client = new Client({
url: 'ws://127.0.0.1:3533',
heartbeat: 10000,
packer: new Packer({
byteOrder: 'big',
seqBytes: 2,
routeBytes: 2,
bufferBytes: 5000
})
});
// 监听连接
client.onConnect((c) => {
console.log('connect success');
// 发送异步消息
c.send({ route: 1, buffer });
// 发送同步请求,需要客户端和服务器同时开启seq配置
c.request(2, buffer, 2000).then((message) => {
console.log('response message: ', new TextDecoder().decode(message.buffer));
}).catch(() => {
console.log('reject');
});
});
// 监听连接断开
client.onDisconnect((c) => {
console.log('disconnect success');
// 重新连接
c.connect();
});
// 监听接收到消息
client.onReceive((c, message) => {
console.log('receive message: ', new TextDecoder().decode(message.buffer));
});
// 监听连接错误
client.onError((c) => {
console.log('connect error');
});
// 监听服务器心跳附带的服务器时间戳
// millisecond 为服务器时间戳(毫秒),可用于计算客户端与服务端的时间偏移
client.onHeartbeat((c, millisecond) => {
if (millisecond) {
// 打印服务器时间戳
console.log('server heartbeat timestamp: ', millisecond);
// 计算客户端与服务端的时间偏移(毫秒)
const offset = millisecond - Date.now();
console.log('client-server time offset: ', offset, 'ms');
}
});
// 建立连接
client.connect();网关侧通过 WithSessionSecurity 开启会话安全:
gate := gate.NewGate(
gate.WithServer(ws.NewServer()),
gate.WithLocator(redis.NewLocator()),
gate.WithRegistry(nacos.NewRegistry()),
// 开启会话安全:signer 用于签发 ServerHello
gate.WithSessionSecurity(ecc.NewSigner()),
// 可选:握手超时,默认 5s
gate.WithSessionSecurityHandshakeTimeout(5*time.Second),
)ecc.NewSigner()/rsa.NewSigner()从[crypto.ecc.signer]/[crypto.rsa.signer]读取密钥;- 默认签名哈希为 SHA-256;RSA 默认 PSS 填充,也支持 PKCS#1 v1.5;ECDSA 签名为
r<delimiter>s十进制文本(delimiter默认空格);详细支持范围见 5.2。 - 客户端只需持有与网关
privateKey配对的公钥(如网关的resource/key.pub.pem)。
客户端配置服务端公钥对应的校验器后,握手成功才会触发 onConnect,业务消息自动加解密:
import { Client, Packer } from './network';
import { Verifier } from './network/security';
const client = new Client({
url: 'ws://127.0.0.1:3533',
heartbeat: 10000,
packer: new Packer(),
security: {
// 服务端签名校验器:Verifier.ecdsa(publicKey, hash?, delimiter?)
// 公钥支持 PKCS#1(RSA PUBLIC KEY)与 PKIX(PUBLIC KEY / ECDSA PUBLIC KEY)
verifier: Verifier.ecdsa(`-----BEGIN ECDSA PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE...
-----END ECDSA PUBLIC KEY-----`),
// 可选:协商套件,1=AES-256-GCM,2=ChaCha20-Poly1305;默认两者都参与协商
suites: [1, 2],
// 可选:握手超时(毫秒),默认 5000,小于等于 0 表示不限制
handshakeTimeout: 5000,
// 可选:自定义随机字节源(原生平台未提供 crypto.getRandomValues 时注入)
// random: (length) => fillSecureRandom(length),
},
});
client.onConnect(() => {
// 握手完成后 send/request 会自动加密
client.send({ route: 1, buffer: new TextEncoder().encode('hello secure server') });
});
client.connect();- 网关使用 RSA 签名时,改为
verifier: Verifier.rsa(pem)(参数为publicKey, hash?, pss?)。 - 服务端签名默认使用 SHA-256;RSA 默认 PSS 填充(
Verifier.rsa(pem, hash, false)切换 PKCS#1 v1.5),ECDSA 支持 P-256/P-384/P-521;哈希算法需与服务端hash配置一致。 - 握手期间不发送心跳;握手完成后心跳帧仍为明文,与 due 保持一致。
- 握手失败会关闭连接,并触发
onError(随后触发onDisconnect);每次connect()都会重新握手,会话密钥不复用。
校验器创建与自定义:Verifier 的参数与 Unity 版一致(hash 默认 sha256,pss 默认 true,delimiter 默认空格):
import { Verifier, type HandshakeVerifier } from './network/security';
const ecdsa = Verifier.ecdsa(publicKeyPem); // SHA-256 + 空格分隔
const rsa = Verifier.rsa(publicKeyPem); // SHA-256 + PSS
const rsa512 = Verifier.rsa(publicKeyPem, 'sha512', false); // SHA-512 + PKCS#1 v1.5
// 完全自定义校验逻辑(必须同步返回 boolean)
const custom: HandshakeVerifier = (data, signature) => true;协商套件:
| ID | 套件 | 说明 |
|---|---|---|
| 1 | AES-256-GCM | 默认首选 |
| 2 | ChaCha20-Poly1305 | 无 AES 硬件加速时更稳 |
网关未显式指定套件时,双方在 [1, 2] 中协商,服务端按自身偏好选择第一个交集;客户端 suites 需与服务端存在交集,否则握手失败。
内置校验器覆盖 RSA 与 ECDSA,API 与 Unity 版保持一致:Verifier.ecdsa(publicKey, hash?, delimiter?)、Verifier.rsa(publicKey, hash?, pss?),返回的校验函数通过 security.verifier 传入。
RSA
| 项目 | 支持情况 |
|---|---|
| 哈希算法 | sha1 / sha224 / sha256(默认)/ sha384 / sha512 |
| 填充方式 | PSS(默认,salt 长度 = 哈希长度)、PKCS#1 v1.5(pss: false) |
| 公钥格式 | PKCS#1(RSA PUBLIC KEY)、PKIX/SPKI(PUBLIC KEY) |
| 密钥长度 | 任意;按模数实际位长计算 emBits,兼容非字节对齐模数 |
import { Verifier } from './network/security';
Verifier.rsa(pem); // 默认 SHA-256 + PSS
Verifier.rsa(pem, 'sha256', false); // PKCS#1 v1.5
Verifier.rsa(pem, 'sha512'); // SHA-512 + PSS
Verifier.rsa(pem, 'sha384', false); // SHA-384 + PKCS#1 v1.5ECDSA
| 项目 | 支持情况 |
|---|---|
| 曲线 | NIST P-256、P-384、P-521 |
| 哈希算法 | sha1 / sha224 / sha256(默认)/ sha384 / sha512;哈希长于曲线阶时按 Go 规则截断 |
| 签名格式 | due 的十进制文本 r<delimiter>s,delimiter 默认空格 |
| 公钥格式 | PKIX/SPKI(PUBLIC KEY / ECDSA PUBLIC KEY),非压缩点 |
import { Verifier } from './network/security';
Verifier.ecdsa(pem); // 默认 SHA-256 + 空格分隔
Verifier.ecdsa(pem, 'sha384'); // SHA-384
Verifier.ecdsa(pem, 'sha256', ','); // 自定义分隔符与服务端配置对照
| Verifier 参数 | due 服务端配置 |
|---|---|
hash(第 2 个参数) |
[crypto.ecc.signer] hash / [crypto.rsa.signer] hash |
delimiter(第 3 个参数,ECDSA) |
[crypto.ecc.signer] delimiter |
pss(第 3 个参数,RSA) |
[crypto.rsa.signer] padding(PSS → true,PKCS → false) |
自定义校验函数(与 Unity 的 HandshakeVerifier 委托一致)
import type { HandshakeVerifier } from './network/security';
const custom: HandshakeVerifier = (data: Uint8Array, signature: Uint8Array): boolean => {
// 自定义校验逻辑
return true;
};公钥入参支持 PEM 字符串、base64 字符串、DER Uint8Array,或已解析的 PublicKey 对象。
暂不支持(可通过 security.verifier 自行实现):
- RSA-PSS 的非标准 salt 长度(固定为哈希长度,与 Go 的
PSSSaltLengthEqualsHash一致) - Ed25519、secp256k1 等其它算法/曲线
- ECDSA 的二进制
r||s、DER、base64 签名(仅支持 due 的十进制文本格式) - X.509 证书形式的公钥(仅支持裸公钥)
- 公钥文件路径(仅接受 PEM/base64/DER 内容,请由业务层读取文件后传入)
- 异步校验器(
verify必须同步返回boolean)
客户端 Packer 必须与网关的 [packet] 配置一致,否则会出现解析错位或 message too large:
| 网关配置 | 客户端字段 | 可选值 |
|---|---|---|
byteOrder |
byteOrder |
big(默认)/ little |
routeBytes |
routeBytes |
1 / 2 / 4 |
seqBytes |
seqBytes |
0 / 1 / 2 / 4 |
bufferBytes |
bufferBytes |
与网关相同的消息上限 |
例如 due-benchmark/cluster/ws 的网关配置为 routeBytes = 4、seqBytes = 4,客户端需使用:
new Packer({ byteOrder: 'big', routeBytes: 4, seqBytes: 4, bufferBytes: 5000 })send()返回boolean:未连接或未完成安全握手时返回false。route/seq溢出、buffer超过bufferBytes会同步抛出异常,属于调用方配置错误。request()会按 route 维护 seq,并按 seq 匹配响应;网关需开启 seq(seqBytes > 0),超时时间小于等于 0 时不启用超时。
import { _decorator, Component } from 'cc';
import { Client, Message, Packer } from './network';
import { Verifier } from './network/security';
const { ccclass, property } = _decorator;
@ccclass('DueClient')
export class DueClient extends Component {
@property
url = 'ws://127.0.0.1:3553';
@property({ multiline: true })
publicKey = '';
private client: Client | null = null;
private retryTimer: any = null;
start() {
this.client = new Client({
url: this.url,
heartbeat: 10000,
packer: new Packer({ byteOrder: 'big', routeBytes: 2, seqBytes: 2, bufferBytes: 5000 }),
security: {
// 网关使用 RSA 签名时改为 Verifier.rsa(this.publicKey)
verifier: Verifier.ecdsa(this.publicKey),
handshakeTimeout: 5000,
},
});
this.client.onConnect(() => {
console.log('[DueClient] 握手完成,已连接');
});
this.client.onDisconnect(() => {
console.log('[DueClient] 连接断开,3s 后重连');
this.reconnect();
});
this.client.onError(() => {
console.error('[DueClient] 连接/握手失败');
});
this.client.onReceive((_client, message) => this.onMessage(message));
this.client.connect();
}
/** 发送异步消息(握手完成前返回 false) */
public sendGreet(message: string): boolean {
if (!this.client) {
return false;
}
const buffer = new TextEncoder().encode(JSON.stringify({ message, sentAt: Date.now() }));
return this.client.send({ route: 1, buffer });
}
/** 发送请求并等待响应(需要网关开启 seq) */
public greet(message: string): Promise<Message> {
if (!this.client) {
return Promise.reject(new Error('client not ready'));
}
const buffer = new TextEncoder().encode(JSON.stringify({ message, sentAt: Date.now() }));
return this.client.request(1, buffer, 3000);
}
private onMessage(message: Message) {
const text = message.buffer ? new TextDecoder().decode(message.buffer) : '';
console.log(`[DueClient] route=${message.route} seq=${message.seq} body=${text}`);
}
private reconnect() {
this.retryTimer && clearTimeout(this.retryTimer);
this.retryTimer = setTimeout(() => {
this.retryTimer = null;
this.client?.connect();
}, 3000);
}
onDestroy() {
this.retryTimer && clearTimeout(this.retryTimer);
this.client?.disconnect();
this.client = null;
}
}挂载该组件到场景节点,填写 url 与 publicKey 即可(网关使用 RSA 时把 Verifier.ecdsa 换成 Verifier.rsa)。
采用大端字节序(可配置),数据帧格式:
| 字段 | 字节数 | 说明 |
|---|---|---|
| size | 4 | 后续数据总长度 |
| header | 1 | 最高位为 1 表示心跳包,最低位为 1 表示安全握手帧 |
| route | 1/2/4 | 消息路由 |
| seq | 0/1/2/4 | 消息序列号(request/response 需要) |
| buffer | 变长 | 消息负载 |
| timestamp | 8 | 仅服务器心跳包携带(毫秒时间戳) |
安全握手帧不携带 route 与 seq:
| 字段 | 字节数 | 说明 |
|---|---|---|
| size | 4 | 1 + 握手载荷长度 |
| header | 1 | 固定为 0x01 |
| payload | 变长 | version(1) | type(1) | length(2) | body |
开启会话安全后,业务帧的 buffer 为加密封包:
| 字段 | 字节数 | 说明 |
|---|---|---|
| epoch | 1 | 密钥纪元,当前为 1 |
| counter | 8 | 发送计数器,大端序 |
| ciphertext | 变长 | AEAD 密文 |
| tag | 16 | AEAD 认证标签 |
握手流程:ClientHello(随机数 + X25519 公钥 + 套件列表)→ ServerHello(套件 + 随机数 + 公钥 + 签名 + 确认 MAC)→ Finished(客户端确认 MAC)。
- 会话密钥:X25519 共享密钥经 HKDF-SHA256 派生;
- AAD:
version(1) | direction(1) | epoch(1) | route(4, 大端) | seq(4, 大端); - 接收端带 64 位重放窗口;
- 客户端按
heartbeat间隔发送明文心跳;网关heartbeatTime = true时心跳携带 8 字节服务器毫秒时间戳。
客户端只负责二进制传输,业务层可自行选择序列化方式:
// JSON
const buffer = new TextEncoder().encode(JSON.stringify(payload));
const text = new TextDecoder().decode(message.buffer);
// 或使用 protobuf / msgpack 等库生成 Uint8Array约定:
route为业务路由,需与 node 侧proxy.Router().AddRouteHandler(route, ...)一致;request/response依赖seq回传,node 侧使用ctx.Response(...)即可;- 单条消息负载不能超过
bufferBytes(开启 security 时还会额外增加 25 字节封包开销)。
建议在 onDisconnect 中做退避重连,并对重复的 onDisconnect/onError 做去重:
let retry = 0;
client.onDisconnect(() => {
retry++;
const delay = Math.min(1000 * retry, 10000);
setTimeout(() => client.connect(), delay);
});
client.onConnect(() => {
retry = 0;
});注意:握手失败会同时触发 onError 与 onDisconnect,重连逻辑需幂等;disconnect() 会立即清理会话状态。
模拟服务器复用了同一份 Packer,保证协议一致,无需真实网关即可对客户端做端到端测试:
cd tools
npm install
# 运行单元测试(密码学向量)与端到端测试(Packer + Client + 安全会话)
npm test
# 单独启动模拟服务器(默认 3533,可用 PORT 覆盖)
npm run server启动模拟服务器后,可在浏览器/Cocos 预览中连接 ws://127.0.0.1:3533 进行联调。
本客户端仅实现 WebSocket 传输。若网关使用 TCP(如 due-benchmark/cluster/tcp),生产环境建议改用 WS 网关;本地联调可先启动自带的字节桥接器:
cd tools
# 终端 1:启动 TCP -> WebSocket 桥接器(默认 127.0.0.1:3553 -> ws://127.0.0.1:3554)
npm run bridge
# 终端 2:使用当前客户端连接真实网关并发送 greet(route=1)
npm run test:gate桥接器与测试脚本支持环境变量:TARGET、PORT、GATE_URL、KEY_FILE、ROUTE、MESSAGE、SIZE、COUNT、SUITES。
若网关本身使用 WebSocket(如 due-benchmark/cluster/ws),无需桥接器,直接指定地址与公钥即可:
$env:GATE_URL='ws://127.0.0.1:3553'
$env:KEY_FILE='E:\workspace\dobyte\due-benchmark\cluster\ws\gate\resource\key.pub.pem'
$env:SIZE='4096'; $env:COUNT='20'
npm run test:gate- 浏览器与微信小游戏均支持
WebSocket、crypto.getRandomValues; - 微信小游戏需在平台配置 socket 合法域名(生产环境使用
wss://)。
| 事项 | 说明 |
|---|---|
| WebSocket | Cocos 原生层已提供 |
| BigInt | 需要 iOS 14+ / 现代 Android 运行时;低版本请升级或使用不开启 security 的包 |
| 随机数 | globalThis.crypto 可能不存在,需注入 security.random |
| TextEncoder/Decoder | 建议引入 polyfill 或替换为自定义 UTF-8 编解码 |
随机数注入示例:
const client = new Client({
url: 'wss://game.example.com',
heartbeat: 10000,
packer: new Packer(),
security: {
// publicKey 为服务端公钥 PEM 内容
verifier: Verifier.ecdsa(publicKey),
random: (length: number) => {
const bytes = new Uint8Array(length);
// 调用平台原生安全随机数接口填充 bytes
// 必须使用密码学安全随机源,禁止 Math.random
nativeSecureRandomFill(bytes);
return bytes;
},
},
});业务工程 tsconfig.json 需要 target: "ES2020"(或更高)以支持 BigInt:
{
"compilerOptions": {
"target": "ES2020",
"experimentalDecorators": true
}
}| 现象 | 原因与处理 |
|---|---|
security: invalid server signature |
公钥与网关私钥不匹配;网关签名哈希与客户端 hash 配置不一致;ECDSA delimiter 与客户端不一致 |
security: verifier is required |
未配置签名校验器;使用 Verifier.ecdsa(pem) 或 Verifier.rsa(pem) 创建后传入 |
security: handshake timeout |
网关未开启 WithSessionSecurity;连接到了 TCP 而不是 WS;公钥解析失败 |
security: session is not established |
握手完成前发送了业务消息(send 返回 false);等待 onConnect 后再发送 |
握手失败后触发 onError + onDisconnect |
预期行为;重连逻辑需做幂等处理 |
route overflow / seq overflow |
route / seq 超出 Packer 的字节范围(2 字节最大 32767) |
message too large |
负载超过 bufferBytes;开启 security 时还会额外增加 25 字节封包开销 |
| 收到乱码或解析失败 | 客户端与服务端 [packet] 配置不一致(byteOrder / routeBytes / seqBytes) |
TS2737 |
工程 target 低于 ES2020 |
| 原生平台握手立即失败 | 缺少 crypto.getRandomValues,注入 security.random |
| 连接成功但收不到响应 | node 未注册对应 route;或网关与 node 未注册到同一注册中心 |
- 客户端校验服务端签名与确认 MAC,防止中间人伪造网关;
- 业务消息使用 AEAD 加密,AAD 绑定 route/seq,篡改或重放会被拒绝;
- 签名哈希由
hash选项配置(默认 SHA-256),需与服务端[crypto.*.signer] hash保持一致; - 当前协议 epoch 固定为 1,暂不支持会话中密钥轮换;
- 握手帧、心跳帧为明文;握手帧本身不携带任何业务数据;
- 客户端仅验证服务端身份,不提供服务端对客户端的证书认证能力。
NetworkTest.ts依赖 Cocos Creator 的cc模块;仓库根目录提供了types/cc.d.ts最小声明与tsconfig.json,因此在仓库内独立类型检查(tsc -p tsconfig.json)不会报错。将assets/scripts拷贝进 Cocos 工程后会使用编辑器自带的完整cc类型,types/与根目录tsconfig.json不会随拷贝进入工程。
仓库内置 GitHub Actions 自动发布流程:.github/workflows/release.yml。
- 触发方式:
- 推送
v*标签(如v1.0.1); - 或在 Actions 页面手动触发
Release工作流并填写标签。
- 推送
- 发布流程:安装
tools依赖 → TypeScript 类型检查 → 运行全部测试 → 打包assets/scripts与 README → 创建或更新 GitHub Release。 - 发布产物:
due-client-cocos-<tag>.zip,解压后即为可拷贝到 Cocos 工程的scripts目录。
git tag v1.0.1
git push origin v1.0.1Cocos 客户端以源码目录分发,不发布到 npm;工作流使用仓库默认的
GITHUB_TOKEN创建 Release,无需额外 Secrets。