first commit

This commit is contained in:
douboer
2026-03-21 18:57:10 +08:00
commit c49aa1a5e9
570 changed files with 107167 additions and 0 deletions

44
apps/gateway/.env.example Normal file
View File

@@ -0,0 +1,44 @@
PORT=8787
HOST=0.0.0.0
GATEWAY_TOKEN=remoteconn-dev-token
CORS_ORIGIN=*
DEBUG_IO_HEX=0
# 豆包 ASR语音输入
ASR_PROVIDER=volcengine
ASR_APP_ID=
ASR_ACCESS_TOKEN=
ASR_SECRET_KEY=
ASR_RESOURCE_ID=volc.seedasr.sauc.duration
# 可选:当前 v3/sauc/bigmodel_async 默认可不填
ASR_CLUSTER=
ASR_WS_URL=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async
# 生产建议 0result 帧不携带上游原始 payload降低下行带宽与前端解析开销
ASR_INCLUDE_RAW_RESULT=0
# 单连接内空文本告警上限,避免上游异常时日志风暴
ASR_EMPTY_TEXT_WARN_LIMIT=3
# 小程序 Codex 语音播报TTS
# 如使用火山引擎 HTTP Chunked/SSE 单向流式 V3请把 TTS_PROVIDER 改为 volcengine。
# 注意:这里填写的是 Access Token虽然请求头字段名仍叫 `X-Api-Access-Key`。
# TTS_RESOURCE_ID 还需要和代码里映射的豆包 1.0 speaker 保持同代匹配。
TTS_PROVIDER=tencent
TTS_APP_ID=
TTS_ACCESS_TOKEN=
TTS_SECRET_ID=
TTS_SECRET_KEY=
TTS_REGION=ap-guangzhou
TTS_CLUSTER=volcano_tts
TTS_RESOURCE_ID=volc.service_type.10029
TTS_VOICE_DEFAULT=female_v1
TTS_SPEED_DEFAULT=1
TTS_TIMEOUT_MS=30000
TTS_CACHE_FILE_MAX_BYTES=8388608
# 小程序配置同步Gateway + SQLite
MINIPROGRAM_APP_ID=
MINIPROGRAM_APP_SECRET=
SYNC_SQLITE_PATH=data/remoteconn-sync.db
SYNC_SECRET_CURRENT=
SYNC_SECRET_VERSION=1
SYNC_TOKEN_TTL_SEC=604800

View File

@@ -0,0 +1,18 @@
{
"GATEWAY_PORT": 8787,
"GATEWAY_HOST": "0.0.0.0",
"GATEWAY_CORS_ORIGIN": "*",
"GATEWAY_LOG_LEVEL": "info",
"GATEWAY_DEBUG_IO_HEX": 0,
"RATE_LIMIT_POINTS": 30,
"RATE_LIMIT_DURATION_SEC": 60,
"ASSIST_TXN_TTL_MS": 30000,
"ASSIST_TXN_CACHE_LIMIT": 512,
"SSH_READY_TIMEOUT_MS": 15000,
"SSH_KEEPALIVE_INTERVAL_MS": 10000,
"SSH_KEEPALIVE_COUNT_MAX": 3,
"TERMINAL_RESUME_GRACE_DEFAULT_MS": 20000,
"TERMINAL_RESUME_GRACE_MAX_MS": 3600000,
"PLUGIN_ONLOAD_TIMEOUT_MS": 3000,
"PLUGIN_ONUNLOAD_TIMEOUT_MS": 3000
}

35
apps/gateway/package.json Normal file
View File

@@ -0,0 +1,35 @@
{
"name": "@remoteconn/gateway",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"scripts": {
"start": "tsx src/index.ts",
"dev": "tsx watch src/index.ts",
"build": "tsc -p tsconfig.json",
"typecheck": "tsc -p tsconfig.json --noEmit",
"lint": "eslint src --ext .ts",
"test": "vitest run",
"bench": "tsx src/bench/gatewayPerfBench.ts",
"bench:ttyd": "tsx src/bench/ttydPerfBench.ts"
},
"dependencies": {
"@remoteconn/shared": "1.0.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"helmet": "^8.1.0",
"pino": "^9.9.0",
"pino-http": "^11.0.0",
"rate-limiter-flexible": "^8.1.0",
"ssh2": "^1.17.0",
"ws": "^8.18.3",
"zod": "^4.1.8"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.3",
"@types/ssh2": "^1.15.5",
"@types/ws": "^8.18.1",
"tsx": "^4.20.5"
}
}

View File

@@ -0,0 +1,307 @@
import { once } from "node:events";
import { readFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AddressInfo } from "node:net";
import { WebSocket } from "ws";
interface BenchOptions {
gatewayUrl?: string;
gatewayToken?: string;
sshHost: string;
sshPort: number;
sshUsername: string;
privateKeyPath: string;
iterations: number;
payloadKb: number;
timeoutMs: number;
}
interface BenchStats {
min: number;
max: number;
avg: number;
p50: number;
p95: number;
}
/**
* 将 `~` 展开为用户目录,方便通过环境变量传入私钥路径。
*/
function expandHome(inputPath: string): string {
if (!inputPath.startsWith("~/")) {
return inputPath;
}
return path.join(os.homedir(), inputPath.slice(2));
}
/**
* 从环境变量读取压测参数,保证脚本可在 CI/本地复用。
*/
function loadOptions(): BenchOptions {
return {
gatewayUrl: process.env.BENCH_GATEWAY_URL,
gatewayToken: process.env.BENCH_GATEWAY_TOKEN,
sshHost: process.env.BENCH_SSH_HOST ?? "127.0.0.1",
sshPort: Number(process.env.BENCH_SSH_PORT ?? "22"),
sshUsername: process.env.BENCH_SSH_USER ?? process.env.USER ?? "root",
privateKeyPath: expandHome(process.env.BENCH_PRIVATE_KEY ?? "~/.ssh/id_ed25519"),
iterations: Number(process.env.BENCH_ITERATIONS ?? "30"),
payloadKb: Number(process.env.BENCH_PAYLOAD_KB ?? "1024"),
timeoutMs: Number(process.env.BENCH_TIMEOUT_MS ?? "8000")
};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 循环轮询条件,避免把一堆一次性监听器挂到 WS 上导致泄漏。
*/
async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (check()) return;
await delay(10);
}
throw new Error(`等待超时: ${label}`);
}
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const pos = (sorted.length - 1) * p;
const base = Math.floor(pos);
const rest = pos - base;
const baseValue = sorted[base] ?? 0;
const nextValue = sorted[base + 1];
if (nextValue === undefined) {
return baseValue;
}
return baseValue + rest * (nextValue - baseValue);
}
function buildStats(samples: number[]): BenchStats {
const sorted = [...samples].sort((a, b) => a - b);
const sum = sorted.reduce((acc, item) => acc + item, 0);
return {
min: sorted[0] ?? 0,
max: sorted[sorted.length - 1] ?? 0,
avg: sorted.length > 0 ? sum / sorted.length : 0,
p50: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95)
};
}
function formatMs(value: number): string {
return `${value.toFixed(2)}ms`;
}
function formatMbps(bytes: number, ms: number): string {
if (ms <= 0) return "0.00 MB/s";
const sec = ms / 1000;
const mb = bytes / 1024 / 1024;
return `${(mb / sec).toFixed(2)} MB/s`;
}
/**
* 本地未提供网关地址时,启动一份临时网关实例用于基线压测。
*/
async function startLocalGateway(): Promise<{ url: string; token: string; close: () => Promise<void> }> {
const token = `bench-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
process.env.HOST = "127.0.0.1";
process.env.PORT = "0";
process.env.GATEWAY_TOKEN = token;
process.env.CORS_ORIGIN = "*";
const { createGatewayServer } = await import("../server");
const server = createGatewayServer();
server.listen(0, "127.0.0.1");
await once(server, "listening");
const address = server.address() as AddressInfo;
return {
url: `ws://127.0.0.1:${address.port}`,
token,
close: async () => {
server.close();
await once(server, "close");
}
};
}
async function main(): Promise<void> {
const options = loadOptions();
const privateKey = await readFile(options.privateKeyPath, "utf8");
const cleanupTasks: Array<() => Promise<void>> = [];
let gatewayUrl = options.gatewayUrl;
let gatewayToken = options.gatewayToken;
if (!gatewayUrl) {
const localGateway = await startLocalGateway();
gatewayUrl = localGateway.url;
gatewayToken = localGateway.token;
cleanupTasks.push(localGateway.close);
}
if (!gatewayToken) {
throw new Error("缺少网关 token请设置 BENCH_GATEWAY_TOKEN或让脚本自动启动本地网关");
}
const endpoint = `${gatewayUrl.replace(/\/$/, "")}/ws/terminal?token=${encodeURIComponent(gatewayToken)}`;
const ws = new WebSocket(endpoint);
let stdoutText = "";
let stdoutBytes = 0;
let connected = false;
let disconnectedReason = "";
let fatalError = "";
ws.on("message", (raw) => {
try {
const frame = JSON.parse(raw.toString()) as {
type: string;
payload?: { data?: string; action?: string; reason?: string; message?: string };
};
if (frame.type === "stdout") {
const data = frame.payload?.data ?? "";
stdoutText += data;
stdoutBytes += Buffer.byteLength(data, "utf8");
return;
}
if (frame.type === "control" && frame.payload?.action === "connected") {
connected = true;
return;
}
if (frame.type === "control" && frame.payload?.action === "disconnect") {
disconnectedReason = frame.payload?.reason ?? "unknown";
return;
}
if (frame.type === "error") {
fatalError = frame.payload?.message ?? "gateway error";
}
} catch {
fatalError = "网关返回了无法解析的消息";
}
});
ws.on("error", (error) => {
fatalError = String(error);
});
const connectedStartedAt = performance.now();
await once(ws, "open");
ws.send(
JSON.stringify({
type: "init",
payload: {
host: options.sshHost,
port: options.sshPort,
username: options.sshUsername,
credential: { type: "privateKey", privateKey },
pty: { cols: 140, rows: 40 }
}
})
);
await waitFor(() => connected || fatalError.length > 0, options.timeoutMs, "SSH 连接建立");
if (fatalError) {
throw new Error(fatalError);
}
const connectMs = performance.now() - connectedStartedAt;
// 关闭 TTY 回显,避免命令内容干扰 RTT 统计(否则 marker 可能因本地 echo 提前出现)。
ws.send(JSON.stringify({ type: "stdin", payload: { data: "stty -echo\n" } }));
await delay(120);
const rttSamples: number[] = [];
for (let index = 0; index < options.iterations; index += 1) {
const marker = `__RCBENCH_${Date.now()}_${index}__`;
const stdoutStart = stdoutText.length;
const startedAt = performance.now();
ws.send(JSON.stringify({ type: "stdin", payload: { data: `printf '${marker}\\n'\n` } }));
await waitFor(
() => stdoutText.slice(stdoutStart).includes(marker) || fatalError.length > 0,
options.timeoutMs,
`命令回显 RTT #${index + 1}`
);
if (fatalError) {
throw new Error(fatalError);
}
rttSamples.push(performance.now() - startedAt);
}
const payloadBytes = options.payloadKb * 1024;
const throughputStartBytes = stdoutBytes;
const throughputStartAt = performance.now();
ws.send(
JSON.stringify({
type: "stdin",
payload: {
data: `dd if=/dev/zero bs=1024 count=${options.payloadKb} 2>/dev/null | tr '\\0' 'x'\n`
}
})
);
await waitFor(
() => stdoutBytes - throughputStartBytes >= payloadBytes || fatalError.length > 0,
options.timeoutMs * 4,
"吞吐测试输出收集"
);
if (fatalError) {
throw new Error(fatalError);
}
const throughputMs = performance.now() - throughputStartAt;
ws.send(JSON.stringify({ type: "control", payload: { action: "disconnect" } }));
await delay(80);
ws.close();
const rtt = buildStats(rttSamples);
console.log("=== Gateway SSH2 基线压测结果 ===");
console.log(
JSON.stringify(
{
ts: new Date().toISOString(),
endpoint,
sshTarget: `${options.sshUsername}@${options.sshHost}:${options.sshPort}`,
iterations: options.iterations,
payloadKb: options.payloadKb,
connect: formatMs(connectMs),
rtt: {
min: formatMs(rtt.min),
p50: formatMs(rtt.p50),
p95: formatMs(rtt.p95),
max: formatMs(rtt.max),
avg: formatMs(rtt.avg)
},
throughput: {
bytes: payloadBytes,
cost: formatMs(throughputMs),
speed: formatMbps(payloadBytes, throughputMs)
},
disconnectReason: disconnectedReason || "client_disconnect"
},
null,
2
)
);
for (const task of cleanupTasks) {
await task();
}
}
main().catch((error) => {
console.error("[bench] 执行失败:", (error as Error).message);
process.exitCode = 1;
});

View File

@@ -0,0 +1,307 @@
import { spawn, type ChildProcessByStdio } from "node:child_process";
import type { Readable } from "node:stream";
import net from "node:net";
import { WebSocket, type RawData } from "ws";
interface BenchOptions {
ttydBin: string;
ttydHost: string;
ttydPort: number;
sshHost: string;
sshPort: number;
sshUsername: string;
sshIdentity?: string;
iterations: number;
payloadKb: number;
timeoutMs: number;
}
interface BenchStats {
min: number;
max: number;
avg: number;
p50: number;
p95: number;
}
/**
* 读取环境变量,统一与 gateway 基线脚本保持参数名兼容。
*/
function loadOptions(): BenchOptions {
const identity = process.env.BENCH_SSH_IDENTITY;
return {
ttydBin: process.env.BENCH_TTYD_BIN ?? "ttyd",
ttydHost: process.env.BENCH_TTYD_HOST ?? "127.0.0.1",
ttydPort: Number(process.env.BENCH_TTYD_PORT ?? "0"),
sshHost: process.env.BENCH_SSH_HOST ?? "127.0.0.1",
sshPort: Number(process.env.BENCH_SSH_PORT ?? "22"),
sshUsername: process.env.BENCH_SSH_USER ?? process.env.USER ?? "root",
sshIdentity: identity && identity.trim().length > 0 ? identity : undefined,
iterations: Number(process.env.BENCH_ITERATIONS ?? "30"),
payloadKb: Number(process.env.BENCH_PAYLOAD_KB ?? "1024"),
timeoutMs: Number(process.env.BENCH_TIMEOUT_MS ?? "8000")
};
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* 轮询等待状态变化,避免事件乱序导致监听器遗漏。
*/
async function waitFor(check: () => boolean, timeoutMs: number, label: string): Promise<void> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (check()) return;
await delay(10);
}
throw new Error(`等待超时: ${label}`);
}
async function pickFreePort(host: string): Promise<number> {
const server = net.createServer();
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(0, host, () => resolve());
});
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
await new Promise<void>((resolve) => server.close(() => resolve()));
if (!port) {
throw new Error("无法分配可用端口");
}
return port;
}
function percentile(sorted: number[], p: number): number {
if (sorted.length === 0) return 0;
const pos = (sorted.length - 1) * p;
const base = Math.floor(pos);
const rest = pos - base;
const baseValue = sorted[base] ?? 0;
const nextValue = sorted[base + 1];
if (nextValue === undefined) {
return baseValue;
}
return baseValue + rest * (nextValue - baseValue);
}
function buildStats(samples: number[]): BenchStats {
const sorted = [...samples].sort((a, b) => a - b);
const sum = sorted.reduce((acc, item) => acc + item, 0);
return {
min: sorted[0] ?? 0,
max: sorted[sorted.length - 1] ?? 0,
avg: sorted.length > 0 ? sum / sorted.length : 0,
p50: percentile(sorted, 0.5),
p95: percentile(sorted, 0.95)
};
}
function formatMs(value: number): string {
return `${value.toFixed(2)}ms`;
}
function formatMbps(bytes: number, ms: number): string {
if (ms <= 0) return "0.00 MB/s";
const sec = ms / 1000;
const mb = bytes / 1024 / 1024;
return `${(mb / sec).toFixed(2)} MB/s`;
}
/**
* 启动 ttyd 子进程命令行为ttyd -> ssh -> 目标 shell。
*/
async function startTtyd(options: BenchOptions): Promise<{ proc: ChildProcessByStdio<null, Readable, Readable>; port: number }> {
const port = options.ttydPort > 0 ? options.ttydPort : await pickFreePort(options.ttydHost);
const sshArgs = [
"-tt",
"-o",
"StrictHostKeyChecking=no",
"-o",
"UserKnownHostsFile=/dev/null",
"-o",
"LogLevel=ERROR"
];
if (options.sshIdentity) {
sshArgs.push("-i", options.sshIdentity);
}
sshArgs.push("-p", String(options.sshPort), `${options.sshUsername}@${options.sshHost}`);
const args = ["-i", options.ttydHost, "-p", String(port), "-W", "--", "ssh", ...sshArgs];
const proc = spawn(options.ttydBin, args, {
stdio: ["ignore", "pipe", "pipe"]
});
let stderrText = "";
proc.stderr.on("data", (chunk: Buffer) => {
stderrText += chunk.toString("utf8");
});
await waitFor(
() => stderrText.includes("Listening on port:") || proc.exitCode !== null,
4000,
"ttyd 启动"
);
if (proc.exitCode !== null) {
throw new Error(`ttyd 启动失败(exit=${proc.exitCode}): ${stderrText.trim()}`);
}
return { proc, port };
}
function sendTtydInput(ws: WebSocket, data: string): void {
ws.send(Buffer.concat([Buffer.from("0"), Buffer.from(data, "utf8")]));
}
function toBuffer(raw: RawData): Buffer {
if (Buffer.isBuffer(raw)) {
return raw;
}
if (raw instanceof ArrayBuffer) {
return Buffer.from(raw);
}
if (Array.isArray(raw)) {
return Buffer.concat(
raw.map((chunk) => {
if (Buffer.isBuffer(chunk)) {
return chunk;
}
return Buffer.from(chunk);
})
);
}
return Buffer.from(raw);
}
async function main(): Promise<void> {
const options = loadOptions();
const { proc, port } = await startTtyd(options);
const endpoint = `ws://${options.ttydHost}:${port}/ws`;
const ws = new WebSocket(endpoint, ["tty"]);
let stdoutText = "";
let stdoutBytes = 0;
let fatalError = "";
let opened = false;
ws.on("open", () => {
opened = true;
ws.send(JSON.stringify({ columns: 140, rows: 40 }));
});
ws.on("message", (raw, isBinary) => {
if (!isBinary) return;
const data = toBuffer(raw);
const cmd = String.fromCharCode(data[0] ?? 0);
if (cmd !== "0") {
return;
}
const text = data.slice(1).toString("utf8");
stdoutText += text;
stdoutBytes += Buffer.byteLength(text, "utf8");
});
ws.on("error", (error) => {
fatalError = String(error);
});
const connectStartedAt = performance.now();
await waitFor(() => opened || fatalError.length > 0, options.timeoutMs, "ttyd ws 连接");
if (fatalError) {
throw new Error(fatalError);
}
const markerInit = `__TTYD_INIT_${Date.now()}__`;
sendTtydInput(ws, `printf '${markerInit}\\n'\n`);
await waitFor(() => stdoutText.includes(markerInit) || fatalError.length > 0, options.timeoutMs, "初始就绪");
if (fatalError) {
throw new Error(fatalError);
}
const connectMs = performance.now() - connectStartedAt;
// 关闭回显,避免输入回显干扰 RTT 检测。
sendTtydInput(ws, "stty -echo\n");
await delay(120);
const rttSamples: number[] = [];
for (let index = 0; index < options.iterations; index += 1) {
const marker = `__TTYDBENCH_${Date.now()}_${index}__`;
const stdoutStart = stdoutText.length;
const startedAt = performance.now();
sendTtydInput(ws, `printf '${marker}\\n'\n`);
await waitFor(
() => stdoutText.slice(stdoutStart).includes(marker) || fatalError.length > 0,
options.timeoutMs,
`命令回显 RTT #${index + 1}`
);
if (fatalError) {
throw new Error(fatalError);
}
rttSamples.push(performance.now() - startedAt);
}
const payloadBytes = options.payloadKb * 1024;
const throughputStartBytes = stdoutBytes;
const throughputStartedAt = performance.now();
sendTtydInput(ws, `dd if=/dev/zero bs=1024 count=${options.payloadKb} 2>/dev/null | tr '\\0' 'x'\n`);
await waitFor(
() => stdoutBytes - throughputStartBytes >= payloadBytes || fatalError.length > 0,
options.timeoutMs * 4,
"吞吐测试输出收集"
);
if (fatalError) {
throw new Error(fatalError);
}
const throughputMs = performance.now() - throughputStartedAt;
ws.close();
proc.kill("SIGTERM");
const rtt = buildStats(rttSamples);
console.log("=== TTYD 基线压测结果 ===");
console.log(
JSON.stringify(
{
ts: new Date().toISOString(),
endpoint,
sshTarget: `${options.sshUsername}@${options.sshHost}:${options.sshPort}`,
iterations: options.iterations,
payloadKb: options.payloadKb,
connect: formatMs(connectMs),
rtt: {
min: formatMs(rtt.min),
p50: formatMs(rtt.p50),
p95: formatMs(rtt.p95),
max: formatMs(rtt.max),
avg: formatMs(rtt.avg)
},
throughput: {
bytes: payloadBytes,
cost: formatMs(throughputMs),
speed: formatMbps(payloadBytes, throughputMs)
}
},
null,
2
)
);
}
main().catch((error) => {
console.error("[bench] 执行失败:", (error as Error).message);
process.exitCode = 1;
});

View File

@@ -0,0 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("gateway config", () => {
const originalEnv = { ...process.env };
beforeEach(() => {
process.env = { ...originalEnv };
vi.resetModules();
});
afterEach(() => {
process.env = { ...originalEnv };
});
it("应为火山 V3 单向流式 TTS 提供新的默认 resource id", async () => {
process.env.TTS_RESOURCE_ID = "";
const imported = await import("./config");
expect(imported.config.tts.resourceId).toBe("volc.service_type.10029");
});
it("应解析 TTS_CACHE_FILE_MAX_BYTES并在非法值时回退默认上限", async () => {
process.env.TTS_CACHE_FILE_MAX_BYTES = "6291456";
let imported = await import("./config");
expect(imported.config.tts.cacheFileMaxBytes).toBe(6 * 1024 * 1024);
vi.resetModules();
process.env.TTS_CACHE_FILE_MAX_BYTES = "0";
imported = await import("./config");
expect(imported.config.tts.cacheFileMaxBytes).toBe(8 * 1024 * 1024);
});
});

400
apps/gateway/src/config.ts Normal file
View File

@@ -0,0 +1,400 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { z } from "zod";
/**
* 运维配置 schema收敛版
*
* 优先级Env > runtime.json > 代码内置默认值。
* 敏感字段GATEWAY_TOKEN / ASR_ACCESS_TOKEN / ASR_SECRET_KEY只走 Env不写入配置文件或仓库。
*
* 字段对照(按配置计划章节):
* 网关基础GATEWAY_PORT / GATEWAY_HOST / GATEWAY_TOKEN / GATEWAY_CORS_ORIGIN / GATEWAY_LOG_LEVEL / GATEWAY_DEBUG_IO_HEX
* 语音识别ASR_PROVIDER / ASR_APP_ID / ASR_ACCESS_TOKEN / ASR_SECRET_KEY / ASR_RESOURCE_ID / ASR_CLUSTER / ASR_WS_URL / ASR_INCLUDE_RAW_RESULT / ASR_EMPTY_TEXT_WARN_LIMIT
* 语音播报TTS_PROVIDER / TTS_APP_ID / TTS_ACCESS_TOKEN / TTS_SECRET_ID / TTS_SECRET_KEY / TTS_REGION / TTS_CLUSTER / TTS_RESOURCE_ID / TTS_VOICE_DEFAULT / TTS_SPEED_DEFAULT / TTS_TIMEOUT_MS / TTS_CACHE_FILE_MAX_BYTES
* 安全策略RATE_LIMIT_POINTS / RATE_LIMIT_DURATION_SEC
* 会话策略ASSIST_TXN_TTL_MS / ASSIST_TXN_CACHE_LIMIT
* SSH 策略SSH_READY_TIMEOUT_MS / SSH_KEEPALIVE_INTERVAL_MS / SSH_KEEPALIVE_COUNT_MAX / TERMINAL_RESUME_GRACE_DEFAULT_MS / TERMINAL_RESUME_GRACE_MAX_MS
* 插件策略PLUGIN_ONLOAD_TIMEOUT_MS / PLUGIN_ONUNLOAD_TIMEOUT_MS
*/
const schema = z.object({
// ── 网关基础 ─────────────────────────────────────────────────────────────
/** 监听端口(别名 PORT 向下兼容) */
GATEWAY_PORT: z.string().optional(),
PORT: z.string().optional(),
/** 监听地址 */
GATEWAY_HOST: z.string().optional().default("0.0.0.0"),
/** @deprecated 请使用 GATEWAY_HOST */
HOST: z.string().optional(),
/** 访问令牌,仅 Env不可写文件 */
GATEWAY_TOKEN: z.string().min(8).default("remoteconn-dev-token"),
/** CORS Access-Control-Allow-Origin */
GATEWAY_CORS_ORIGIN: z.string().optional().default("*"),
/** 日志级别 */
GATEWAY_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
/** 原始 IO 十六进制调试1 时输出原始帧转储 */
GATEWAY_DEBUG_IO_HEX: z.string().optional().default("0"),
// ── 小程序同步(配置持久化)───────────────────────────────────────────────
/** 微信小程序 AppID用于 code2Session */
MINIPROGRAM_APP_ID: z.string().optional().default(""),
/** 微信小程序 AppSecret仅 Env */
MINIPROGRAM_APP_SECRET: z.string().optional().default(""),
/** 同步 SQLite 文件路径 */
SYNC_SQLITE_PATH: z.string().optional().default("data/remoteconn-sync.db"),
/** 同步敏感字段加密主密钥,仅 Env */
SYNC_SECRET_CURRENT: z.string().optional().default(""),
/** 当前加密密钥版本 */
SYNC_SECRET_VERSION: z.string().optional().default("1"),
/** 同步登录 token 有效期(秒) */
SYNC_TOKEN_TTL_SEC: z.string().optional().default("604800"),
// ── 语音识别(通用)───────────────────────────────────────────────────────
/** 语音供应商标识 */
ASR_PROVIDER: z.string().optional().default("volcengine"),
/** 语音服务 App ID */
ASR_APP_ID: z.string().optional(),
/** 语音服务 Access Token仅 Env */
ASR_ACCESS_TOKEN: z.string().optional(),
/** 语音服务 Secret Key仅 Env */
ASR_SECRET_KEY: z.string().optional(),
/** 语音资源标识ASR 2.0 小时版默认值) */
ASR_RESOURCE_ID: z.string().optional().default("volc.seedasr.sauc.duration"),
/** 集群参数(可选) */
ASR_CLUSTER: z.string().optional(),
/** WebSocket 接入地址 */
ASR_WS_URL: z
.string()
.url()
.optional()
.default("wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async"),
/** 是否在 result 帧携带上游原始 payload默认关闭减少传输体积 */
ASR_INCLUDE_RAW_RESULT: z.string().optional().default("0"),
/** 单连接内“空文本结果”告警上限,避免日志风暴 */
ASR_EMPTY_TEXT_WARN_LIMIT: z.string().optional().default("3"),
// ── 语音播报TTS───────────────────────────────────────────────────────
/** TTS 供应商标识 */
TTS_PROVIDER: z.string().optional().default("tencent"),
/** TTS App ID当前腾讯云短文本合成保留配置位未直接参与签名 */
TTS_APP_ID: z.string().optional(),
/** TTS Access TokenVolcengine 使用) */
TTS_ACCESS_TOKEN: z.string().optional(),
/** TTS Secret ID仅 Env */
TTS_SECRET_ID: z.string().optional(),
/** TTS Secret Key仅 Env */
TTS_SECRET_KEY: z.string().optional(),
/** TTS 地域 */
TTS_REGION: z.string().optional().default("ap-guangzhou"),
/** TTS 集群Volcengine 使用) */
TTS_CLUSTER: z.string().optional().default("volcano_tts"),
/** TTS 资源标识(火山 HTTP Chunked/SSE 单向流式 V3 默认值) */
TTS_RESOURCE_ID: z.string().optional().default("volc.service_type.10029"),
/** 默认音色别名 */
TTS_VOICE_DEFAULT: z.string().optional().default("female_v1"),
/** 默认语速 */
TTS_SPEED_DEFAULT: z.string().optional().default("1"),
/** 单次 TTS 请求超时(毫秒) */
TTS_TIMEOUT_MS: z.string().optional().default("30000"),
/** 单个 TTS 音频缓存文件的最大大小(字节) */
TTS_CACHE_FILE_MAX_BYTES: z
.string()
.optional()
.default(String(8 * 1024 * 1024)),
// ── 安全策略(限流)────────────────────────────────────────────────────────
/** 单 IP 在窗口期内最大请求次数 */
RATE_LIMIT_POINTS: z.string().optional().default("30"),
/** 限流计数器重置周期(秒) */
RATE_LIMIT_DURATION_SEC: z.string().optional().default("60"),
// ── 会话策略assist 事务去重缓存)──────────────────────────────────────────
/** 同一事务 ID 在此时间内视为重复(毫秒) */
ASSIST_TXN_TTL_MS: z.string().optional().default("30000"),
/** LRU 缓存最大条目数 */
ASSIST_TXN_CACHE_LIMIT: z.string().optional().default("512"),
// ── SSH 策略 ─────────────────────────────────────────────────────────────
/** 等待 SSH ready 事件的超时时间(毫秒) */
SSH_READY_TIMEOUT_MS: z.string().optional().default("15000"),
/** 心跳包发送间隔(毫秒) */
SSH_KEEPALIVE_INTERVAL_MS: z.string().optional().default("10000"),
/** 连续无响应超过此次数后断开连接 */
SSH_KEEPALIVE_COUNT_MAX: z.string().optional().default("3"),
/** 终端续接驻留默认窗口(毫秒) */
TERMINAL_RESUME_GRACE_DEFAULT_MS: z.string().optional().default("20000"),
/** 终端续接驻留最大窗口(毫秒) */
TERMINAL_RESUME_GRACE_MAX_MS: z.string().optional().default("3600000"),
// ── 插件运行时策略 ────────────────────────────────────────────────────────
/** 单个插件 onLoad 钩子最长执行时间(毫秒) */
PLUGIN_ONLOAD_TIMEOUT_MS: z.string().optional().default("3000"),
/** 单个插件 onUnload 钩子最长执行时间(毫秒) */
PLUGIN_ONUNLOAD_TIMEOUT_MS: z.string().optional().default("3000"),
// ── 向下兼容别名(旧字段,下一个版本窗口删除)────────────────────────────
/** @deprecated 请使用 GATEWAY_CORS_ORIGIN */
CORS_ORIGIN: z.string().optional(),
/** @deprecated 请使用 GATEWAY_DEBUG_IO_HEX */
DEBUG_IO_HEX: z.string().optional()
});
const runtimeFileSchema = z.object({
// ── 网关基础(非敏感)───────────────────────────────────────────────────────
GATEWAY_PORT: z.union([z.string(), z.number()]).optional(),
PORT: z.union([z.string(), z.number()]).optional(),
GATEWAY_HOST: z.string().optional(),
HOST: z.string().optional(),
GATEWAY_CORS_ORIGIN: z.string().optional(),
GATEWAY_LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).optional(),
GATEWAY_DEBUG_IO_HEX: z.union([z.string(), z.number(), z.boolean()]).optional(),
ASR_INCLUDE_RAW_RESULT: z.union([z.string(), z.number(), z.boolean()]).optional(),
ASR_EMPTY_TEXT_WARN_LIMIT: z.union([z.string(), z.number()]).optional(),
TTS_PROVIDER: z.string().optional(),
TTS_APP_ID: z.string().optional(),
TTS_ACCESS_TOKEN: z.string().optional(),
TTS_REGION: z.string().optional(),
TTS_CLUSTER: z.string().optional(),
TTS_RESOURCE_ID: z.string().optional(),
TTS_VOICE_DEFAULT: z.string().optional(),
TTS_SPEED_DEFAULT: z.union([z.string(), z.number()]).optional(),
TTS_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(),
TTS_CACHE_FILE_MAX_BYTES: z.union([z.string(), z.number()]).optional(),
// ── 小程序同步(非敏感)───────────────────────────────────────────────────
MINIPROGRAM_APP_ID: z.string().optional(),
SYNC_SQLITE_PATH: z.string().optional(),
SYNC_SECRET_VERSION: z.union([z.string(), z.number()]).optional(),
SYNC_TOKEN_TTL_SEC: z.union([z.string(), z.number()]).optional(),
// ── 安全策略(限流)────────────────────────────────────────────────────────
RATE_LIMIT_POINTS: z.union([z.string(), z.number()]).optional(),
RATE_LIMIT_DURATION_SEC: z.union([z.string(), z.number()]).optional(),
// ── 会话策略 ─────────────────────────────────────────────────────────────
ASSIST_TXN_TTL_MS: z.union([z.string(), z.number()]).optional(),
ASSIST_TXN_CACHE_LIMIT: z.union([z.string(), z.number()]).optional(),
// ── SSH 策略 ─────────────────────────────────────────────────────────────
SSH_READY_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(),
SSH_KEEPALIVE_INTERVAL_MS: z.union([z.string(), z.number()]).optional(),
SSH_KEEPALIVE_COUNT_MAX: z.union([z.string(), z.number()]).optional(),
TERMINAL_RESUME_GRACE_DEFAULT_MS: z.union([z.string(), z.number()]).optional(),
TERMINAL_RESUME_GRACE_MAX_MS: z.union([z.string(), z.number()]).optional(),
// ── 插件运行时策略 ────────────────────────────────────────────────────────
PLUGIN_ONLOAD_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(),
PLUGIN_ONUNLOAD_TIMEOUT_MS: z.union([z.string(), z.number()]).optional(),
// ── 向下兼容别名 ─────────────────────────────────────────────────────────
CORS_ORIGIN: z.string().optional(),
DEBUG_IO_HEX: z.union([z.string(), z.number(), z.boolean()]).optional()
});
type RuntimeFileConfig = z.infer<typeof runtimeFileSchema>;
function resolveRuntimeConfigPath(): string | null {
const fromEnv = process.env.GATEWAY_RUNTIME_CONFIG_PATH?.trim();
if (fromEnv) return fromEnv;
const candidates = [
path.resolve(process.cwd(), "config/runtime.json"),
"/etc/remoteconn/gateway.runtime.json"
];
return candidates.find((candidate) => existsSync(candidate)) ?? null;
}
function scalarToString(value: string | number | boolean): string {
if (typeof value === "boolean") {
return value ? "1" : "0";
}
return String(value);
}
function loadRuntimeFileConfig(): RuntimeFileConfig {
const runtimePath = resolveRuntimeConfigPath();
if (!runtimePath) return {};
try {
const raw = readFileSync(runtimePath, "utf-8");
const parsed = JSON.parse(raw) as unknown;
return runtimeFileSchema.parse(parsed);
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(`invalid runtime config at ${runtimePath}: ${reason}`);
}
}
function toEnvLikeRecord(runtime: RuntimeFileConfig): Record<string, string> {
const entries = Object.entries(runtime)
.filter((entry): entry is [string, string | number | boolean] => entry[1] !== undefined)
.map(([key, value]) => [key, scalarToString(value)]);
return Object.fromEntries(entries);
}
/**
* 轻量 .env 解析(不引入 dotenv 依赖):
* - 仅做 K=V 解析与引号去除;
* - 支持 `export KEY=VALUE`
* - 不覆盖已存在的 process.env由合并顺序保证
*/
function parseDotEnv(content: string): Record<string, string> {
const out: Record<string, string> = {};
const lines = content.split(/\r?\n/);
for (const raw of lines) {
const line = raw.trim();
if (!line || line.startsWith("#")) {
continue;
}
const normalized = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
const idx = normalized.indexOf("=");
if (idx <= 0) {
continue;
}
const key = normalized.slice(0, idx).trim();
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
continue;
}
let value = normalized.slice(idx + 1).trim();
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
value = value.slice(1, -1);
}
out[key] = value;
}
return out;
}
function loadDotEnvConfig(): Record<string, string> {
const fromEnv = process.env.GATEWAY_ENV_FILE?.trim();
const candidates = [
fromEnv,
path.resolve(process.cwd(), ".env"),
path.resolve(process.cwd(), "apps/gateway/.env")
].filter((item): item is string => Boolean(item));
for (const file of candidates) {
if (!existsSync(file)) {
continue;
}
try {
const raw = readFileSync(file, "utf-8");
return parseDotEnv(raw);
} catch {
// 读取失败时跳过,继续尝试其他候选路径。
}
}
return {};
}
const runtimeFileConfig = loadRuntimeFileConfig();
const dotenvConfig = loadDotEnvConfig();
const mergedInput = {
...toEnvLikeRecord(runtimeFileConfig),
...dotenvConfig,
...process.env
};
const env = schema.parse(mergedInput);
function isTtsEnabled(ttsProvider: string): boolean {
const provider = String(ttsProvider || "")
.trim()
.toLowerCase();
if (provider === "volcengine") {
return Boolean((env.TTS_APP_ID ?? "").trim() && (env.TTS_ACCESS_TOKEN ?? "").trim());
}
if (provider === "tencent") {
return Boolean((env.TTS_SECRET_ID ?? "").trim() && (env.TTS_SECRET_KEY ?? "").trim());
}
return false;
}
function parseBool(value: string): boolean {
return /^(1|true|yes|on)$/i.test(value.trim());
}
function parsePositiveInt(value: string, fallback: number): number {
const n = parseInt(value, 10);
return Number.isFinite(n) && n > 0 ? n : fallback;
}
export const config = {
// ── 网关基础 ─────────────────────────────────────────────────────────────
port: parsePositiveInt(env.GATEWAY_PORT ?? env.PORT ?? "8787", 8787),
host: env.GATEWAY_HOST ?? env.HOST ?? "0.0.0.0",
gatewayToken: env.GATEWAY_TOKEN,
/** 兼容旧字段 CORS_ORIGIN */
corsOrigin: env.GATEWAY_CORS_ORIGIN !== "*" ? env.GATEWAY_CORS_ORIGIN : (env.CORS_ORIGIN ?? "*"),
logLevel: env.GATEWAY_LOG_LEVEL,
/** 兼容旧字段 DEBUG_IO_HEX */
debugIoHex: parseBool(
env.GATEWAY_DEBUG_IO_HEX !== "0" ? env.GATEWAY_DEBUG_IO_HEX : (env.DEBUG_IO_HEX ?? "0")
),
// ── 小程序同步(配置持久化)───────────────────────────────────────────────
sync: {
miniprogramAppId: (env.MINIPROGRAM_APP_ID ?? "").trim(),
miniprogramAppSecret: (env.MINIPROGRAM_APP_SECRET ?? "").trim(),
sqlitePath: (env.SYNC_SQLITE_PATH ?? "data/remoteconn-sync.db").trim(),
secretCurrent: (env.SYNC_SECRET_CURRENT ?? "").trim(),
secretVersion: parsePositiveInt(env.SYNC_SECRET_VERSION ?? "1", 1),
tokenTtlSec: parsePositiveInt(env.SYNC_TOKEN_TTL_SEC ?? "604800", 604800),
enabled: Boolean(
(env.MINIPROGRAM_APP_ID ?? "").trim() &&
(env.MINIPROGRAM_APP_SECRET ?? "").trim() &&
(env.SYNC_SECRET_CURRENT ?? "").trim()
)
},
// ── 安全策略 ─────────────────────────────────────────────────────────────
rateLimitPoints: parsePositiveInt(env.RATE_LIMIT_POINTS, 30),
rateLimitDurationSec: parsePositiveInt(env.RATE_LIMIT_DURATION_SEC, 60),
// ── 会话策略 ─────────────────────────────────────────────────────────────
assistTxnTtlMs: parsePositiveInt(env.ASSIST_TXN_TTL_MS, 30000),
assistTxnCacheLimit: parsePositiveInt(env.ASSIST_TXN_CACHE_LIMIT, 512),
// ── SSH 策略 ─────────────────────────────────────────────────────────────
sshReadyTimeoutMs: parsePositiveInt(env.SSH_READY_TIMEOUT_MS, 15000),
sshKeepaliveIntervalMs: parsePositiveInt(env.SSH_KEEPALIVE_INTERVAL_MS, 10000),
sshKeepaliveCountMax: parsePositiveInt(env.SSH_KEEPALIVE_COUNT_MAX, 3),
terminalResumeGraceDefaultMs: parsePositiveInt(env.TERMINAL_RESUME_GRACE_DEFAULT_MS, 20000),
terminalResumeGraceMaxMs: parsePositiveInt(env.TERMINAL_RESUME_GRACE_MAX_MS, 60 * 60 * 1000),
// ── 语音识别(通用)───────────────────────────────────────────────────────
asr: {
provider: (env.ASR_PROVIDER ?? "volcengine").trim(),
appId: (env.ASR_APP_ID ?? "").trim(),
accessToken: (env.ASR_ACCESS_TOKEN ?? "").trim(),
secretKey: (env.ASR_SECRET_KEY ?? "").trim(),
resourceId: (env.ASR_RESOURCE_ID ?? "volc.seedasr.sauc.duration").trim(),
cluster: (env.ASR_CLUSTER ?? "").trim(),
wsUrl: (env.ASR_WS_URL ?? "wss://openspeech.bytedance.com/api/v3/sauc/bigmodel_async").trim(),
includeRawResult: parseBool(env.ASR_INCLUDE_RAW_RESULT ?? "0"),
emptyTextWarnLimit: parsePositiveInt(env.ASR_EMPTY_TEXT_WARN_LIMIT ?? "3", 3)
},
// ── 语音播报TTS───────────────────────────────────────────────────────
tts: {
provider: (env.TTS_PROVIDER ?? "tencent").trim(),
appId: (env.TTS_APP_ID ?? "").trim(),
accessToken: (env.TTS_ACCESS_TOKEN ?? "").trim(),
secretId: (env.TTS_SECRET_ID ?? "").trim(),
secretKey: (env.TTS_SECRET_KEY ?? "").trim(),
region: (env.TTS_REGION ?? "ap-guangzhou").trim() || "ap-guangzhou",
cluster: (env.TTS_CLUSTER ?? "volcano_tts").trim() || "volcano_tts",
resourceId: (env.TTS_RESOURCE_ID ?? "volc.service_type.10029").trim() || "volc.service_type.10029",
voiceDefault: (env.TTS_VOICE_DEFAULT ?? "female_v1").trim() || "female_v1",
speedDefault: Number(env.TTS_SPEED_DEFAULT ?? "1") || 1,
timeoutMs: parsePositiveInt(env.TTS_TIMEOUT_MS ?? "30000", 30000),
cacheFileMaxBytes: parsePositiveInt(
env.TTS_CACHE_FILE_MAX_BYTES ?? String(8 * 1024 * 1024),
8 * 1024 * 1024
),
enabled: isTtsEnabled(env.TTS_PROVIDER ?? "tencent")
},
// ── 插件运行时策略 ────────────────────────────────────────────────────────
pluginOnloadTimeoutMs: parsePositiveInt(env.PLUGIN_ONLOAD_TIMEOUT_MS, 3000),
pluginOnunloadTimeoutMs: parsePositiveInt(env.PLUGIN_ONUNLOAD_TIMEOUT_MS, 3000)
};

View File

@@ -0,0 +1,61 @@
const PREVIEW_CHARS = 48;
const PREVIEW_BYTES = 64;
function toHexBytes(input: Uint8Array): string {
return Array.from(input)
.map((b) => b.toString(16).padStart(2, "0"))
.join(" ");
}
function toHexCodeUnits(input: string, limit = PREVIEW_CHARS): string {
const parts: string[] = [];
const max = Math.min(input.length, limit);
for (let i = 0; i < max; i += 1) {
parts.push(input.charCodeAt(i).toString(16).padStart(4, "0"));
}
return parts.join(" ");
}
function toHexLatin1Bytes(input: string, limit = PREVIEW_CHARS): string {
const parts: string[] = [];
const max = Math.min(input.length, limit);
for (let i = 0; i < max; i += 1) {
const code = input.charCodeAt(i);
if (code <= 0xff) {
parts.push(code.toString(16).padStart(2, "0"));
} else {
parts.push("..");
}
}
return parts.join(" ");
}
/**
* 生成字符串的十六进制调试快照,定位“哪一段链路改坏了字节”。
* - utf8Hex: 按 UTF-8 编码后的字节;
* - latin1Hex: 将每个 code unit 视作单字节(仅 0x00-0xFF 有效);
* - codeUnitHex: 原始 JS code unit16 进制)。
*/
export function inspectStringHex(data: string): Record<string, string | number> {
const utf8Bytes = Buffer.from(data, "utf8");
return {
charLen: data.length,
utf8ByteLen: utf8Bytes.length,
preview: JSON.stringify(data.slice(0, PREVIEW_CHARS)),
codeUnitHex: toHexCodeUnits(data),
latin1Hex: toHexLatin1Bytes(data),
utf8Hex: toHexBytes(utf8Bytes.subarray(0, PREVIEW_BYTES))
};
}
/**
* 生成二进制缓冲区的十六进制快照,用于原始字节层排障。
*/
export function inspectBufferHex(data: Buffer | Uint8Array): Record<string, string | number> {
const bytes = Buffer.from(data);
return {
byteLen: bytes.length,
utf8Preview: JSON.stringify(bytes.toString("utf8").slice(0, PREVIEW_CHARS)),
hex: toHexBytes(bytes.subarray(0, PREVIEW_BYTES))
};
}

View File

@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { createTerminalCaptureArmStore } from "./terminalCaptureArm";
describe("terminalCaptureArm", () => {
it("会拒绝没有匹配条件的录制规则", () => {
const store = createTerminalCaptureArmStore();
expect(() =>
store.arm({
captureDir: "/tmp/remoteconn-captures",
ttlMs: 60_000
})
).toThrow(/至少需要提供一个匹配条件/);
});
it("命中后会一次性取走规则", () => {
const store = createTerminalCaptureArmStore();
const rule = store.arm({
captureDir: "/tmp/remoteconn-captures",
ttlMs: 60_000,
clientSessionKey: "mini-session-1",
host: "example.com",
username: "gavin"
});
const matched = store.take({
ip: "127.0.0.1",
clientSessionKey: "mini-session-1",
host: "example.com",
port: 22,
username: "gavin"
});
expect(matched?.id).toBe(rule.id);
expect(store.list()).toEqual([]);
});
it("过期规则不会再命中", () => {
const nowSpy = vi.spyOn(Date, "now");
nowSpy.mockReturnValue(1_000);
const store = createTerminalCaptureArmStore();
store.arm({
captureDir: "/tmp/remoteconn-captures",
ttlMs: 1_000,
username: "gavin"
});
nowSpy.mockReturnValue(2_500);
const matched = store.take({
ip: "127.0.0.1",
host: "example.com",
port: 22,
username: "gavin"
});
expect(matched).toBeNull();
nowSpy.mockRestore();
});
});

View File

@@ -0,0 +1,148 @@
import { randomUUID } from "node:crypto";
export interface ArmedTerminalCaptureRule {
id: string;
createdAt: number;
expiresAt: number;
captureDir: string;
ip?: string;
clientSessionKey?: string;
host?: string;
port?: number;
username?: string;
}
export interface TerminalCaptureArmInput {
captureDir: string;
ttlMs: number;
ip?: string;
clientSessionKey?: string;
host?: string;
port?: number;
username?: string;
}
export interface TerminalCaptureMatchTarget {
ip: string;
clientSessionKey?: string;
host: string;
port: number;
username: string;
}
export interface TerminalCaptureArmStore {
arm(input: TerminalCaptureArmInput): ArmedTerminalCaptureRule;
take(target: TerminalCaptureMatchTarget): ArmedTerminalCaptureRule | null;
list(): ArmedTerminalCaptureRule[];
}
const DEFAULT_TTL_MS = 5 * 60 * 1000;
const MAX_TTL_MS = 60 * 60 * 1000;
function normalizeString(value: unknown): string {
return String(value || "").trim();
}
function normalizeOptionalString(value: unknown): string | undefined {
const next = normalizeString(value);
return next ? next : undefined;
}
function normalizePort(value: unknown): number | undefined {
const next = Number(value);
if (!Number.isFinite(next) || next <= 0) {
return undefined;
}
return Math.round(next);
}
function normalizeTtlMs(value: unknown): number {
const next = Number(value);
if (!Number.isFinite(next) || next <= 0) {
return DEFAULT_TTL_MS;
}
return Math.min(Math.max(Math.round(next), 1_000), MAX_TTL_MS);
}
function hasMatchConstraint(rule: Omit<ArmedTerminalCaptureRule, "id" | "createdAt" | "expiresAt">): boolean {
return Boolean(rule.ip || rule.clientSessionKey || rule.host || rule.port || rule.username);
}
function matchesRule(rule: ArmedTerminalCaptureRule, target: TerminalCaptureMatchTarget): boolean {
if (rule.ip && rule.ip !== target.ip) {
return false;
}
if (rule.clientSessionKey && rule.clientSessionKey !== normalizeString(target.clientSessionKey)) {
return false;
}
if (rule.host && rule.host !== target.host) {
return false;
}
if (rule.port && rule.port !== target.port) {
return false;
}
if (rule.username && rule.username !== target.username) {
return false;
}
return true;
}
export function createTerminalCaptureArmStore(): TerminalCaptureArmStore {
const rules = new Map<string, ArmedTerminalCaptureRule>();
const cleanupExpired = (): void => {
const now = Date.now();
for (const [id, rule] of rules.entries()) {
if (rule.expiresAt <= now) {
rules.delete(id);
}
}
};
return {
arm(input) {
cleanupExpired();
const captureDir = normalizeString(input.captureDir);
if (!captureDir) {
throw new Error("captureDir 不能为空");
}
const normalizedRule = {
captureDir,
ip: normalizeOptionalString(input.ip),
clientSessionKey: normalizeOptionalString(input.clientSessionKey),
host: normalizeOptionalString(input.host),
port: normalizePort(input.port),
username: normalizeOptionalString(input.username)
};
if (!hasMatchConstraint(normalizedRule)) {
throw new Error("至少需要提供一个匹配条件ip/clientSessionKey/host/port/username");
}
const now = Date.now();
const rule: ArmedTerminalCaptureRule = {
id: randomUUID(),
createdAt: now,
expiresAt: now + normalizeTtlMs(input.ttlMs),
...normalizedRule
};
rules.set(rule.id, rule);
return rule;
},
take(target) {
cleanupExpired();
for (const [id, rule] of rules.entries()) {
if (!matchesRule(rule, target)) {
continue;
}
rules.delete(id);
return rule;
}
return null;
},
list() {
cleanupExpired();
return Array.from(rules.values()).sort((left, right) => left.createdAt - right.createdAt);
}
};
}

View File

@@ -0,0 +1,78 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { createTerminalFrameCaptureRecorder } from "./terminalFrameCapture";
const ORIGINAL_CAPTURE_DIR = process.env.RC_TERMINAL_CAPTURE_DIR;
afterEach(() => {
if (ORIGINAL_CAPTURE_DIR === undefined) {
delete process.env.RC_TERMINAL_CAPTURE_DIR;
} else {
process.env.RC_TERMINAL_CAPTURE_DIR = ORIGINAL_CAPTURE_DIR;
}
});
describe("terminalFrameCapture", () => {
it("未配置目录时不会开启录制", () => {
delete process.env.RC_TERMINAL_CAPTURE_DIR;
const recorder = createTerminalFrameCaptureRecorder({
ip: "127.0.0.1",
host: "example.com",
port: 22,
username: "gavin"
});
expect(recorder).toBeNull();
});
it("会把 meta、frame 和 close 事件写入 jsonl 文件", () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "remoteconn-capture-"));
process.env.RC_TERMINAL_CAPTURE_DIR = tempDir;
const recorder = createTerminalFrameCaptureRecorder({
ip: "127.0.0.1",
host: "example.com",
port: 22,
username: "gavin",
clientSessionKey: "session-1"
});
expect(recorder).not.toBeNull();
recorder?.record("stdin", "hello\n");
recorder?.record("stdout", "world\r\n");
recorder?.close("shell_closed");
const filePath = recorder?.filePath;
expect(filePath).toBeTruthy();
const lines = fs
.readFileSync(String(filePath), "utf8")
.trim()
.split("\n")
.map((line) => JSON.parse(line));
expect(lines[0]).toMatchObject({
kind: "meta",
host: "example.com",
username: "gavin",
clientSessionKey: "session-1"
});
expect(lines[1]).toMatchObject({
kind: "frame",
type: "stdin",
data: "hello\n"
});
expect(lines[2]).toMatchObject({
kind: "frame",
type: "stdout",
data: "world\r\n"
});
expect(lines[3]).toMatchObject({
kind: "close",
reason: "shell_closed"
});
fs.rmSync(tempDir, { recursive: true, force: true });
});
});

View File

@@ -0,0 +1,97 @@
import fs from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
export type TerminalCaptureFrameType = "stdin" | "stdout" | "stderr";
export interface TerminalFrameCaptureRecorder {
filePath: string;
record(type: TerminalCaptureFrameType, data: string): void;
close(reason?: string): void;
}
interface TerminalFrameCaptureMeta {
ip: string;
host: string;
port: number;
username: string;
clientSessionKey?: string;
captureDir?: string;
}
function sanitizePathToken(input: string): string {
return String(input || "")
.trim()
.replace(/[^a-zA-Z0-9._-]+/g, "_")
.replace(/^_+|_+$/g, "") || "unknown";
}
function resolveCaptureDir(preferredDir?: string): string {
return String(preferredDir || process.env.RC_TERMINAL_CAPTURE_DIR || "").trim();
}
export function createTerminalFrameCaptureRecorder(
meta: TerminalFrameCaptureMeta
): TerminalFrameCaptureRecorder | null {
const captureDir = resolveCaptureDir(meta.captureDir);
if (!captureDir) {
return null;
}
fs.mkdirSync(captureDir, { recursive: true });
const startedAt = Date.now();
const fileName = [
new Date(startedAt).toISOString().replace(/[:.]/g, "-"),
sanitizePathToken(meta.username),
"@",
sanitizePathToken(meta.host),
`-${Number(meta.port) || 22}`,
`-${randomUUID().slice(0, 8)}.jsonl`
].join("");
const filePath = path.join(captureDir, fileName);
let closed = false;
const writeLine = (payload: Record<string, unknown>): void => {
fs.appendFileSync(filePath, `${JSON.stringify(payload)}\n`, "utf8");
};
writeLine({
kind: "meta",
version: 1,
startedAt,
ip: meta.ip,
host: meta.host,
port: meta.port,
username: meta.username,
clientSessionKey: meta.clientSessionKey || ""
});
return {
filePath,
record(type, data) {
if (closed || !data) {
return;
}
const now = Date.now();
writeLine({
kind: "frame",
at: now,
offsetMs: Math.max(0, now - startedAt),
type,
data
});
},
close(reason) {
if (closed) {
return;
}
closed = true;
writeLine({
kind: "close",
at: Date.now(),
offsetMs: Math.max(0, Date.now() - startedAt),
reason: reason || ""
});
}
};
}

View File

@@ -0,0 +1,9 @@
import { config } from "./config";
import { logger } from "./logger";
import { createGatewayServer } from "./server";
const server = createGatewayServer();
server.listen(config.port, config.host, () => {
logger.info({ host: config.host, port: config.port }, "gateway started");
});

View File

@@ -0,0 +1,32 @@
import pino from "pino";
/**
* 将日志时间格式化为紧凑数字串:
* - 目标格式YYYYMMDDHHmmssSSSS
* - 字段含义:年(4)月(2)日(2)时(2)分(2)秒(2)毫秒(4)
*
* 说明:
* 1) JavaScript 原生毫秒精度是 0-999三位
* 2) 这里按产品约定扩展为四位:`毫秒三位 + 末尾补 0`,例如 `120 -> 1200`
* 3) 使用本地时区,便于和用户界面展示时间对齐。
*/
function formatCompactLocalTime(now: Date): string {
const year = String(now.getFullYear());
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
const hour = String(now.getHours()).padStart(2, "0");
const minute = String(now.getMinutes()).padStart(2, "0");
const second = String(now.getSeconds()).padStart(2, "0");
const millisecond4 = `${String(now.getMilliseconds()).padStart(3, "0")}0`;
return `${year}${month}${day}${hour}${minute}${second}${millisecond4}`;
}
export const logger = pino({
level: process.env.LOG_LEVEL ?? "info",
/**
* 将默认 epoch 毫秒时间戳(如 1771821864018改为业务约定数字格式
* - 输出示例:`"time":202602231245101200`
* - 仍使用 `time` 字段,避免破坏现有日志消费方字段约定。
*/
timestamp: () => `,"time":${formatCompactLocalTime(new Date())}`
});

View File

@@ -0,0 +1,15 @@
import { RateLimiterMemory } from "rate-limiter-flexible";
import { config } from "../config";
/**
* 连接限流:同一 IP 每分钟限制一定连接次数,防止爆破和资源耗尽。
* 参数由 config.rateLimitPoints / config.rateLimitDurationSec 控制。
*/
const limiter = new RateLimiterMemory({
points: config.rateLimitPoints,
duration: config.rateLimitDurationSec
});
export async function checkConnectionRate(ip: string): Promise<void> {
await limiter.consume(ip || "unknown", 1);
}

1557
apps/gateway/src/server.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,421 @@
import { EventEmitter } from "node:events";
import { beforeEach, describe, expect, it, vi } from "vitest";
const { connectSpy, streamWriteSpy } = vi.hoisted(() => ({
connectSpy: vi.fn(),
streamWriteSpy: vi.fn()
}));
let capturedStream: MockChannel | null = null;
class MockChannel extends EventEmitter {
public stderr = {
on: (_event: string, _listener: (chunk: Buffer) => void) => {
return this.stderr;
}
};
public write(data: string | Buffer): void {
streamWriteSpy(data);
}
public setWindow(_rows: number, _cols: number, _height: number, _width: number): void {
// no-op for tests
}
public close(): void {
this.emit("close");
}
}
class MockForwardStream extends EventEmitter {}
vi.mock("ssh2", () => ({
Client: class MockClient extends EventEmitter {
public connect(config: unknown): this {
connectSpy(config);
const host = (config as { host?: string })?.host;
if (host === "ready-host" || host === "jump-host") {
queueMicrotask(() => this.emit("ready"));
} else {
queueMicrotask(() => this.emit("error", new Error("mock connect failed")));
}
return this;
}
public shell(
_options: unknown,
callback: (shellError: Error | undefined, stream: MockChannel) => void
): void {
const ch = new MockChannel();
capturedStream = ch;
callback(undefined, ch);
}
public forwardOut(
_srcIP: string,
_srcPort: number,
_dstIP: string,
_dstPort: number,
callback: (error: Error | undefined, stream: MockForwardStream) => void
): void {
callback(undefined, new MockForwardStream());
}
public end(): void {
// 模拟 ssh2 客户端 end 行为,无需实际动作。
}
}
}));
import { createSshSession, encodeInputForSsh, INIT_BEGIN_FOR_TEST, INIT_DONE_FOR_TEST } from "./sshSession";
describe("sshSession", () => {
beforeEach(() => {
connectSpy.mockReset();
streamWriteSpy.mockReset();
capturedStream = null;
});
it("密码认证显式优先 keyboard-interactive 再回退 password", async () => {
await expect(
createSshSession({
host: "127.0.0.1",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: () => {},
onStderr: () => {},
onClose: () => {}
})
).rejects.toThrow("mock connect failed");
expect(connectSpy).toHaveBeenCalledTimes(1);
const config = connectSpy.mock.calls[0]?.[0] as {
authHandler?: Array<{ type: string; username: string; password?: string; prompt?: (...args: unknown[]) => void }>;
tryKeyboard?: boolean;
password?: string;
};
expect(config.password).toBe("secret");
expect(config.tryKeyboard).toBe(true);
expect(config.authHandler).toHaveLength(2);
expect(config.authHandler?.[0]?.type).toBe("keyboard-interactive");
expect(config.authHandler?.[0]?.username).toBe("gavin");
expect(typeof config.authHandler?.[0]?.prompt).toBe("function");
expect(config.authHandler?.[1]).toEqual({
type: "password",
username: "gavin",
password: "secret"
});
});
it("私钥认证不应注入密码认证策略", async () => {
await expect(
createSshSession({
host: "127.0.0.1",
port: 22,
username: "gavin",
credential: { type: "privateKey", privateKey: "mock-key" },
pty: { cols: 80, rows: 24 },
onStdout: () => {},
onStderr: () => {},
onClose: () => {}
})
).rejects.toThrow("mock connect failed");
expect(connectSpy).toHaveBeenCalledTimes(1);
const config = connectSpy.mock.calls[0]?.[0] as { authHandler?: string[]; privateKey?: string };
expect(config.privateKey).toBe("mock-key");
expect(config.authHandler).toBeUndefined();
});
it("字节串样式输入应按 latin1 还原为原始字节", () => {
const byteString = "\u00e4\u00b8\u00ad\u00e6\u0096\u0087";
const encoded = encodeInputForSsh(byteString);
expect(typeof encoded).toBe("string");
expect(encoded).toBe("中文");
});
it("普通 Unicode 输入应保持字符串,避免误判", () => {
const encoded = encodeInputForSsh("中文");
expect(typeof encoded).toBe("string");
expect(encoded).toBe("中文");
});
it("混合输入Unicode + 字节串)应归一为正确 Unicode 字符串", () => {
const mixed = "测\u00e8\u00af\u0095";
const encoded = encodeInputForSsh(mixed);
expect(typeof encoded).toBe("string");
expect(encoded).toBe("测试");
});
it("非法高位噪声应过滤 C1 控制字符", () => {
const encoded = encodeInputForSsh("\u008b\u0095");
expect(typeof encoded).toBe("string");
expect(encoded).toBe("");
});
it("gateway 建链后应分三次写入w1=stty-echo+BEGIN, w2=init, w3=stty+echo+DONE", async () => {
await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: () => {},
onStderr: () => {},
onClose: () => {}
});
// 独立三次写入,确保 BEGIN/DONE 即使 init 失败也一定发出
expect(streamWriteSpy).toHaveBeenCalledTimes(3);
const w1 = Buffer.isBuffer(streamWriteSpy.mock.calls[0]?.[0])
? (streamWriteSpy.mock.calls[0][0] as Buffer).toString("utf8")
: String(streamWriteSpy.mock.calls[0]?.[0]);
const w2 = Buffer.isBuffer(streamWriteSpy.mock.calls[1]?.[0])
? (streamWriteSpy.mock.calls[1][0] as Buffer).toString("utf8")
: String(streamWriteSpy.mock.calls[1]?.[0]);
const w3 = Buffer.isBuffer(streamWriteSpy.mock.calls[2]?.[0])
? (streamWriteSpy.mock.calls[2][0] as Buffer).toString("utf8")
: String(streamWriteSpy.mock.calls[2]?.[0]);
// w1: 关回显 + BEGIN 哨兵
expect(w1).toContain("stty -echo");
expect(w1).toContain("RCSBEGIN");
// w2: shell 初始化,不含 ${VAR:-} 语法,兼容 bash/dash
expect(w2).toContain("setopt MULTIBYTE PRINT_EIGHT_BIT");
expect(w2).not.toContain("${"); // 不用 parameter expansion 默认值语法
// w3: 开回显 + DONE 哨兵
expect(w3).toContain("stty echo");
expect(w3).toContain("RCSDONE");
});
it("配置跳板机后应先连接 jump再通过 sock 连接 target", async () => {
await createSshSession({
host: "ready-host",
port: 22,
username: "target-user",
credential: { type: "password", password: "target-secret" },
jumpHost: {
host: "jump-host",
port: 2222,
username: "jump-user",
credential: { type: "privateKey", privateKey: "jump-key" }
},
pty: { cols: 80, rows: 24 },
onStdout: () => {},
onStderr: () => {},
onClose: () => {}
});
expect(connectSpy).toHaveBeenCalledTimes(2);
const jumpConfig = connectSpy.mock.calls[0]?.[0] as { host?: string; username?: string; privateKey?: string };
const targetConfig = connectSpy.mock.calls[1]?.[0] as {
host?: string;
username?: string;
password?: string;
sock?: unknown;
};
expect(jumpConfig.host).toBe("jump-host");
expect(jumpConfig.username).toBe("jump-user");
expect(jumpConfig.privateKey).toBe("jump-key");
expect(targetConfig.host).toBe("ready-host");
expect(targetConfig.username).toBe("target-user");
expect(targetConfig.password).toBe("target-secret");
expect(targetConfig.sock).toBeTruthy();
});
it("BEGIN 前的内容Last login保留BEGIN→DONE 之间丢弃DONE 后正常转发", async () => {
const received: string[] = [];
await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: (d) => received.push(d),
onStderr: () => {},
onClose: () => {}
});
const ch = capturedStream!;
// Last loginsshd 在 shell 启动前输出)
ch.emit("data", Buffer.from("Last login: Wed Feb 25 10:20:19 2026\r\n", "utf8"));
expect(received).toHaveLength(0); // BEGIN 未到,缓冲中
// BEGIN 哨兵 + 命令回显init 命令被 zsh echo
// 真实场景echo 输出为 SENTINEL\r\n命令回显里 sentinel 后面跟 '
const beginLine = Buffer.from(INIT_BEGIN_FOR_TEST + "\r\n", "utf8");
ch.emit("data", beginLine.subarray(0, 3));
ch.emit("data", Buffer.concat([
beginLine.subarray(3),
Buffer.from("stty iutf8 2>/dev/null; setopt MULTIBYTE...\r\n", "utf8")
]));
// BEGIN 之前的 Last login 应已转发
expect(received.join("")).toContain("Last login:");
// init 命令回显在 BEGIN 之后,在丢弃区内
expect(received.join("")).not.toContain("setopt MULTIBYTE");
const prevLen = received.length;
// DONE 哨兵(分两个 chunk 验证跨 chunkSENTINEL\r\n 中途截断)
const doneLine = Buffer.from(INIT_DONE_FOR_TEST + "\r\n", "utf8");
ch.emit("data", doneLine.subarray(0, 3));
expect(received.length).toBe(prevLen); // DONE 未完整,仍丢弃
ch.emit("data", Buffer.concat([
doneLine.subarray(3),
Buffer.from("gavin mini ~ % ", "utf8")
]));
const output = received.join("");
expect(output).toContain("Last login:"); // 保留
expect(output).not.toContain("setopt MULTIBYTE"); // init 回显已丢弃
expect(output).toContain("gavin mini ~ %"); // DONE 后正常转发
});
it("rawBefore 末尾含命令回显prompt + stty -echo...)时,仅保留 banner丢弃回显行", async () => {
const received: string[] = [];
await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: (d) => received.push(d),
onStderr: () => {},
onClose: () => {}
});
const ch = capturedStream!;
// 真实 SSH 场景banner → 命令回显 → BEGIN
// sshd banner
ch.emit("data", Buffer.from("Last login: Wed Feb 25 10:00:00 2026\r\n\r\n", "utf8"));
// PTY 对 W1 的 echo提示符 + 命令 + CRLF由 PTY ONLCR 添加)
ch.emit("data", Buffer.from("~ % stty -echo; echo '__RCSBEGIN_7f3a__'\r\n", "utf8"));
// BEGIN 哨兵本体echo 输出为 SENTINEL\r\nPTY ONLCR
ch.emit("data", Buffer.from(INIT_BEGIN_FOR_TEST + "\r\n", "utf8"));
// W2 回显(在丢弃区内)
ch.emit("data", Buffer.from("stty iutf8; setopt MULTIBYTE...\r\n", "utf8"));
// DONE 哨兵命令回显sentinel 后跟 '+ 实际 echo 输出sentinel + \r\n
// 这正是产生 bug 的场景:命令回显里有 __RCSDONE_7f3a__' ,实际输出是 __RCSDONE_7f3a__\r\n
ch.emit("data", Buffer.from("stty echo; echo '__RCSDONE_7f3a__'\r\n", "utf8"));
ch.emit("data", Buffer.concat([
Buffer.from(INIT_DONE_FOR_TEST + "\r\n", "utf8"),
Buffer.from("gavin mini ~ % ", "utf8")
]));
const output = received.join("");
expect(output).toContain("Last login:"); // banner 保留
expect(output).not.toContain("stty -echo"); // 命令回显被丢弃
expect(output).not.toContain("setopt MULTIBYTE"); // init 回显在丢弃区内
expect(output).not.toContain("__RCSDONE_7f3a__"); // DONE 哨兵本身不可见
expect(output).not.toContain("stty echo"); // W3 命令回显不可见
expect(output).toContain("gavin mini ~ %"); // DONE 后正常转发
});
it("哨兵行仅为 LF 时,仍应过滤内部初始化命令", async () => {
const received: string[] = [];
await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: (d) => received.push(d),
onStderr: () => {},
onClose: () => {}
});
const ch = capturedStream!;
ch.emit("data", Buffer.from("Last login: Wed Feb 25 12:07:16 2026 from 202.96.99.162\n", "utf8"));
ch.emit("data", Buffer.from("~ % stty -echo; echo '__RCSBEGIN_7f3a__'\n", "utf8"));
ch.emit("data", Buffer.from(INIT_BEGIN_FOR_TEST + "\n", "utf8"));
ch.emit("data", Buffer.from("stty iutf8; setopt MULTIBYTE...\n", "utf8"));
ch.emit("data", Buffer.from("stty echo; echo '__RCSDONE_7f3a__'\n", "utf8"));
ch.emit("data", Buffer.concat([
Buffer.from(INIT_DONE_FOR_TEST + "\n", "utf8"),
Buffer.from("gavin mini ~ % ", "utf8")
]));
const output = received.join("");
expect(output).toContain("Last login:");
expect(output).toContain("gavin mini ~ %");
expect(output).not.toContain("stty -echo");
expect(output).not.toContain("setopt MULTIBYTE");
expect(output).not.toContain("stty echo");
expect(output).not.toContain("__RCSBEGIN_7f3a__");
expect(output).not.toContain("__RCSDONE_7f3a__");
});
it("BEGIN/DONE 未命中并超时时,仍应兜底清理内部初始化命令", async () => {
const received: string[] = [];
vi.useFakeTimers();
try {
await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: (d) => received.push(d),
onStderr: () => {},
onClose: () => {}
});
const ch = capturedStream!;
ch.emit("data", Buffer.from("Activate the web console with: systemctl enable --now cockpit.socket\r\n", "utf8"));
ch.emit("data", Buffer.from("Last login: Wed Feb 25 12:20:32 2026 from 115.193.12.66\r\n", "utf8"));
ch.emit("data", Buffer.from("stty -echo; echo '__RCSBEGIN_7f3a__'\r\n", "utf8"));
ch.emit("data", Buffer.from("stty iutf8 2>/dev/null; setopt MULTIBYTE PRINT_EIGHT_BIT 2>/dev/null; unsetopt PROMPT_SP 2>/dev/null; PROMPT_EOL_MARK=''\r\n", "utf8"));
ch.emit("data", Buffer.from("stty echo; echo '__RCSDONE_7f3a__'\r\n", "utf8"));
ch.emit("data", Buffer.from("[gavin@kvm-douboer ~]$ ", "utf8"));
await vi.advanceTimersByTimeAsync(3100);
const output = received.join("");
expect(output).toContain("Activate the web console");
expect(output).toContain("Last login:");
expect(output).toContain("[gavin@kvm-douboer ~]$");
expect(output).not.toContain("stty -echo; echo");
expect(output).not.toContain("stty iutf8");
expect(output).not.toContain("setopt MULTIBYTE");
expect(output).not.toContain("PROMPT_EOL_MARK");
expect(output).not.toContain("stty echo; echo");
expect(output).not.toContain("__RCSBEGIN_7f3a__");
expect(output).not.toContain("__RCSDONE_7f3a__");
} finally {
vi.useRealTimers();
}
});
it("主动 close 后不应重复触发 onClose避免 switch 日志重复)", async () => {
const closeReasons: string[] = [];
const session = await createSshSession({
host: "ready-host",
port: 22,
username: "gavin",
credential: { type: "password", password: "secret" },
pty: { cols: 80, rows: 24 },
onStdout: () => {},
onStderr: () => {},
onClose: (reason) => closeReasons.push(reason)
});
session.close("switch");
expect(closeReasons).toEqual(["switch"]);
});
});

View File

@@ -0,0 +1,517 @@
import { Client, type ClientChannel, type ConnectConfig } from "ssh2";
import { StringDecoder } from "node:string_decoder";
import { config } from "../config";
interface SshHopOptions {
host: string;
port: number;
username: string;
credential:
| { type: "password"; password: string }
| { type: "privateKey"; privateKey: string; passphrase?: string }
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
knownHostFingerprint?: string;
}
export interface SshConnectOptions {
host: string;
port: number;
username: string;
credential:
| { type: "password"; password: string }
| { type: "privateKey"; privateKey: string; passphrase?: string }
| { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
jumpHost?: SshHopOptions;
pty: { cols: number; rows: number };
knownHostFingerprint?: string;
onHostFingerprint?: (payload: { fingerprint: string; hostPort: string; role: "target" | "jump" }) => void;
onStdout: (data: string) => void;
onStderr: (data: string) => void;
onClose: (reason: string) => void;
}
export interface ActiveSshSession {
write(data: string, traceId?: number): void;
resize(cols: number, rows: number): void;
close(reason?: string): void;
}
/**
* 初始化哨兵:用于标记“静默初始化命令”的开始与结束。
*/
const INIT_BEGIN = "__RCSBEGIN_7f3a__";
const INIT_DONE = "__RCSDONE_7f3a__";
/**
* 初始化命令分三段写入:
* 1) 关闭回显 + BEGIN 哨兵
* 2) shell 兼容初始化
* 3) 打开回显 + DONE 哨兵
*/
const SHELL_INIT_W1 = "stty -echo; echo '__RCSBEGIN_7f3a__'\r";
const SHELL_INIT_W2 = [
"stty iutf8 2>/dev/null",
'; [ -z "$LANG" ] && export LANG=zh_CN.UTF-8',
'; [ -z "$LC_CTYPE" ] && export LC_CTYPE=zh_CN.UTF-8',
'; [ -z "$LC_ALL" ] && export LC_ALL=zh_CN.UTF-8',
"; setopt MULTIBYTE PRINT_EIGHT_BIT 2>/dev/null",
"; unsetopt PROMPT_SP 2>/dev/null",
"; PROMPT_EOL_MARK='' 2>/dev/null"
].join("") + "\r";
const SHELL_INIT_W3 = "stty echo; echo '__RCSDONE_7f3a__'\r";
/**
* 这些特征行一旦出现在 stdout说明是网关内部初始化泄漏必须过滤。
*/
const INIT_LEAK_PATTERNS = [
"stty -echo; echo '__RCSBEGIN_7f3a__'",
"stty iutf8 2>/dev/null",
"setopt MULTIBYTE PRINT_EIGHT_BIT",
"unsetopt PROMPT_SP",
"PROMPT_EOL_MARK=''",
"stty echo; echo '__RCSDONE_7f3a__'",
INIT_BEGIN,
INIT_DONE
];
/** @internal 仅供测试调用。 */
export const INIT_BEGIN_FOR_TEST = INIT_BEGIN;
/** @internal 仅供测试调用。 */
export const INIT_DONE_FOR_TEST = INIT_DONE;
interface SentinelMatch {
index: number;
end: number;
}
/**
* 输入去噪:去除 C1 控制字符0x80-0x9F
*/
function stripC1Controls(data: string): string {
return data.replace(/[\u0080-\u009F]/g, "");
}
/**
* 判断字节串是否可无损按 UTF-8 解释。
*/
function isLosslessUtf8Bytes(bytes: Buffer): boolean {
const decoded = bytes.toString("utf8");
if (decoded.includes("\uFFFD")) {
return false;
}
return Buffer.from(decoded, "utf8").equals(bytes);
}
function decodeByteRun(bytes: number[]): string {
if (bytes.length === 0) {
return "";
}
const buf = Buffer.from(bytes);
if (isLosslessUtf8Bytes(buf)) {
return buf.toString("utf8");
}
return String.fromCharCode(...bytes.filter((b) => (b >= 0x20 && b <= 0x7e) || b === 0x09));
}
/**
* 混合输入归一:宽字符原样保留,单字节段按 UTF-8 尝试解码。
*/
function normalizeMixedInput(data: string): string {
let output = "";
let byteRun: number[] = [];
const flushByteRun = (): void => {
output += decodeByteRun(byteRun);
byteRun = [];
};
for (const ch of data) {
const codePoint = ch.codePointAt(0) ?? 0;
if (codePoint <= 0xff) {
byteRun.push(codePoint);
continue;
}
flushByteRun();
output += ch;
}
flushByteRun();
return output;
}
/**
* 将前端输入转换为 SSH 可写内容。
*/
export function encodeInputForSsh(data: string): string {
let hasHighByte = false;
let hasWideChar = false;
for (let i = 0; i < data.length; i += 1) {
const code = data.charCodeAt(i);
if (code > 0xff) {
hasWideChar = true;
continue;
}
if (code >= 0x80) {
hasHighByte = true;
}
}
if (hasWideChar) {
return normalizeMixedInput(data).replace(/\uFFFD/g, "");
}
if (!hasHighByte) {
return data;
}
const bytes = Buffer.from(data, "latin1");
if (isLosslessUtf8Bytes(bytes)) {
return bytes.toString("utf8");
}
return stripC1Controls(data);
}
/**
* 匹配哨兵真实输出行SENTINEL + \n 或 \r\n
* 注意:不会误匹配命令回显里的 `echo '__RCS...__'`(后面是单引号)。
*/
function findSentinelLine(buffer: string, sentinel: string): SentinelMatch | null {
let from = 0;
while (from < buffer.length) {
const index = buffer.indexOf(sentinel, from);
if (index < 0) {
return null;
}
const next = buffer[index + sentinel.length];
const nextNext = buffer[index + sentinel.length + 1];
if (next === "\n") {
return { index, end: index + sentinel.length + 1 };
}
if (next === "\r" && nextNext === "\n") {
return { index, end: index + sentinel.length + 2 };
}
// 落在 chunk 边界,等下个 chunk 再判定。
if (next === undefined || (next === "\r" && nextNext === undefined)) {
return null;
}
from = index + 1;
}
return null;
}
/**
* 兜底清理初始化泄漏内容,保留 banner 与提示符。
*/
function sanitizeInitLeakOutput(text: string): string {
return text
.split(/(\r\n|\n|\r)/)
.reduce((acc, current, index, parts) => {
if (index % 2 === 1) {
return acc;
}
const shouldDrop = INIT_LEAK_PATTERNS.some((pattern) => current.includes(pattern));
if (shouldDrop) {
return acc;
}
return acc + current + (parts[index + 1] ?? "");
}, "");
}
/**
* 统一可读错误信息,便于前端提示用户。
*/
function normalizeSshError(error: Error): Error {
const raw = String(error.message ?? "");
if (raw.includes("All configured authentication methods failed")) {
return new Error(
`SSH 认证失败:用户名/密码不正确,或目标服务器要求额外认证(如 publickey + keyboard-interactive 多因子)。原始错误: ${raw}`
);
}
if (raw.includes("Timed out while waiting for handshake")) {
return new Error(`SSH 握手超时,请检查目标主机连通性与端口(原始错误: ${raw}`);
}
return error;
}
function toPrivateKeyPayload(
credential: Extract<SshHopOptions["credential"], { type: "privateKey" | "certificate" }>
): string {
if (credential.type === "certificate") {
return `${credential.privateKey}\n${credential.certificate}`;
}
return credential.privateKey;
}
/**
* 统一构造 ssh2 connect 配置:
* - 密码模式优先 keyboard-interactive再回退 password
* - 私钥/证书模式直接走 publickey
* - hostVerifier 在每个 hop 独立上报指纹,便于前端分别确认。
*/
function buildSshConnectConfig(
hop: SshHopOptions,
onHostFingerprint: SshConnectOptions["onHostFingerprint"],
role: "target" | "jump",
sock?: ConnectConfig["sock"]
): ConnectConfig {
const hostPort = `${hop.host}:${hop.port}`;
const baseConfig: ConnectConfig = {
host: hop.host,
port: hop.port,
username: hop.username,
readyTimeout: config.sshReadyTimeoutMs,
keepaliveInterval: config.sshKeepaliveIntervalMs,
keepaliveCountMax: config.sshKeepaliveCountMax,
hostHash: "sha256",
...(sock ? { sock } : {}),
hostVerifier: (keyHash: string) => {
onHostFingerprint?.({ fingerprint: keyHash, hostPort, role });
return !hop.knownHostFingerprint || keyHash === hop.knownHostFingerprint;
}
};
if (hop.credential.type === "password") {
const authPassword = hop.credential.password;
const authHandler = [
{
type: "keyboard-interactive",
username: hop.username,
prompt(
_name: string,
_instructions: string,
_lang: string,
prompts: Array<{ prompt: string; echo?: boolean }>,
finish: (responses: string[]) => void
) {
finish(prompts.map(() => authPassword));
}
},
{ type: "password", username: hop.username, password: authPassword }
] as unknown as ConnectConfig["authHandler"];
return {
...baseConfig,
password: authPassword,
tryKeyboard: true,
authHandler
};
}
return {
...baseConfig,
privateKey: toPrivateKeyPayload(hop.credential),
passphrase: hop.credential.passphrase
};
}
/**
* 建立 SSH 会话并返回可操作句柄。
* 仅保留已验证有效的链路:双哨兵过滤 + 超时兜底净化。
*/
export async function createSshSession(options: SshConnectOptions): Promise<ActiveSshSession> {
const targetConn = new Client();
const jumpConn = options.jumpHost ? new Client() : null;
return await new Promise<ActiveSshSession>((resolve, reject) => {
let streamRef: {
write: (data: string | Buffer) => void;
setWindow: (rows: number, cols: number, height: number, width: number) => void;
close: () => void;
on: (event: string, listener: (...args: unknown[]) => void) => void;
stderr: { on: (event: string, listener: (chunk: Buffer) => void) => void };
} | null = null;
let closeReported = false;
/**
* 关闭原因只允许上报一次,避免 `close()` / `stream.close` / `conn.close`
* 多路并发触发导致日志重复。
*/
const reportCloseOnce = (reason: string): void => {
if (closeReported) {
return;
}
closeReported = true;
options.onClose(reason);
};
const onError = (error: Error): void => {
reject(normalizeSshError(error));
targetConn.end();
jumpConn?.end();
};
const openShell = (): void => {
targetConn.shell(
{ cols: options.pty.cols, rows: options.pty.rows, term: "xterm-256color", modes: { ECHO: 0 } },
(shellError: Error | undefined, stream: ClientChannel) => {
if (shellError) {
onError(shellError);
return;
}
streamRef = stream as typeof streamRef;
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
let initState: "waiting_begin" | "waiting_done" | "done" = "waiting_begin";
let initBuffer = "";
const initTimeoutHandle = setTimeout(() => {
if (initState !== "done") {
initState = "done";
const sanitized = sanitizeInitLeakOutput(initBuffer);
if (sanitized) {
options.onStdout(sanitized);
}
initBuffer = "";
}
}, 3000);
stream.on("data", (chunk: Buffer) => {
const text = stdoutDecoder.write(chunk);
if (!text) {
return;
}
if (initState === "done") {
options.onStdout(text);
return;
}
initBuffer += text;
if (initState === "waiting_begin") {
const beginMatch = findSentinelLine(initBuffer, INIT_BEGIN);
if (!beginMatch) {
return;
}
const rawBefore = initBuffer.slice(0, beginMatch.index);
const echoIndex = rawBefore.lastIndexOf("stty -echo");
const newlineBeforeEcho = echoIndex >= 0 ? rawBefore.lastIndexOf("\n", echoIndex) : -1;
const before =
echoIndex >= 0 ? (newlineBeforeEcho >= 0 ? rawBefore.slice(0, newlineBeforeEcho + 1) : "") : rawBefore;
initBuffer = initBuffer.slice(beginMatch.end);
initState = "waiting_done";
const sanitizedBefore = sanitizeInitLeakOutput(before);
if (sanitizedBefore) {
options.onStdout(sanitizedBefore);
}
}
if (initState === "waiting_done") {
const doneMatch = findSentinelLine(initBuffer, INIT_DONE);
if (!doneMatch) {
const keepTail = INIT_DONE.length + 2;
if (initBuffer.length > keepTail) {
initBuffer = initBuffer.slice(-keepTail);
}
return;
}
const after = sanitizeInitLeakOutput(initBuffer.slice(doneMatch.end));
initBuffer = "";
initState = "done";
clearTimeout(initTimeoutHandle);
if (after) {
options.onStdout(after);
}
}
});
stream.stderr.on("data", (chunk: Buffer) => {
const text = stderrDecoder.write(chunk);
if (text) {
options.onStderr(text);
}
});
try {
stream.write(Buffer.from(SHELL_INIT_W1, "utf8"));
stream.write(Buffer.from(SHELL_INIT_W2, "utf8"));
stream.write(Buffer.from(SHELL_INIT_W3, "utf8"));
} catch {
clearTimeout(initTimeoutHandle);
initState = "done";
}
stream.on("close", () => {
clearTimeout(initTimeoutHandle);
const remainOut = stdoutDecoder.end();
if (remainOut) {
options.onStdout(remainOut);
}
const remainErr = stderrDecoder.end();
if (remainErr) {
options.onStderr(remainErr);
}
reportCloseOnce("shell_closed");
targetConn.end();
jumpConn?.end();
});
resolve({
write(data: string, _traceId?: number) {
const payload = encodeInputForSsh(data).replace(/\uFFFD/g, "");
if (payload) {
streamRef?.write(Buffer.from(payload, "utf8"));
}
},
resize(cols: number, rows: number) {
streamRef?.setWindow(rows, cols, 0, 0);
},
close(reason = "manual") {
reportCloseOnce(reason);
streamRef?.close();
targetConn.end();
jumpConn?.end();
}
});
}
);
};
targetConn.on("ready", openShell);
targetConn.on("error", onError);
targetConn.on("close", () => {
reportCloseOnce("connection_closed");
});
const targetHop: SshHopOptions = {
host: options.host,
port: options.port,
username: options.username,
credential: options.credential,
knownHostFingerprint: options.knownHostFingerprint
};
if (!options.jumpHost) {
targetConn.connect(buildSshConnectConfig(targetHop, options.onHostFingerprint, "target"));
return;
}
jumpConn?.on("error", onError);
jumpConn?.on("close", () => {
reportCloseOnce("jump_connection_closed");
});
jumpConn?.on("ready", () => {
jumpConn.forwardOut("127.0.0.1", 0, options.host, options.port, (error, stream) => {
if (error || !stream) {
onError(error ?? new Error("jump forward stream missing"));
return;
}
targetConn.connect(buildSshConnectConfig(targetHop, options.onHostFingerprint, "target", stream));
});
});
jumpConn?.connect(buildSshConnectConfig(options.jumpHost, options.onHostFingerprint, "jump"));
});
}

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
process.env.MINIPROGRAM_APP_ID = "wx-test-app";
process.env.MINIPROGRAM_APP_SECRET = "wx-test-secret";
process.env.SYNC_SECRET_CURRENT = "sync-secret-for-test";
const { createSyncToken, decryptSecretPayload, encryptSecretPayload, verifySyncToken } = await import(
"./crypto"
);
describe("sync crypto", () => {
it("应能加解密敏感凭据", () => {
const source = {
password: "pw-123456",
privateKey: "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----"
};
const encrypted = encryptSecretPayload(source);
const decrypted = decryptSecretPayload(encrypted.secretBlob, encrypted.secretVersion);
expect(decrypted).toEqual(source);
});
it("应能签发并校验同步 token", () => {
const session = createSyncToken("user-1", "openid-1");
const payload = verifySyncToken(session.token);
expect(payload.uid).toBe("user-1");
expect(payload.oid).toBe("openid-1");
expect(payload.exp).toBeGreaterThan(Date.now());
});
});

View File

@@ -0,0 +1,119 @@
import { createCipheriv, createDecipheriv, createHash, createHmac, timingSafeEqual, randomBytes } from "node:crypto";
import { config } from "../config";
interface EncryptedSecretBlob {
alg: "aes-256-gcm";
keyVersion: number;
iv: string;
tag: string;
ciphertext: string;
}
interface SyncTokenPayload {
uid: string;
oid: string;
exp: number;
}
function toBase64Url(input: Buffer | string): string {
const source = Buffer.isBuffer(input) ? input : Buffer.from(input, "utf8");
return source
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function fromBase64Url(input: string): Buffer {
const normalized = String(input || "")
.replace(/-/g, "+")
.replace(/_/g, "/");
const padded = normalized + "=".repeat((4 - (normalized.length % 4 || 4)) % 4);
return Buffer.from(padded, "base64");
}
function deriveKey(label: string): Buffer {
return createHash("sha256")
.update(`${config.sync.secretCurrent}:${label}`, "utf8")
.digest();
}
function stableJson(value: unknown): string {
return JSON.stringify(value ?? {});
}
export function encryptSecretPayload(payload: unknown): { secretBlob: string; secretVersion: number } {
const plaintext = Buffer.from(stableJson(payload), "utf8");
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", deriveKey("sync-secret"), iv);
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
const tag = cipher.getAuthTag();
const blob: EncryptedSecretBlob = {
alg: "aes-256-gcm",
keyVersion: config.sync.secretVersion,
iv: toBase64Url(iv),
tag: toBase64Url(tag),
ciphertext: toBase64Url(ciphertext)
};
return {
secretBlob: JSON.stringify(blob),
secretVersion: config.sync.secretVersion
};
}
export function decryptSecretPayload(secretBlob: string, secretVersion: number): Record<string, unknown> {
if (!secretBlob) {
return {};
}
if (secretVersion !== config.sync.secretVersion) {
throw new Error("unsupported secret version");
}
const blob = JSON.parse(secretBlob) as EncryptedSecretBlob;
const decipher = createDecipheriv("aes-256-gcm", deriveKey("sync-secret"), fromBase64Url(blob.iv));
decipher.setAuthTag(fromBase64Url(blob.tag));
const plaintext = Buffer.concat([
decipher.update(fromBase64Url(blob.ciphertext)),
decipher.final()
]).toString("utf8");
const parsed = JSON.parse(plaintext) as Record<string, unknown>;
return parsed && typeof parsed === "object" ? parsed : {};
}
export function createSyncToken(userId: string, openid: string): { token: string; expiresAt: string } {
const exp = Date.now() + config.sync.tokenTtlSec * 1000;
const payload: SyncTokenPayload = {
uid: userId,
oid: openid,
exp
};
const encodedPayload = toBase64Url(JSON.stringify(payload));
const signature = createHmac("sha256", deriveKey("sync-token"))
.update(encodedPayload, "utf8")
.digest();
return {
token: `v1.${encodedPayload}.${toBase64Url(signature)}`,
expiresAt: new Date(exp).toISOString()
};
}
export function verifySyncToken(token: string): SyncTokenPayload {
const [version, encodedPayload, encodedSignature] = String(token || "").split(".");
if (version !== "v1" || !encodedPayload || !encodedSignature) {
throw new Error("invalid token format");
}
const expected = createHmac("sha256", deriveKey("sync-token"))
.update(encodedPayload, "utf8")
.digest();
const actual = fromBase64Url(encodedSignature);
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
throw new Error("invalid token signature");
}
const payload = JSON.parse(fromBase64Url(encodedPayload).toString("utf8")) as SyncTokenPayload;
if (!payload || typeof payload !== "object" || !payload.uid || !payload.oid || !Number.isFinite(payload.exp)) {
throw new Error("invalid token payload");
}
if (Date.now() >= payload.exp) {
throw new Error("token expired");
}
return payload;
}

View File

@@ -0,0 +1,105 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterEach, describe, expect, it } from "vitest";
process.env.MINIPROGRAM_APP_ID = "wx-test-app";
process.env.MINIPROGRAM_APP_SECRET = "wx-test-secret";
process.env.SYNC_SECRET_CURRENT = "sync-secret-for-test";
const { SyncRepository } = await import("./repository");
const { initializeSyncDb } = await import("./sqlite");
const tempDirs: string[] = [];
function createRepository() {
const dir = mkdtempSync(path.join(tmpdir(), "remoteconn-sync-"));
tempDirs.push(dir);
const db = initializeSyncDb(new DatabaseSync(path.join(dir, "sync.db")));
return new SyncRepository(db);
}
afterEach(() => {
while (tempDirs.length) {
const dir = tempDirs.pop();
if (!dir) break;
rmSync(dir, { recursive: true, force: true });
}
});
describe("sync repository", () => {
it("应保存并取回带加密凭据的服务器配置", () => {
const repo = createRepository();
const user = repo.getOrCreateUser("openid-user");
repo.upsertServers(user.id, [
{
id: "srv-1",
name: "server-1",
tags: ["prod"],
host: "10.0.0.1",
port: 22,
username: "root",
authType: "privateKey",
projectPath: "~/workspace",
timeoutSeconds: 20,
heartbeatSeconds: 15,
transportMode: "gateway",
jumpHost: {
enabled: true,
host: "10.0.0.2",
port: 22,
username: "jump",
authType: "password"
},
sortOrder: 1,
lastConnectedAt: "",
updatedAt: "2026-03-09T00:00:00.000Z",
deletedAt: null,
password: "",
privateKey: "secret-key",
passphrase: "secret-passphrase",
certificate: "",
jumpPassword: "jump-secret",
jumpPrivateKey: "",
jumpPassphrase: "",
jumpCertificate: ""
}
]);
const rows = repo.listServers(user.id);
expect(rows).toHaveLength(1);
const first = rows[0];
expect(first).toBeDefined();
expect(first && first.host).toBe("10.0.0.1");
expect(first && first.privateKey).toBe("secret-key");
expect(first && first.jumpPassword).toBe("jump-secret");
});
it("应保存并返回闪念记录", () => {
const repo = createRepository();
const user = repo.getOrCreateUser("openid-user-2");
repo.upsertRecords(user.id, [
{
id: "rec-1",
content: "deploy before 18:00",
serverId: "srv-1",
category: "问题",
contextLabel: "prod-api",
processed: false,
discarded: true,
createdAt: "2026-03-09T00:00:00.000Z",
updatedAt: "2026-03-09T00:10:00.000Z",
deletedAt: null
}
]);
const rows = repo.listRecords(user.id);
expect(rows).toHaveLength(1);
const first = rows[0];
expect(first).toBeDefined();
expect(first && first.content).toBe("deploy before 18:00");
expect(first && first.category).toBe("问题");
expect(first && first.discarded).toBe(true);
});
});

View File

@@ -0,0 +1,254 @@
import { randomUUID } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import { decryptSecretPayload, encryptSecretPayload } from "./crypto";
import { getSyncDb } from "./sqlite";
import type { SyncRecord, SyncServer, SyncServerCommon, SyncServerSecret, SyncSettingsPayload } from "./schema";
export interface UserRow {
id: string;
openid: string;
unionid: string | null;
created_at: string;
updated_at: string;
}
function nowIso(): string {
return new Date().toISOString();
}
function parseJsonObject(input: string): Record<string, unknown> {
try {
const parsed = JSON.parse(input);
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
function parseJsonArrayLike<T>(input: string): T {
return JSON.parse(input) as T;
}
function pickServerCommon(server: SyncServer): SyncServerCommon {
return {
id: server.id,
name: server.name,
tags: server.tags,
host: server.host,
port: server.port,
username: server.username,
authType: server.authType,
projectPath: server.projectPath,
timeoutSeconds: server.timeoutSeconds,
heartbeatSeconds: server.heartbeatSeconds,
transportMode: server.transportMode,
jumpHost: server.jumpHost,
sortOrder: server.sortOrder,
lastConnectedAt: server.lastConnectedAt,
updatedAt: server.updatedAt,
deletedAt: server.deletedAt ?? null
};
}
function pickServerSecrets(server: SyncServer): SyncServerSecret {
return {
password: server.password,
privateKey: server.privateKey,
passphrase: server.passphrase,
certificate: server.certificate,
jumpPassword: server.jumpPassword,
jumpPrivateKey: server.jumpPrivateKey,
jumpPassphrase: server.jumpPassphrase,
jumpCertificate: server.jumpCertificate
};
}
export class SyncRepository {
private readonly db: DatabaseSync;
constructor(database: DatabaseSync = getSyncDb()) {
this.db = database;
}
getOrCreateUser(openid: string, unionid?: string | null): UserRow {
const found = this.db
.prepare("SELECT id, openid, unionid, created_at, updated_at FROM users WHERE openid = ?")
.get(openid) as UserRow | undefined;
if (found) {
if (unionid && unionid !== found.unionid) {
const updatedAt = nowIso();
this.db
.prepare("UPDATE users SET unionid = ?, updated_at = ? WHERE id = ?")
.run(unionid, updatedAt, found.id);
found.unionid = unionid;
found.updated_at = updatedAt;
}
return found;
}
const row: UserRow = {
id: randomUUID(),
openid,
unionid: unionid || null,
created_at: nowIso(),
updated_at: nowIso()
};
this.db
.prepare(
"INSERT INTO users (id, openid, unionid, created_at, updated_at) VALUES (?, ?, ?, ?, ?)"
)
.run(row.id, row.openid, row.unionid, row.created_at, row.updated_at);
return row;
}
getSettings(userId: string): SyncSettingsPayload | null {
const row = this.db
.prepare("SELECT settings_json, updated_at FROM user_settings WHERE user_id = ?")
.get(userId) as { settings_json: string; updated_at: string } | undefined;
if (!row) return null;
return {
data: parseJsonObject(row.settings_json),
updatedAt: row.updated_at
};
}
upsertSettings(userId: string, payload: SyncSettingsPayload): void {
const current = this.getSettings(userId);
if (current && current.updatedAt > payload.updatedAt) {
return;
}
this.db
.prepare(
`
INSERT INTO user_settings (user_id, settings_json, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
settings_json = excluded.settings_json,
updated_at = excluded.updated_at
`
)
.run(userId, JSON.stringify(payload.data), payload.updatedAt);
}
listServers(userId: string): SyncServer[] {
const rows = this.db
.prepare(
`
SELECT server_json, secret_blob, secret_version, deleted_at
FROM user_servers
WHERE user_id = ?
ORDER BY updated_at DESC
`
)
.all(userId) as Array<{
server_json: string;
secret_blob: string | null;
secret_version: number;
deleted_at: string | null;
}>;
return rows.map((row) => {
const common = parseJsonArrayLike<SyncServerCommon>(row.server_json);
const secrets = row.secret_blob ? (decryptSecretPayload(row.secret_blob, row.secret_version) as SyncServerSecret) : {};
return {
...common,
...secrets,
deletedAt: row.deleted_at ?? common.deletedAt ?? null
} as SyncServer;
});
}
upsertServers(userId: string, servers: SyncServer[]): void {
const selectStmt = this.db.prepare(
"SELECT updated_at FROM user_servers WHERE user_id = ? AND server_id = ?"
);
const upsertStmt = this.db.prepare(`
INSERT INTO user_servers (
id, user_id, server_id, server_json, secret_blob, secret_version, updated_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, server_id) DO UPDATE SET
server_json = excluded.server_json,
secret_blob = excluded.secret_blob,
secret_version = excluded.secret_version,
updated_at = excluded.updated_at,
deleted_at = excluded.deleted_at
`);
this.db.exec("BEGIN");
try {
servers.forEach((server) => {
const current = selectStmt.get(userId, server.id) as { updated_at: string } | undefined;
if (current && current.updated_at > server.updatedAt) {
return;
}
const encrypted = encryptSecretPayload(pickServerSecrets(server));
upsertStmt.run(
randomUUID(),
userId,
server.id,
JSON.stringify(pickServerCommon(server)),
encrypted.secretBlob,
encrypted.secretVersion,
server.updatedAt,
server.deletedAt ?? null
);
});
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}
}
listRecords(userId: string): SyncRecord[] {
const rows = this.db
.prepare(
`
SELECT record_json, deleted_at
FROM user_records
WHERE user_id = ?
ORDER BY updated_at DESC
`
)
.all(userId) as Array<{ record_json: string; deleted_at: string | null }>;
return rows.map((row) => {
const record = parseJsonArrayLike<SyncRecord>(row.record_json);
return {
...record,
deletedAt: row.deleted_at ?? record.deletedAt ?? null
};
});
}
upsertRecords(userId: string, records: SyncRecord[]): void {
const selectStmt = this.db.prepare(
"SELECT updated_at FROM user_records WHERE user_id = ? AND record_id = ?"
);
const upsertStmt = this.db.prepare(`
INSERT INTO user_records (id, user_id, record_id, record_json, updated_at, deleted_at)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id, record_id) DO UPDATE SET
record_json = excluded.record_json,
updated_at = excluded.updated_at,
deleted_at = excluded.deleted_at
`);
this.db.exec("BEGIN");
try {
records.forEach((record) => {
const current = selectStmt.get(userId, record.id) as { updated_at: string } | undefined;
if (current && current.updated_at > record.updatedAt) {
return;
}
upsertStmt.run(
randomUUID(),
userId,
record.id,
JSON.stringify(record),
record.updatedAt,
record.deletedAt ?? null
);
});
this.db.exec("COMMIT");
} catch (error) {
this.db.exec("ROLLBACK");
throw error;
}
}
}

View File

@@ -0,0 +1,214 @@
import type { Express, Request, Response, NextFunction } from "express";
import { config } from "../config";
import { logger } from "../logger";
import { verifySyncToken } from "./crypto";
import { SyncRepository } from "./repository";
import {
syncLoginBodySchema,
syncRecordsPayloadSchema,
syncSettingsPayloadSchema,
syncServersPayloadSchema
} from "./schema";
import { loginMiniprogramUser } from "./userService";
import { registerMiniprogramTtsRoutes } from "../tts/routes";
interface SyncAuthedRequest extends Request {
syncUser?: {
userId: string;
openid: string;
};
}
function ensureSyncEnabled(res: Response): boolean {
if (config.sync.enabled) {
return true;
}
logger.warn(
{
hasAppId: Boolean(config.sync.miniprogramAppId),
hasAppSecret: Boolean(config.sync.miniprogramAppSecret),
hasSecretCurrent: Boolean(config.sync.secretCurrent)
},
"小程序同步服务未启用"
);
res.status(503).json({
ok: false,
code: "SYNC_DISABLED",
message: "小程序同步服务未配置"
});
return false;
}
function requireGatewayToken(req: Request, res: Response): boolean {
const token = String(req.headers["x-gateway-token"] || "");
if (token === config.gatewayToken) {
return true;
}
logger.warn(
{
path: req.path,
hasToken: Boolean(token)
},
"小程序同步登录缺少有效 gateway token"
);
res.status(401).json({
ok: false,
code: "AUTH_FAILED",
message: "gateway token 无效"
});
return false;
}
function requireSyncUser(req: SyncAuthedRequest, res: Response, next: NextFunction): void {
if (!ensureSyncEnabled(res)) return;
const auth = String(req.headers.authorization || "");
const token = auth.startsWith("Bearer ") ? auth.slice("Bearer ".length).trim() : "";
if (!token) {
logger.warn({ path: req.path }, "小程序同步请求缺少 Bearer token");
res.status(401).json({ ok: false, code: "SYNC_TOKEN_MISSING", message: "缺少同步令牌" });
return;
}
try {
const payload = verifySyncToken(token);
req.syncUser = {
userId: payload.uid,
openid: payload.oid
};
next();
} catch (error) {
logger.warn(
{
path: req.path,
err: error
},
"小程序同步请求鉴权失败"
);
res.status(401).json({
ok: false,
code: "SYNC_TOKEN_INVALID",
message: error instanceof Error ? error.message : "同步令牌无效"
});
}
}
export function registerSyncRoutes(app: Express): void {
const repository = new SyncRepository();
registerMiniprogramTtsRoutes(app, requireSyncUser);
app.post("/api/miniprogram/auth/login", async (req, res) => {
if (!ensureSyncEnabled(res) || !requireGatewayToken(req, res)) return;
const parsed = syncLoginBodySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({
ok: false,
code: "INVALID_BODY",
message: "登录参数不合法"
});
return;
}
try {
const result = await loginMiniprogramUser(parsed.data.code, repository);
res.json({
ok: true,
token: result.token,
expiresAt: result.expiresAt,
user: {
id: result.user.id
}
});
} catch (error) {
logger.warn({ err: error }, "小程序同步登录失败");
res.status(502).json({
ok: false,
code: "WECHAT_LOGIN_FAILED",
message: error instanceof Error ? error.message : "微信登录失败"
});
}
});
app.get("/api/miniprogram/sync/bootstrap", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
res.json({
ok: true,
settings: repository.getSettings(userId),
servers: repository.listServers(userId),
records: repository.listRecords(userId)
});
});
app.get("/api/miniprogram/sync/settings", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
res.json({ ok: true, settings: repository.getSettings(userId) });
});
app.put("/api/miniprogram/sync/settings", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
const parsed = syncSettingsPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ ok: false, code: "INVALID_BODY", message: "settings 参数不合法" });
return;
}
repository.upsertSettings(userId, parsed.data);
res.json({ ok: true, settings: repository.getSettings(userId) });
});
app.get("/api/miniprogram/sync/servers", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
res.json({ ok: true, servers: repository.listServers(userId) });
});
app.put("/api/miniprogram/sync/servers", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
const parsed = syncServersPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ ok: false, code: "INVALID_BODY", message: "servers 参数不合法" });
return;
}
repository.upsertServers(userId, parsed.data.servers);
res.json({ ok: true, servers: repository.listServers(userId) });
});
app.get("/api/miniprogram/sync/records", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
res.json({ ok: true, records: repository.listRecords(userId) });
});
app.put("/api/miniprogram/sync/records", requireSyncUser, (req: SyncAuthedRequest, res) => {
const userId = req.syncUser?.userId;
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
const parsed = syncRecordsPayloadSchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ ok: false, code: "INVALID_BODY", message: "records 参数不合法" });
return;
}
repository.upsertRecords(userId, parsed.data.records);
res.json({ ok: true, records: repository.listRecords(userId) });
});
}

View File

@@ -0,0 +1,82 @@
import { z } from "zod";
/**
* 小程序同步公共 schema
* 1. 第一阶段只约束必要字段,避免把本地对象完全写死;
* 2. 服务器普通字段与敏感字段拆开,便于单独加密存储。
*/
export const syncLoginBodySchema = z.object({
code: z.string().trim().min(1)
});
export const syncSettingsPayloadSchema = z.object({
updatedAt: z.string().trim().min(1),
data: z.record(z.string(), z.unknown())
});
export const syncJumpHostSchema = z.object({
enabled: z.boolean().optional().default(false),
host: z.string().optional().default(""),
port: z.number().int().min(1).max(65535).optional().default(22),
username: z.string().optional().default(""),
authType: z.enum(["password", "privateKey", "certificate"]).optional().default("password")
});
export const syncServerSecretSchema = z.object({
password: z.string().optional().default(""),
privateKey: z.string().optional().default(""),
passphrase: z.string().optional().default(""),
certificate: z.string().optional().default(""),
jumpPassword: z.string().optional().default(""),
jumpPrivateKey: z.string().optional().default(""),
jumpPassphrase: z.string().optional().default(""),
jumpCertificate: z.string().optional().default("")
});
export const syncServerCommonSchema = z.object({
id: z.string().trim().min(1),
name: z.string().optional().default(""),
tags: z.array(z.string()).optional().default([]),
host: z.string().optional().default(""),
port: z.number().int().min(1).max(65535).optional().default(22),
username: z.string().optional().default(""),
authType: z.enum(["password", "privateKey", "certificate"]).optional().default("password"),
projectPath: z.string().optional().default(""),
timeoutSeconds: z.number().int().min(1).max(3600).optional().default(15),
heartbeatSeconds: z.number().int().min(1).max(3600).optional().default(10),
transportMode: z.string().optional().default("gateway"),
jumpHost: syncJumpHostSchema.optional().default({ enabled: false, host: "", port: 22, username: "", authType: "password" }),
sortOrder: z.number().int().optional().default(0),
lastConnectedAt: z.string().optional().default(""),
updatedAt: z.string().trim().min(1),
deletedAt: z.string().trim().min(1).nullable().optional().default(null)
});
export const syncServerSchema = syncServerCommonSchema.merge(syncServerSecretSchema);
export const syncServersPayloadSchema = z.object({
servers: z.array(syncServerSchema)
});
export const syncRecordSchema = z.object({
id: z.string().trim().min(1),
content: z.string().optional().default(""),
serverId: z.string().optional().default(""),
category: z.string().optional().default("未分类"),
contextLabel: z.string().optional().default(""),
processed: z.boolean().optional().default(false),
discarded: z.boolean().optional().default(false),
createdAt: z.string().trim().min(1),
updatedAt: z.string().trim().min(1),
deletedAt: z.string().trim().min(1).nullable().optional().default(null)
});
export const syncRecordsPayloadSchema = z.object({
records: z.array(syncRecordSchema)
});
export type SyncSettingsPayload = z.infer<typeof syncSettingsPayloadSchema>;
export type SyncServer = z.infer<typeof syncServerSchema>;
export type SyncServerCommon = z.infer<typeof syncServerCommonSchema>;
export type SyncServerSecret = z.infer<typeof syncServerSecretSchema>;
export type SyncRecord = z.infer<typeof syncRecordSchema>;

View File

@@ -0,0 +1,77 @@
import { mkdirSync } from "node:fs";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { config } from "../config";
let db: DatabaseSync | null = null;
export function initializeSyncDb(database: DatabaseSync): DatabaseSync {
database.exec(`
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
openid TEXT NOT NULL UNIQUE,
unionid TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS user_settings (
user_id TEXT PRIMARY KEY,
settings_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS user_servers (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
server_id TEXT NOT NULL,
server_json TEXT NOT NULL,
secret_blob TEXT,
secret_version INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL,
deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_servers_user_server
ON user_servers(user_id, server_id);
CREATE INDEX IF NOT EXISTS idx_user_servers_user_updated
ON user_servers(user_id, updated_at);
CREATE TABLE IF NOT EXISTS user_records (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
record_id TEXT NOT NULL,
record_json TEXT NOT NULL,
updated_at TEXT NOT NULL,
deleted_at TEXT,
FOREIGN KEY (user_id) REFERENCES users(id)
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_records_user_record
ON user_records(user_id, record_id);
CREATE INDEX IF NOT EXISTS idx_user_records_user_updated
ON user_records(user_id, updated_at);
CREATE TABLE IF NOT EXISTS schema_meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
`);
return database;
}
export function getSyncDb(): DatabaseSync {
if (db) {
return db;
}
const sqlitePath = path.resolve(process.cwd(), config.sync.sqlitePath);
mkdirSync(path.dirname(sqlitePath), { recursive: true });
db = new DatabaseSync(sqlitePath);
initializeSyncDb(db);
return db;
}

View File

@@ -0,0 +1,43 @@
import { SyncRepository } from "./repository";
import { createSyncToken } from "./crypto";
import { config } from "../config";
interface Code2SessionResult {
openid: string;
unionid?: string;
}
async function fetchCode2Session(code: string): Promise<Code2SessionResult> {
const url = new URL("https://api.weixin.qq.com/sns/jscode2session");
url.searchParams.set("appid", config.sync.miniprogramAppId);
url.searchParams.set("secret", config.sync.miniprogramAppSecret);
url.searchParams.set("js_code", code);
url.searchParams.set("grant_type", "authorization_code");
const response = await fetch(url, { signal: AbortSignal.timeout(10000) });
if (!response.ok) {
throw new Error(`wechat code2Session failed: ${response.status}`);
}
const payload = (await response.json()) as {
openid?: string;
unionid?: string;
errcode?: number;
errmsg?: string;
};
if (!payload.openid) {
throw new Error(payload.errmsg || `wechat code2Session failed: ${payload.errcode || "unknown"}`);
}
return {
openid: payload.openid,
unionid: payload.unionid
};
}
export async function loginMiniprogramUser(code: string, repository = new SyncRepository()) {
const result = await fetchCode2Session(code);
const user = repository.getOrCreateUser(result.openid, result.unionid || null);
const session = createSyncToken(user.id, user.openid);
return {
user,
...session
};
}

View File

@@ -0,0 +1,175 @@
import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
export interface TtsCacheEntry {
cacheKey: string;
contentType: string;
bytes: number;
createdAt: string;
lastAccessAt: string;
}
interface TtsCacheFileRecord extends TtsCacheEntry {
version: 1;
}
interface TtsCacheStoreOptions {
cacheDir: string;
ttlMs: number;
maxTotalBytes: number;
maxFileBytes: number;
}
/**
* 磁盘缓存采用“音频文件 + metadata sidecar”
* 1. 命中时不再请求上游 TTS
* 2. metadata 只保留必要字段,不记录原始文本;
* 3. 每次写入后顺带做一次轻量淘汰,维持总量上限。
*/
export class TtsCacheStore {
private cacheDir: string;
private ttlMs: number;
private maxTotalBytes: number;
private maxFileBytes: number;
constructor(options: TtsCacheStoreOptions) {
this.cacheDir = options.cacheDir;
this.ttlMs = options.ttlMs;
this.maxTotalBytes = options.maxTotalBytes;
this.maxFileBytes = options.maxFileBytes;
}
private audioPath(cacheKey: string): string {
return path.join(this.cacheDir, `${cacheKey}.mp3`);
}
private metaPath(cacheKey: string): string {
return path.join(this.cacheDir, `${cacheKey}.json`);
}
private async ensureDir(): Promise<void> {
await mkdir(this.cacheDir, { recursive: true });
}
private async removeCacheKey(cacheKey: string): Promise<void> {
await Promise.allSettled([rm(this.audioPath(cacheKey), { force: true }), rm(this.metaPath(cacheKey), { force: true })]);
}
async get(cacheKey: string): Promise<{ entry: TtsCacheEntry; audioPath: string } | null> {
await this.ensureDir();
try {
const metaRaw = await readFile(this.metaPath(cacheKey), "utf8");
const parsed = JSON.parse(metaRaw) as Partial<TtsCacheFileRecord>;
const audioPath = this.audioPath(cacheKey);
const audioStat = await stat(audioPath);
const lastAccessAt = parsed.lastAccessAt || parsed.createdAt || new Date().toISOString();
if (Date.now() - +new Date(lastAccessAt) > this.ttlMs) {
await this.removeCacheKey(cacheKey);
return null;
}
const nowIso = new Date().toISOString();
const entry: TtsCacheEntry = {
cacheKey,
contentType: String(parsed.contentType || "audio/mpeg"),
bytes: Number(parsed.bytes) || audioStat.size,
createdAt: String(parsed.createdAt || nowIso),
lastAccessAt: nowIso
};
await writeFile(
this.metaPath(cacheKey),
JSON.stringify(
{
version: 1,
...entry
} satisfies TtsCacheFileRecord,
null,
2
),
"utf8"
);
return { entry, audioPath };
} catch {
await this.removeCacheKey(cacheKey);
return null;
}
}
async put(cacheKey: string, audio: Buffer, contentType: string): Promise<TtsCacheEntry> {
await this.ensureDir();
if (audio.length <= 0) {
throw new Error("audio buffer is empty");
}
if (audio.length > this.maxFileBytes) {
throw new Error("audio file exceeds cache single-file limit");
}
const nowIso = new Date().toISOString();
const entry: TtsCacheEntry = {
cacheKey,
contentType: contentType || "audio/mpeg",
bytes: audio.length,
createdAt: nowIso,
lastAccessAt: nowIso
};
await writeFile(this.audioPath(cacheKey), audio);
await writeFile(
this.metaPath(cacheKey),
JSON.stringify(
{
version: 1,
...entry
} satisfies TtsCacheFileRecord,
null,
2
),
"utf8"
);
await this.prune();
return entry;
}
async prune(): Promise<void> {
await this.ensureDir();
const names = await readdir(this.cacheDir);
const metaFiles = names.filter((name) => name.endsWith(".json"));
const rows: Array<TtsCacheEntry & { audioPath: string; metaPath: string; sortValue: number }> = [];
for (const file of metaFiles) {
try {
const metaPath = path.join(this.cacheDir, file);
const raw = await readFile(metaPath, "utf8");
const parsed = JSON.parse(raw) as Partial<TtsCacheFileRecord>;
const cacheKey = file.replace(/\.json$/u, "");
const audioPath = this.audioPath(cacheKey);
const audioStat = await stat(audioPath);
const lastAccessAt = String(parsed.lastAccessAt || parsed.createdAt || new Date(0).toISOString());
if (Date.now() - +new Date(lastAccessAt) > this.ttlMs) {
await this.removeCacheKey(cacheKey);
continue;
}
rows.push({
cacheKey,
contentType: String(parsed.contentType || "audio/mpeg"),
bytes: Number(parsed.bytes) || audioStat.size,
createdAt: String(parsed.createdAt || new Date().toISOString()),
lastAccessAt,
audioPath,
metaPath,
sortValue: +new Date(lastAccessAt || parsed.createdAt || 0) || 0
});
} catch {
// 单条损坏直接移除,避免拖垮后续缓存命中。
const cacheKey = file.replace(/\.json$/u, "");
await this.removeCacheKey(cacheKey);
}
}
let totalBytes = rows.reduce((sum, item) => sum + item.bytes, 0);
if (totalBytes <= this.maxTotalBytes) {
return;
}
rows.sort((a, b) => a.sortValue - b.sortValue);
for (const row of rows) {
if (totalBytes <= this.maxTotalBytes) break;
await Promise.allSettled([rm(row.audioPath, { force: true }), rm(row.metaPath, { force: true })]);
totalBytes -= row.bytes;
}
}
}

View File

@@ -0,0 +1,42 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
describe("tts provider helpers", () => {
beforeEach(() => {
process.env.TTS_PROVIDER = "tencent";
process.env.TTS_VOICE_DEFAULT = "female_v1";
process.env.TTS_SPEED_DEFAULT = "1";
vi.resetModules();
});
it("normalizeTtsRequest 会压缩空白并生成缓存键", async () => {
const { normalizeTtsRequest } = await import("./provider");
const result = normalizeTtsRequest({
text: "请 先检查\r\n\r\n gateway 配置。。。。",
scene: "codex_terminal"
});
expect(result.normalizedText).toBe("请 先检查\ngateway 配置。");
expect(result.cacheKey).toMatch(/^[a-f0-9]{40}$/);
expect(result.voice.alias).toBe("female_v1");
expect(result.speed).toBe(1);
});
it("resolveTtsVoiceProfile 应映射到豆包 1.0 公共音色", async () => {
const { resolveTtsVoiceProfile } = await import("./provider");
expect(resolveTtsVoiceProfile("female_v1").volcVoiceType).toBe("zh_female_cancan_mars_bigtts");
expect(resolveTtsVoiceProfile("male_v1").volcVoiceType).toBe("zh_male_qingshuangnanda_mars_bigtts");
});
it("normalizeTtsRequest 会拒绝超出腾讯云安全字节上限的文本", async () => {
const { normalizeTtsRequest, TtsServiceError } = await import("./provider");
expect(() =>
normalizeTtsRequest({
text: "测".repeat(151),
scene: "codex_terminal"
})
).toThrowError(TtsServiceError);
});
});

View File

@@ -0,0 +1,209 @@
import { createHash } from "node:crypto";
import { config } from "../config";
export interface TtsSynthesizeInput {
text: string;
scene: "codex_terminal";
voice?: string;
speed?: number;
}
export interface TtsVoiceProfile {
alias: string;
providerVoiceType: number;
volcVoiceType: string;
}
export interface TtsNormalizedRequest {
scene: "codex_terminal";
normalizedText: string;
voice: TtsVoiceProfile;
speed: number;
textHash: string;
cacheKey: string;
provider: string;
}
export interface TtsProviderRequest {
text: string;
voice: TtsVoiceProfile;
speed: number;
traceId: string;
}
export interface TtsProviderResult {
audio: Buffer;
contentType: string;
}
export interface TtsProviderAdapter {
readonly providerName: string;
synthesize(request: TtsProviderRequest): Promise<TtsProviderResult>;
}
export const TTS_UPSTREAM_REJECTED_MESSAGE = "TTS 上游鉴权或权限失败,请检查密钥、地域和账号权限";
const TTS_VOICE_PROFILES: Record<string, TtsVoiceProfile> = Object.freeze({
female_v1: {
alias: "female_v1",
providerVoiceType: 101027,
// 豆包语音合成 1.0 公共女声音色,和 `volc.service_type.10029` 同代可直接配套使用。
volcVoiceType: "zh_female_cancan_mars_bigtts"
},
male_v1: {
alias: "male_v1",
providerVoiceType: 101004,
// 同步切到豆包 1.0 公共男声音色,避免旧 BV700 音色与当前 resource_id 代际不匹配。
volcVoiceType: "zh_male_qingshuangnanda_mars_bigtts"
}
});
const TTS_MAX_NORMALIZED_UTF8_BYTES = 450;
/**
* 对外错误统一带 code / status路由层只做一次翻译。
*/
export class TtsServiceError extends Error {
code: string;
status: number;
constructor(code: string, message: string, status = 400) {
super(message);
this.name = "TtsServiceError";
this.code = code;
this.status = status;
}
}
/**
* 上游错误正文经常包含换行、长追踪串或 HTML 片段:
* 1. 压成单行,便于进入日志和小程序 warning
* 2. 截断到有限长度,避免把整段上游响应直接透给前端;
* 3. 保留最关键的错误码和首句说明。
*/
export function normalizeTtsUpstreamDetail(rawDetail: unknown): string {
const detail = typeof rawDetail === "string" ? rawDetail : String(rawDetail || "");
if (!detail.trim()) return "";
const singleLine = detail.replace(/\s+/g, " ").trim();
return singleLine.length > 180 ? `${singleLine.slice(0, 177)}...` : singleLine;
}
export function buildTtsUpstreamRejectedMessage(detail?: string): string {
const normalizedDetail = normalizeTtsUpstreamDetail(detail);
return normalizedDetail
? `${TTS_UPSTREAM_REJECTED_MESSAGE}${normalizedDetail}`
: TTS_UPSTREAM_REJECTED_MESSAGE;
}
export function isTtsUpstreamRejectedDetail(detail: string): boolean {
return /(not granted|access token|authorization|auth|permission|forbidden|unauthorized|resource|grant|鉴权|权限|令牌|密钥)/i.test(
normalizeTtsUpstreamDetail(detail)
);
}
export function buildTtsUpstreamHttpError(status: number, detail?: string): TtsServiceError {
if (status === 401 || status === 403) {
return new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(detail), 502);
}
const normalizedDetail = normalizeTtsUpstreamDetail(detail);
return new TtsServiceError(
"TTS_UPSTREAM_FAILED",
normalizedDetail ? `TTS 上游请求失败: ${status} ${normalizedDetail}` : `TTS 上游请求失败: ${status}`,
502
);
}
export function buildTextHash(text: string): string {
return createHash("sha1")
.update(String(text || ""), "utf8")
.digest("hex");
}
/**
* 网关二次归一化文本:
* 1. 合并 CRLF / 多空格,避免同义文本重复生成缓存;
* 2. 压缩重复标点,降低 TTS 朗读噪音;
* 3. 保留自然语言句间空格,不在服务端做过度语义改写。
*/
export function normalizeTtsText(rawText: string): string {
return String(rawText || "")
.replace(/\r\n?/g, "\n")
.replace(/[ \t\f\v]+/g, " ")
.replace(/\n{2,}/g, "\n")
.replace(/([。!?!?.,;:])\1{1,}/g, "$1")
.replace(/[ \t]*\n[ \t]*/g, "\n")
.trim();
}
export function normalizeTtsSpeed(rawSpeed: unknown): number {
const fallback = Number(config.tts.speedDefault) || 1;
const numeric = Number(rawSpeed);
if (!Number.isFinite(numeric)) {
return Math.max(0.8, Math.min(1.2, fallback));
}
return Math.max(0.8, Math.min(1.2, Number(numeric.toFixed(2))));
}
export function resolveTtsVoiceProfile(rawVoice: unknown): TtsVoiceProfile {
const normalized = String(rawVoice || config.tts.voiceDefault || "female_v1")
.trim()
.toLowerCase();
return TTS_VOICE_PROFILES[normalized] ?? TTS_VOICE_PROFILES.female_v1!;
}
export function buildTtsCacheKey(
providerName: string,
voice: TtsVoiceProfile,
speed: number,
normalizedText: string
): string {
return createHash("sha1")
.update(
[
String(providerName || "")
.trim()
.toLowerCase(),
String(voice.alias || ""),
String(Number(speed).toFixed(2)),
normalizedText,
"v1"
].join("\n"),
"utf8"
)
.digest("hex");
}
export function normalizeTtsRequest(input: TtsSynthesizeInput): TtsNormalizedRequest {
const source: Partial<TtsSynthesizeInput> =
input && typeof input === "object" ? input : { text: "", scene: "codex_terminal" };
const rawText = String(source.text || "");
if (rawText.length > 500) {
throw new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400);
}
const normalizedText = normalizeTtsText(rawText);
if (!normalizedText) {
throw new TtsServiceError("TEXT_NOT_SPEAKABLE", "当前内容不适合播报", 400);
}
if (Buffer.byteLength(normalizedText, "utf8") > TTS_MAX_NORMALIZED_UTF8_BYTES) {
throw new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400);
}
if (normalizedText.length > 280) {
throw new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400);
}
const voice = resolveTtsVoiceProfile(source.voice);
const speed = normalizeTtsSpeed(source.speed);
const providerName =
String(config.tts.provider || "tencent")
.trim()
.toLowerCase() || "tencent";
const scene = source.scene === "codex_terminal" ? "codex_terminal" : "codex_terminal";
return {
scene,
normalizedText,
voice,
speed,
textHash: buildTextHash(normalizedText),
cacheKey: buildTtsCacheKey(providerName, voice, speed, normalizedText),
provider: providerName
};
}

View File

@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("tencent tts provider", () => {
beforeEach(() => {
process.env.TTS_PROVIDER = "tencent";
process.env.TTS_SECRET_ID = "secret-id";
process.env.TTS_SECRET_KEY = "secret-key";
process.env.TTS_REGION = "ap-guangzhou";
process.env.TTS_TIMEOUT_MS = "10000";
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("buildTencentTextToVoiceRequest 应生成 TC3 请求头并映射语速", async () => {
const { buildTencentTextToVoiceRequest } = await import("./tencent");
const built = buildTencentTextToVoiceRequest(
{
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "BV700_V2_streaming"
},
speed: 1,
traceId: "trace-1"
},
Date.UTC(2026, 2, 12, 8, 0, 0)
);
expect(built.url).toBe("https://tts.tencentcloudapi.com");
expect(built.headers["X-TC-Action"]).toBe("TextToVoice");
expect(built.headers["X-TC-Region"]).toBe("ap-guangzhou");
expect(built.headers.Authorization).toContain("TC3-HMAC-SHA256");
expect(built.payload.VoiceType).toBe(101027);
expect(built.payload.Speed).toBe(0);
});
it("较慢与较快倍速应映射到腾讯云 speed 区间", async () => {
const { buildTencentTextToVoiceRequest } = await import("./tencent");
const slowBuilt = buildTencentTextToVoiceRequest({
text: "slow",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "BV700_V2_streaming"
},
speed: 0.8,
traceId: "trace-slow"
});
const fastBuilt = buildTencentTextToVoiceRequest({
text: "fast",
voice: {
alias: "male_v1",
providerVoiceType: 101004,
volcVoiceType: "BV700_V2_streaming"
},
speed: 1.2,
traceId: "trace-fast"
});
expect(slowBuilt.payload.Speed).toBe(-1);
expect(fastBuilt.payload.Speed).toBe(1);
});
it("上游返回 403 时应识别为鉴权或权限失败", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
Response: {
Error: {
Code: "AuthFailure.InvalidSecretId",
Message: "The SecretId is not found"
}
}
}),
{
status: 403,
headers: {
"Content-Type": "application/json"
}
}
)
)
);
const { TencentTtsProvider } = await import("./tencent");
const provider = new TencentTtsProvider();
await expect(
provider.synthesize({
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "BV700_V2_streaming"
},
speed: 1,
traceId: "trace-auth-403"
})
).rejects.toMatchObject({
code: "TTS_UPSTREAM_REJECTED",
status: 502,
message: expect.stringContaining("AuthFailure.InvalidSecretId")
});
});
});

View File

@@ -0,0 +1,216 @@
import { createHmac, createHash, randomUUID } from "node:crypto";
import { config } from "../../config";
import type { TtsProviderAdapter, TtsProviderRequest, TtsProviderResult } from "../provider";
import {
buildTtsUpstreamHttpError,
buildTtsUpstreamRejectedMessage,
normalizeTtsUpstreamDetail,
TtsServiceError
} from "../provider";
const TENCENT_TTS_HOST = "tts.tencentcloudapi.com";
const TENCENT_TTS_ACTION = "TextToVoice";
const TENCENT_TTS_VERSION = "2019-08-23";
const TENCENT_TTS_SERVICE = "tts";
interface TencentTtsRequestPayload {
Text: string;
SessionId: string;
ModelType: number;
VoiceType: number;
Codec: "mp3";
SampleRate: number;
PrimaryLanguage: number;
Speed: number;
Volume: number;
}
interface TencentTtsResponse {
Response?: {
Audio?: string;
Error?: {
Code?: string;
Message?: string;
};
};
}
/**
* 小程序侧把 speed 暴露为“倍速语义”:
* - 1.0 表示 1x
* - 0.8 / 1.2 分别对应较慢 / 较快。
* 腾讯云 `Speed` 的 0 才是 1x因此这里做一层线性映射
* 0.8 -> -1
* 1.0 -> 0
* 1.2 -> 1
*/
function mapRatioSpeedToTencentSpeed(speed: number): number {
const normalized = Number.isFinite(Number(speed)) ? Number(speed) : 1;
const providerSpeed = (normalized - 1) / 0.2;
return Math.max(-2, Math.min(6, Number(providerSpeed.toFixed(2))));
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value, "utf8").digest("hex");
}
function hmacSha256(
key: Buffer | string,
value: string,
output: "hex" | "buffer" = "buffer"
): Buffer | string {
const digest = createHmac("sha256", key).update(value, "utf8");
return output === "hex" ? digest.digest("hex") : digest.digest();
}
function parseTencentTtsResponse(rawText: string): TencentTtsResponse | null {
try {
return JSON.parse(rawText) as TencentTtsResponse;
} catch {
return null;
}
}
/**
* 腾讯云错误体通常同时带 Code 和 Message
* 1. 优先把 Code 保留下来,便于直接定位 CAM/签名/权限问题;
* 2. 无 JSON 时再退回原始文本,避免完全丢掉上游返回。
*/
function formatTencentErrorDetail(
errorPayload?: { Code?: string; Message?: string } | null,
rawText?: string
): string {
const code = normalizeTtsUpstreamDetail(errorPayload?.Code);
const message = normalizeTtsUpstreamDetail(errorPayload?.Message);
if (code && message) {
return `${code}: ${message}`;
}
if (code) {
return code;
}
if (message) {
return message;
}
return normalizeTtsUpstreamDetail(rawText);
}
/**
* 腾讯云 API 3.0TC3-HMAC-SHA256签名
* 1. 仅签当前固定 header 集合,避免实现过度泛化;
* 2. action / version / host 都来自官方 TextToVoice 接口;
* 3. TTS v1 只走短文本同步合成,返回 base64 音频。
*/
export function buildTencentTextToVoiceRequest(request: TtsProviderRequest, now = Date.now()) {
const secretId = String(config.tts.secretId || "").trim();
const secretKey = String(config.tts.secretKey || "").trim();
if (!secretId || !secretKey) {
throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503);
}
const payload: TencentTtsRequestPayload = {
Text: request.text,
SessionId: request.traceId || randomUUID(),
ModelType: 1,
VoiceType: request.voice.providerVoiceType,
Codec: "mp3",
SampleRate: 16000,
PrimaryLanguage: 1,
Speed: mapRatioSpeedToTencentSpeed(request.speed),
Volume: 1
};
const body = JSON.stringify(payload);
const timestamp = Math.max(1, Math.floor(now / 1000));
const date = new Date(timestamp * 1000).toISOString().slice(0, 10);
const canonicalHeaders = [
"content-type:application/json; charset=utf-8",
`host:${TENCENT_TTS_HOST}`,
`x-tc-action:${TENCENT_TTS_ACTION.toLowerCase()}`
].join("\n");
const signedHeaders = "content-type;host;x-tc-action";
const canonicalRequest = ["POST", "/", "", `${canonicalHeaders}\n`, signedHeaders, sha256Hex(body)].join(
"\n"
);
const credentialScope = `${date}/${TENCENT_TTS_SERVICE}/tc3_request`;
const stringToSign = [
"TC3-HMAC-SHA256",
String(timestamp),
credentialScope,
sha256Hex(canonicalRequest)
].join("\n");
const secretDate = hmacSha256(`TC3${secretKey}`, date) as Buffer;
const secretService = hmacSha256(secretDate, TENCENT_TTS_SERVICE) as Buffer;
const secretSigning = hmacSha256(secretService, "tc3_request") as Buffer;
const signature = hmacSha256(secretSigning, stringToSign, "hex") as string;
const authorization = [
"TC3-HMAC-SHA256",
`Credential=${secretId}/${credentialScope}`,
`SignedHeaders=${signedHeaders}`,
`Signature=${signature}`
].join(", ");
return {
url: `https://${TENCENT_TTS_HOST}`,
body,
payload,
headers: {
Authorization: authorization,
"Content-Type": "application/json; charset=utf-8",
Host: TENCENT_TTS_HOST,
"X-TC-Action": TENCENT_TTS_ACTION,
"X-TC-Region": config.tts.region,
"X-TC-Timestamp": String(timestamp),
"X-TC-Version": TENCENT_TTS_VERSION
}
};
}
export class TencentTtsProvider implements TtsProviderAdapter {
readonly providerName = "tencent";
async synthesize(request: TtsProviderRequest): Promise<TtsProviderResult> {
const built = buildTencentTextToVoiceRequest(request);
let response: Response;
try {
response = await fetch(built.url, {
method: "POST",
headers: built.headers,
body: built.body,
signal: AbortSignal.timeout(config.tts.timeoutMs)
});
} catch (error) {
throw new TtsServiceError(
"TTS_UPSTREAM_FAILED",
error instanceof Error && /timeout/i.test(error.message)
? "语音生成超时,请稍后重试"
: "语音生成失败",
502
);
}
const rawText = await response.text();
const parsed = parseTencentTtsResponse(rawText);
if (!response.ok) {
throw buildTtsUpstreamHttpError(response.status, formatTencentErrorDetail(parsed?.Response?.Error, rawText));
}
if (!parsed) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游返回格式异常", 502);
}
const errorPayload = parsed.Response?.Error;
if (errorPayload) {
const detail = formatTencentErrorDetail(errorPayload, rawText);
if (/^(AuthFailure|UnauthorizedOperation)\b/.test(String(errorPayload.Code || "").trim())) {
throw new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(detail), 502);
}
throw new TtsServiceError(
"TTS_UPSTREAM_FAILED",
detail || "TTS 上游返回错误",
502
);
}
const audioBase64 = String(parsed.Response?.Audio || "").trim();
if (!audioBase64) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游未返回音频", 502);
}
return {
audio: Buffer.from(audioBase64, "base64"),
contentType: "audio/mpeg"
};
}
}

View File

@@ -0,0 +1,193 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
function createStreamResponse(chunks: string[], contentType: string): Response {
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(encoder.encode(chunk));
}
controller.close();
}
});
return new Response(stream, {
status: 200,
headers: {
"Content-Type": contentType
}
});
}
describe("volcengine tts provider", () => {
beforeEach(() => {
process.env.TTS_PROVIDER = "volcengine";
process.env.TTS_APP_ID = "app-id";
process.env.TTS_ACCESS_TOKEN = "access-token";
process.env.TTS_RESOURCE_ID = "volc.service_type.10029";
process.env.TTS_TIMEOUT_MS = "200";
vi.resetModules();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("buildVolcengineTtsRequest 应生成 V3 HTTP 单向流式请求", async () => {
const { buildVolcengineTtsRequest } = await import("./volcengine");
const built = buildVolcengineTtsRequest(
{
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "zh_female_cancan_mars_bigtts"
},
speed: 1.2,
traceId: "trace-1"
},
"access-token"
);
expect(built.url).toBe("https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse");
expect(built.headers).toMatchObject({
"Content-Type": "application/json",
"X-Api-App-Id": "app-id",
"X-Api-Access-Key": "access-token",
"X-Api-Resource-Id": "volc.service_type.10029",
"X-Control-Require-Usage-Tokens-Return": "text_words"
});
expect(built.body).toMatchObject({
user: {
uid: "trace-1"
},
req_params: {
text: "请先检查 gateway 配置。",
speaker: "zh_female_cancan_mars_bigtts",
audio_params: {
format: "mp3",
sample_rate: 24000,
speech_rate: 20
},
additions: '{"disable_markdown_filter":true}'
}
});
});
it("synthesize 应拼接 HTTP Chunked 流式音频块", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
createStreamResponse(
[
'{"code":0,"message":"Success","sequence":1,"data":"',
'YXVkaW8tMQ=="}\n{"code":0,"message":"Success","sequence":2,"data":"YXVkaW8tMg=="}',
'\n{"code":20000000,"message":"OK","event":152,"data":null,"usage":{"text_words":7}}\n'
],
"application/json"
)
);
vi.stubGlobal("fetch", fetchMock);
const { VolcengineTtsProvider } = await import("./volcengine");
const provider = new VolcengineTtsProvider();
const result = await provider.synthesize({
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "zh_female_cancan_mars_bigtts"
},
speed: 1,
traceId: "trace-demo"
});
expect(result.contentType).toBe("audio/mpeg");
expect(result.audio).toEqual(Buffer.from("audio-1audio-2"));
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
"https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse",
expect.objectContaining({
method: "POST",
headers: expect.objectContaining({
"X-Api-Resource-Id": "volc.service_type.10029"
})
})
);
});
it("synthesize 应兼容 SSE 单向流式响应", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(
createStreamResponse(
[
'event: 352\ndata: {"code":0,"message":"Success","event":352,"sequence":1,"data":"YXVkaW8tMQ=="}\n\n',
'event: 351\ndata: {"code":0,"message":"Success","event":351,"data":null}\n\n',
'event: 352\ndata: {"code":0,"message":"Success","event":352,"sequence":2,"data":"YXVkaW8tMg=="}\n\n',
'event: 152\ndata: {"code":20000000,"message":"OK","event":152,"data":null,"usage":{"text_words":9}}\n\n'
],
"text/event-stream"
)
);
vi.stubGlobal("fetch", fetchMock);
const { VolcengineTtsProvider } = await import("./volcengine");
const provider = new VolcengineTtsProvider();
const result = await provider.synthesize({
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "zh_female_cancan_mars_bigtts"
},
speed: 1,
traceId: "trace-sse"
});
expect(result.audio).toEqual(Buffer.from("audio-1audio-2"));
expect(result.contentType).toBe("audio/mpeg");
});
it("上游返回鉴权错误时应识别为权限失败", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
new Response(
JSON.stringify({
code: 45000000,
message: "resource is not authorized"
}),
{
status: 403,
headers: {
"Content-Type": "application/json"
}
}
)
)
);
const { VolcengineTtsProvider } = await import("./volcengine");
const provider = new VolcengineTtsProvider();
await expect(
provider.synthesize({
text: "请先检查 gateway 配置。",
voice: {
alias: "female_v1",
providerVoiceType: 101027,
volcVoiceType: "zh_female_cancan_mars_bigtts"
},
speed: 1,
traceId: "trace-auth-403"
})
).rejects.toMatchObject({
code: "TTS_UPSTREAM_REJECTED",
status: 502,
message: expect.stringContaining("resource is not authorized")
});
});
});

View File

@@ -0,0 +1,484 @@
import { randomUUID } from "node:crypto";
import { config } from "../../config";
import { logger } from "../../logger";
import type { TtsProviderAdapter, TtsProviderRequest, TtsProviderResult } from "../provider";
import {
buildTtsUpstreamHttpError,
buildTtsUpstreamRejectedMessage,
isTtsUpstreamRejectedDetail,
normalizeTtsUpstreamDetail,
TtsServiceError
} from "../provider";
// 对齐当前豆包语音 HTTP 单向流式 SSE demo默认走 SSE 端点;
// 同时仍保留 chunked JSON 解析兜底,兼容代理层或上游的回退响应。
const VOLCENGINE_TTS_URL = "https://openspeech.bytedance.com/api/v3/tts/unidirectional/sse";
const VOLCENGINE_TTS_SAMPLE_RATE = 24000;
const VOLCENGINE_STREAM_SUCCESS_CODE = 20000000;
const VOLCENGINE_STREAM_AUTH_REJECTED_CODE = 45000000;
const VOLCENGINE_STREAM_TEXT_TOO_LONG_CODE = 40402003;
const VOLCENGINE_STREAM_SENTENCE_END_EVENT = 351;
const VOLCENGINE_STREAM_AUDIO_EVENT = 352;
const VOLCENGINE_STREAM_FINISH_EVENT = 152;
const VOLCENGINE_STREAM_ERROR_EVENT = 153;
interface VolcengineTtsRequestBody {
user: {
uid: string;
};
req_params: {
text: string;
speaker: string;
audio_params: {
format: "mp3";
sample_rate: number;
speech_rate: number;
};
additions: string;
};
}
interface VolcengineTtsHttpRequest {
url: string;
headers: Record<string, string>;
body: VolcengineTtsRequestBody;
}
interface VolcengineTtsStreamPayload {
code?: number;
message?: string;
event?: number;
sequence?: number;
data?: unknown;
usage?: {
text_words?: number;
};
}
interface VolcengineStreamState {
audioChunks: Buffer[];
firstChunkAtMs: number;
finishCode: number | null;
usageTextWords: number | null;
}
function ensureVolcengineConfig(): void {
if (!config.tts.appId || !config.tts.accessToken || !config.tts.resourceId) {
throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503);
}
}
/**
* V3 文档中 `speech_rate` 的取值范围为 `[-50, 100]`
* 1. `0` 表示 1.0x
* 2. `100` 表示 2.0x
* 3. 当前产品只暴露 0.8 / 1.0 / 1.2 三档,因此继续保守映射到同一线性区间。
*/
function mapRatioSpeedToVolcengineSpeechRate(speed: number): number {
const normalized = Number.isFinite(Number(speed)) ? Number(speed) : 1;
const mapped = Math.round((normalized - 1) * 100);
return Math.max(-50, Math.min(100, mapped));
}
export function buildVolcengineTtsRequest(
request: TtsProviderRequest,
accessToken: string
): VolcengineTtsHttpRequest {
ensureVolcengineConfig();
const requestId = randomUUID();
return {
url: VOLCENGINE_TTS_URL,
headers: {
"Content-Type": "application/json",
"X-Api-App-Id": config.tts.appId,
// 文档里的 header 名仍是 `X-Api-Access-Key`,但其值实际应填写控制台签发的 Access Token。
"X-Api-Access-Key": accessToken,
"X-Api-Resource-Id": config.tts.resourceId,
"X-Api-Request-Id": requestId,
// 要求在结束事件里返回 text_words方便记录计费量和排障。
"X-Control-Require-Usage-Tokens-Return": "text_words"
},
body: {
user: {
uid: request.traceId || requestId
},
req_params: {
text: request.text,
speaker: request.voice.volcVoiceType,
audio_params: {
format: "mp3",
sample_rate: VOLCENGINE_TTS_SAMPLE_RATE,
speech_rate: mapRatioSpeedToVolcengineSpeechRate(request.speed)
},
// 小程序送来的播报文本常带 Markdown/终端痕迹,要求上游先做一次语法过滤,降低朗读噪音。
additions: JSON.stringify({
disable_markdown_filter: true
})
}
}
};
}
function extractHttpErrorDetail(rawText: string): string {
const text = normalizeTtsUpstreamDetail(rawText);
if (!text) {
return "";
}
try {
const parsed = JSON.parse(text) as { message?: string; code?: number | string; data?: unknown };
const detail = normalizeTtsUpstreamDetail(
parsed.message || (typeof parsed.data === "string" ? parsed.data : "") || text
);
if (parsed.code !== undefined) {
return detail ? `code=${parsed.code} ${detail}` : `code=${parsed.code}`;
}
return detail;
} catch {
return text;
}
}
function extractStreamDetail(payload: VolcengineTtsStreamPayload): string {
const directMessage = normalizeTtsUpstreamDetail(payload.message || "");
if (directMessage) {
return directMessage;
}
if (typeof payload.data === "string") {
return normalizeTtsUpstreamDetail(payload.data);
}
return "";
}
function resolveAudioBase64(data: unknown): string {
if (typeof data === "string") {
return data.trim();
}
if (!data || typeof data !== "object") {
return "";
}
const row = data as Record<string, unknown>;
const direct = row.audio_base64 ?? row.audio ?? row.audio_data;
return typeof direct === "string" ? direct.trim() : "";
}
function createVolcengineStreamError(payload: VolcengineTtsStreamPayload): TtsServiceError {
const code = Number(payload.code ?? 0);
const detail = extractStreamDetail(payload);
if (code === VOLCENGINE_STREAM_TEXT_TOO_LONG_CODE) {
return new TtsServiceError("TEXT_TOO_LONG", "播报文本过长", 400);
}
if (/quota exceeded.*concurrency|concurrency.*quota exceeded|too many requests/i.test(detail)) {
return new TtsServiceError("TTS_BUSY", "语音生成繁忙,请稍后重试", 503);
}
if (code === VOLCENGINE_STREAM_AUTH_REJECTED_CODE || isTtsUpstreamRejectedDetail(detail)) {
return new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(detail), 502);
}
const codeLabel = code > 0 ? `火山 TTS 错误码 ${code}` : "语音生成失败";
return new TtsServiceError("TTS_UPSTREAM_FAILED", detail ? `${codeLabel}: ${detail}` : codeLabel, 502);
}
function extractJsonObjects(source: string): { items: string[]; rest: string } {
const items: string[] = [];
let start = -1;
let depth = 0;
let inString = false;
let escaped = false;
for (let index = 0; index < source.length; index += 1) {
const char = source[index]!;
if (start < 0) {
if (char === "{") {
start = index;
depth = 1;
}
continue;
}
if (inString) {
if (escaped) {
escaped = false;
continue;
}
if (char === "\\") {
escaped = true;
continue;
}
if (char === '"') {
inString = false;
}
continue;
}
if (char === '"') {
inString = true;
continue;
}
if (char === "{") {
depth += 1;
continue;
}
if (char === "}") {
depth -= 1;
if (depth === 0) {
items.push(source.slice(start, index + 1));
start = -1;
}
}
}
return {
items,
rest: start >= 0 ? source.slice(start) : ""
};
}
function extractSseBlocks(source: string, flush: boolean): { items: string[]; rest: string } {
const items: string[] = [];
let rest = source;
while (true) {
const matched = rest.match(/\r\n\r\n|\n\n/);
if (!matched || matched.index === undefined) {
break;
}
const block = rest.slice(0, matched.index);
rest = rest.slice(matched.index + matched[0].length);
const dataLines = block
.split(/\r\n|\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart());
if (dataLines.length > 0) {
items.push(dataLines.join("\n"));
}
}
if (flush && rest.trim()) {
const dataLines = rest
.split(/\r\n|\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart());
if (dataLines.length > 0) {
items.push(dataLines.join("\n"));
rest = "";
}
}
return { items, rest };
}
function applyStreamPayload(
state: VolcengineStreamState,
payload: VolcengineTtsStreamPayload,
startedAt: number,
traceId: string
): void {
const event = Number(payload.event ?? 0);
const code = Number(payload.code ?? 0);
if (event === VOLCENGINE_STREAM_ERROR_EVENT || (code !== 0 && code !== VOLCENGINE_STREAM_SUCCESS_CODE)) {
throw createVolcengineStreamError(payload);
}
const audioBase64 = resolveAudioBase64(payload.data);
if (audioBase64 && (event === 0 || event === VOLCENGINE_STREAM_AUDIO_EVENT)) {
if (!state.firstChunkAtMs) {
state.firstChunkAtMs = Date.now();
logger.info(
{
traceId,
resourceId: config.tts.resourceId,
elapsedMs: state.firstChunkAtMs - startedAt
},
"火山 TTS 收到首个音频分片"
);
}
state.audioChunks.push(Buffer.from(audioBase64, "base64"));
return;
}
if (event === VOLCENGINE_STREAM_SENTENCE_END_EVENT) {
return;
}
if (event === VOLCENGINE_STREAM_FINISH_EVENT || code === VOLCENGINE_STREAM_SUCCESS_CODE) {
state.finishCode = code || VOLCENGINE_STREAM_SUCCESS_CODE;
state.usageTextWords =
payload.usage && typeof payload.usage.text_words === "number"
? payload.usage.text_words
: state.usageTextWords;
}
}
async function consumeVolcengineStream(
response: Response,
request: TtsProviderRequest,
startedAt: number
): Promise<VolcengineStreamState> {
if (!response.body) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游未返回流式响应体", 502);
}
const contentType = String(response.headers.get("content-type") || "").toLowerCase();
const isSse = contentType.includes("text/event-stream");
const reader = response.body.getReader();
const decoder = new TextDecoder();
const state: VolcengineStreamState = {
audioChunks: [],
firstChunkAtMs: 0,
finishCode: null,
usageTextWords: null
};
let streamBuffer = "";
while (true) {
const { done, value } = await reader.read();
if (value) {
streamBuffer += decoder.decode(value, { stream: !done });
if (isSse) {
const parsed = extractSseBlocks(streamBuffer, false);
streamBuffer = parsed.rest;
for (const item of parsed.items) {
applyStreamPayload(
state,
JSON.parse(item) as VolcengineTtsStreamPayload,
startedAt,
request.traceId
);
}
} else {
const parsed = extractJsonObjects(streamBuffer);
streamBuffer = parsed.rest;
for (const item of parsed.items) {
applyStreamPayload(
state,
JSON.parse(item) as VolcengineTtsStreamPayload,
startedAt,
request.traceId
);
}
}
}
if (done) {
break;
}
}
streamBuffer += decoder.decode();
if (streamBuffer.trim()) {
if (isSse) {
const parsed = extractSseBlocks(streamBuffer, true);
streamBuffer = parsed.rest;
for (const item of parsed.items) {
applyStreamPayload(state, JSON.parse(item) as VolcengineTtsStreamPayload, startedAt, request.traceId);
}
} else {
const parsed = extractJsonObjects(streamBuffer);
streamBuffer = parsed.rest;
for (const item of parsed.items) {
applyStreamPayload(state, JSON.parse(item) as VolcengineTtsStreamPayload, startedAt, request.traceId);
}
if (streamBuffer.trim()) {
applyStreamPayload(
state,
JSON.parse(streamBuffer) as VolcengineTtsStreamPayload,
startedAt,
request.traceId
);
streamBuffer = "";
}
}
}
if (streamBuffer.trim()) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游流式响应不完整", 502);
}
return state;
}
export class VolcengineTtsProvider implements TtsProviderAdapter {
readonly providerName = "volcengine";
async synthesize(request: TtsProviderRequest): Promise<TtsProviderResult> {
const token = String(config.tts.accessToken || "").trim();
if (!token) {
throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503);
}
const built = buildVolcengineTtsRequest(request, token);
const timeoutMs = config.tts.timeoutMs;
const startedAt = Date.now();
let stage = "requesting";
logger.info(
{
traceId: request.traceId,
textLength: request.text.length,
resourceId: config.tts.resourceId,
timeoutMs
},
"火山 TTS 合成开始"
);
try {
const response = await fetch(built.url, {
method: "POST",
headers: built.headers,
body: JSON.stringify(built.body),
signal: AbortSignal.timeout(timeoutMs)
});
stage = "response_headers";
if (!response.ok) {
const detail = extractHttpErrorDetail(await response.text());
throw buildTtsUpstreamHttpError(response.status, detail);
}
stage = "streaming";
const streamState = await consumeVolcengineStream(response, request, startedAt);
if (streamState.audioChunks.length === 0) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "TTS 上游未返回音频", 502);
}
logger.info(
{
traceId: request.traceId,
resourceId: config.tts.resourceId,
chunkCount: streamState.audioChunks.length,
audioBytes: streamState.audioChunks.reduce((sum, item) => sum + item.length, 0),
elapsedMs: Date.now() - startedAt,
firstChunkDelayMs: streamState.firstChunkAtMs ? streamState.firstChunkAtMs - startedAt : null,
usageTextWords: streamState.usageTextWords
},
"火山 TTS 合成完成"
);
return {
audio: Buffer.concat(streamState.audioChunks),
contentType: "audio/mpeg"
};
} catch (error) {
logger.warn(
{
traceId: request.traceId,
resourceId: config.tts.resourceId,
stage,
elapsedMs: Date.now() - startedAt,
err: error
},
"火山 TTS 合成失败"
);
if (error instanceof TtsServiceError) {
throw error;
}
const message = error instanceof Error ? error.message : String(error || "");
if (/timeout|timed out|aborted|超时/i.test(message)) {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "语音生成超时,请稍后重试", 502);
}
if (isTtsUpstreamRejectedDetail(message)) {
throw new TtsServiceError("TTS_UPSTREAM_REJECTED", buildTtsUpstreamRejectedMessage(message), 502);
}
throw new TtsServiceError(
"TTS_UPSTREAM_FAILED",
normalizeTtsUpstreamDetail(message) || "语音生成失败",
502
);
}
}
}

View File

@@ -0,0 +1,434 @@
import { Buffer } from "node:buffer";
import type WebSocket from "ws";
import type { RawData } from "ws";
/**
* 这里只保留 gateway 现阶段真正会用到的播客协议常量:
* 1. 连接生命周期;
* 2. 会话生命周期;
* 3. 播客音频 round 输出。
*/
export enum VolcenginePodcastEventType {
StartConnection = 1,
FinishConnection = 2,
ConnectionStarted = 50,
ConnectionFinished = 52,
StartSession = 100,
FinishSession = 102,
SessionStarted = 150,
SessionFinished = 152,
PodcastRoundStart = 360,
PodcastRoundResponse = 361,
PodcastRoundEnd = 362,
PodcastEnd = 363
}
export enum VolcenginePodcastMsgType {
FullClientRequest = 0b1,
FullServerResponse = 0b1001,
AudioOnlyServer = 0b1011,
Error = 0b1111
}
export enum VolcenginePodcastMsgFlagBits {
NoSeq = 0,
PositiveSeq = 0b1,
NegativeSeq = 0b11,
WithEvent = 0b100
}
enum VolcenginePodcastVersionBits {
Version1 = 1
}
enum VolcenginePodcastHeaderSizeBits {
HeaderSize4 = 1
}
enum VolcenginePodcastSerializationBits {
JSON = 0b1
}
enum VolcenginePodcastCompressionBits {
None = 0
}
export interface VolcenginePodcastMessage {
version: VolcenginePodcastVersionBits;
headerSize: VolcenginePodcastHeaderSizeBits;
type: VolcenginePodcastMsgType;
flag: VolcenginePodcastMsgFlagBits;
serialization: VolcenginePodcastSerializationBits;
compression: VolcenginePodcastCompressionBits;
event?: VolcenginePodcastEventType;
sessionId?: string;
connectId?: string;
sequence?: number;
errorCode?: number;
payload: Uint8Array;
}
const messageQueues = new Map<WebSocket, VolcenginePodcastMessage[]>();
const messageResolvers = new Map<
WebSocket,
Array<{
resolve: (message: VolcenginePodcastMessage) => void;
reject: (error: Error) => void;
timer?: NodeJS.Timeout;
}>
>();
const initializedSockets = new WeakSet<WebSocket>();
export function createVolcenginePodcastMessage(
type: VolcenginePodcastMsgType,
flag: VolcenginePodcastMsgFlagBits
): VolcenginePodcastMessage {
return {
version: VolcenginePodcastVersionBits.Version1,
headerSize: VolcenginePodcastHeaderSizeBits.HeaderSize4,
type,
flag,
serialization: VolcenginePodcastSerializationBits.JSON,
compression: VolcenginePodcastCompressionBits.None,
payload: new Uint8Array(0)
};
}
function writeUint32(value: number): Uint8Array {
const buffer = new ArrayBuffer(4);
new DataView(buffer).setUint32(0, value >>> 0, false);
return new Uint8Array(buffer);
}
function writeInt32(value: number): Uint8Array {
const buffer = new ArrayBuffer(4);
new DataView(buffer).setInt32(0, value | 0, false);
return new Uint8Array(buffer);
}
function writeString(value: string): Uint8Array {
const bytes = Buffer.from(String(value || ""), "utf8");
const result = new Uint8Array(4 + bytes.length);
result.set(writeUint32(bytes.length), 0);
result.set(bytes, 4);
return result;
}
function writePayload(payload: Uint8Array): Uint8Array {
const normalized = payload instanceof Uint8Array ? payload : new Uint8Array(payload || []);
const result = new Uint8Array(4 + normalized.length);
result.set(writeUint32(normalized.length), 0);
result.set(normalized, 4);
return result;
}
export function marshalVolcenginePodcastMessage(message: VolcenginePodcastMessage): Uint8Array {
const parts: Uint8Array[] = [];
const headerSize = 4 * message.headerSize;
const header = new Uint8Array(headerSize);
header[0] = (message.version << 4) | message.headerSize;
header[1] = (message.type << 4) | message.flag;
header[2] = (message.serialization << 4) | message.compression;
parts.push(header);
if (message.flag === VolcenginePodcastMsgFlagBits.WithEvent) {
parts.push(writeInt32(message.event ?? 0));
if (
message.event === VolcenginePodcastEventType.ConnectionStarted ||
message.event === VolcenginePodcastEventType.ConnectionFinished
) {
parts.push(writeString(message.connectId || ""));
} else if (
message.event !== VolcenginePodcastEventType.StartConnection &&
message.event !== VolcenginePodcastEventType.FinishConnection
) {
parts.push(writeString(message.sessionId || ""));
}
}
if (
message.flag === VolcenginePodcastMsgFlagBits.PositiveSeq ||
message.flag === VolcenginePodcastMsgFlagBits.NegativeSeq
) {
parts.push(writeInt32(message.sequence ?? 0));
}
if (message.type === VolcenginePodcastMsgType.Error) {
parts.push(writeUint32(message.errorCode ?? 0));
}
parts.push(writePayload(message.payload));
const totalLength = parts.reduce((sum, item) => sum + item.length, 0);
const merged = new Uint8Array(totalLength);
let offset = 0;
for (const part of parts) {
merged.set(part, offset);
offset += part.length;
}
return merged;
}
function readUint32(data: Uint8Array, offset: number): number {
return new DataView(data.buffer, data.byteOffset + offset, 4).getUint32(0, false);
}
function readInt32(data: Uint8Array, offset: number): number {
return new DataView(data.buffer, data.byteOffset + offset, 4).getInt32(0, false);
}
function readLengthPrefixedString(data: Uint8Array, offset: number): { value: string; nextOffset: number } {
if (offset + 4 > data.length) {
throw new Error("播客 TTS 协议帧缺少字符串长度");
}
const size = readUint32(data, offset);
const nextOffset = offset + 4;
const endOffset = nextOffset + size;
if (endOffset > data.length) {
throw new Error("播客 TTS 协议帧字符串数据不完整");
}
return {
value: size > 0 ? new TextDecoder().decode(data.slice(nextOffset, endOffset)) : "",
nextOffset: endOffset
};
}
export function unmarshalVolcenginePodcastMessage(data: Uint8Array): VolcenginePodcastMessage {
if (data.length < 4) {
throw new Error("播客 TTS 协议帧长度不足");
}
let offset = 0;
const versionAndHeaderSize = data[offset] ?? 0;
offset += 1;
const typeAndFlag = data[offset] ?? 0;
offset += 1;
const serializationAndCompression = data[offset] ?? 0;
offset += 1;
offset = 4 * (versionAndHeaderSize & 0b00001111);
const message: VolcenginePodcastMessage = {
version: (versionAndHeaderSize >> 4) as VolcenginePodcastVersionBits,
headerSize: (versionAndHeaderSize & 0b00001111) as VolcenginePodcastHeaderSizeBits,
type: (typeAndFlag >> 4) as VolcenginePodcastMsgType,
flag: (typeAndFlag & 0b00001111) as VolcenginePodcastMsgFlagBits,
serialization: (serializationAndCompression >> 4) as VolcenginePodcastSerializationBits,
compression: (serializationAndCompression & 0b00001111) as VolcenginePodcastCompressionBits,
payload: new Uint8Array(0)
};
if (message.flag === VolcenginePodcastMsgFlagBits.WithEvent) {
message.event = readInt32(data, offset) as VolcenginePodcastEventType;
offset += 4;
if (
message.event !== VolcenginePodcastEventType.StartConnection &&
message.event !== VolcenginePodcastEventType.FinishConnection &&
message.event !== VolcenginePodcastEventType.ConnectionStarted &&
message.event !== VolcenginePodcastEventType.ConnectionFinished
) {
const sessionId = readLengthPrefixedString(data, offset);
message.sessionId = sessionId.value;
offset = sessionId.nextOffset;
}
if (
message.event === VolcenginePodcastEventType.ConnectionStarted ||
message.event === VolcenginePodcastEventType.ConnectionFinished
) {
const connectId = readLengthPrefixedString(data, offset);
message.connectId = connectId.value;
offset = connectId.nextOffset;
}
}
if (
message.flag === VolcenginePodcastMsgFlagBits.PositiveSeq ||
message.flag === VolcenginePodcastMsgFlagBits.NegativeSeq
) {
message.sequence = readInt32(data, offset);
offset += 4;
}
if (message.type === VolcenginePodcastMsgType.Error) {
message.errorCode = readUint32(data, offset);
offset += 4;
}
if (offset + 4 > data.length) {
throw new Error("播客 TTS 协议帧缺少 payload 长度");
}
const payloadSize = readUint32(data, offset);
offset += 4;
if (offset + payloadSize > data.length) {
throw new Error("播客 TTS 协议帧 payload 数据不完整");
}
message.payload = payloadSize > 0 ? data.slice(offset, offset + payloadSize) : new Uint8Array(0);
return message;
}
function sendMessage(ws: WebSocket, message: VolcenginePodcastMessage): Promise<void> {
const data = marshalVolcenginePodcastMessage(message);
return new Promise((resolve, reject) => {
ws.send(data, (error?: Error) => {
if (error) {
reject(error);
return;
}
resolve();
});
});
}
function toUint8Array(data: RawData): Uint8Array {
if (Buffer.isBuffer(data)) {
return new Uint8Array(data);
}
if (data instanceof ArrayBuffer) {
return new Uint8Array(data);
}
if (data instanceof Uint8Array) {
return data;
}
if (Array.isArray(data)) {
return Buffer.concat(data.map((item) => Buffer.from(item)));
}
throw new Error(`不支持的播客 TTS 消息类型: ${typeof data}`);
}
function rejectAllResolvers(ws: WebSocket, error: Error): void {
const resolvers = messageResolvers.get(ws) || [];
while (resolvers.length > 0) {
const resolver = resolvers.shift();
if (!resolver) continue;
if (resolver.timer) {
clearTimeout(resolver.timer);
}
resolver.reject(error);
}
}
function setupMessageHandler(ws: WebSocket): void {
if (initializedSockets.has(ws)) {
return;
}
initializedSockets.add(ws);
messageQueues.set(ws, []);
messageResolvers.set(ws, []);
ws.on("message", (data: RawData) => {
try {
const message = unmarshalVolcenginePodcastMessage(toUint8Array(data));
const resolvers = messageResolvers.get(ws) || [];
const queue = messageQueues.get(ws) || [];
const pending = resolvers.shift();
if (pending) {
if (pending.timer) {
clearTimeout(pending.timer);
}
pending.resolve(message);
return;
}
queue.push(message);
messageQueues.set(ws, queue);
} catch (error) {
rejectAllResolvers(
ws,
error instanceof Error ? error : new Error("解析播客 TTS 消息失败")
);
}
});
ws.on("error", (error) => {
rejectAllResolvers(ws, error instanceof Error ? error : new Error("播客 TTS 连接失败"));
});
ws.on("close", () => {
rejectAllResolvers(ws, new Error("播客 TTS 连接已关闭"));
messageQueues.delete(ws);
messageResolvers.delete(ws);
});
}
export async function receiveVolcenginePodcastMessage(
ws: WebSocket,
timeoutMs: number
): Promise<VolcenginePodcastMessage> {
setupMessageHandler(ws);
const queue = messageQueues.get(ws) || [];
if (queue.length > 0) {
return queue.shift() as VolcenginePodcastMessage;
}
return new Promise((resolve, reject) => {
const resolvers = messageResolvers.get(ws) || [];
const resolver = {
resolve,
reject,
timer:
timeoutMs > 0
? setTimeout(() => {
const currentResolvers = messageResolvers.get(ws) || [];
const index = currentResolvers.indexOf(resolver);
if (index >= 0) {
currentResolvers.splice(index, 1);
}
reject(new Error("播客 TTS 响应超时"));
}, timeoutMs)
: undefined
};
resolvers.push(resolver);
messageResolvers.set(ws, resolvers);
});
}
export async function waitForVolcenginePodcastEvent(
ws: WebSocket,
messageType: VolcenginePodcastMsgType,
eventType: VolcenginePodcastEventType,
timeoutMs: number
): Promise<VolcenginePodcastMessage> {
const message = await receiveVolcenginePodcastMessage(ws, timeoutMs);
if (message.type !== messageType || message.event !== eventType) {
throw new Error(`播客 TTS 返回了未预期事件: type=${message.type}, event=${message.event}`);
}
return message;
}
function buildEventPayload(payload: Uint8Array, event: VolcenginePodcastEventType, sessionId?: string) {
const message = createVolcenginePodcastMessage(
VolcenginePodcastMsgType.FullClientRequest,
VolcenginePodcastMsgFlagBits.WithEvent
);
message.event = event;
if (sessionId) {
message.sessionId = sessionId;
}
message.payload = payload;
return message;
}
export async function startVolcenginePodcastConnection(ws: WebSocket): Promise<void> {
await sendMessage(
ws,
buildEventPayload(new TextEncoder().encode("{}"), VolcenginePodcastEventType.StartConnection)
);
}
export async function finishVolcenginePodcastConnection(ws: WebSocket): Promise<void> {
await sendMessage(
ws,
buildEventPayload(new TextEncoder().encode("{}"), VolcenginePodcastEventType.FinishConnection)
);
}
export async function startVolcenginePodcastSession(
ws: WebSocket,
payload: Uint8Array,
sessionId: string
): Promise<void> {
await sendMessage(ws, buildEventPayload(payload, VolcenginePodcastEventType.StartSession, sessionId));
}
export async function finishVolcenginePodcastSession(ws: WebSocket, sessionId: string): Promise<void> {
await sendMessage(
ws,
buildEventPayload(new TextEncoder().encode("{}"), VolcenginePodcastEventType.FinishSession, sessionId)
);
}

View File

@@ -0,0 +1,170 @@
import type { Express, Request, Response, NextFunction } from "express";
import { RateLimiterMemory } from "rate-limiter-flexible";
import { logger } from "../logger";
import { miniprogramTtsSynthesizeBodySchema } from "./schema";
import { TtsService } from "./service";
import { TtsServiceError } from "./provider";
interface SyncAuthedRequest extends Request {
syncUser?: {
userId: string;
openid: string;
};
}
const ttsService = new TtsService();
const userLimiter = new RateLimiterMemory({
points: 20,
duration: 600
});
const ipLimiter = new RateLimiterMemory({
points: 60,
duration: 600
});
function resolvePublicBaseUrl(req: Request): string {
const forwardedProto = String(req.headers["x-forwarded-proto"] || "")
.split(",")
.at(0)
?.trim();
const forwardedHost = String(req.headers["x-forwarded-host"] || "")
.split(",")
.at(0)
?.trim();
const host = forwardedHost || req.get("host") || "127.0.0.1:8787";
const protocol = forwardedProto || req.protocol || "http";
return `${protocol}://${host}`;
}
function sendTtsError(res: Response, error: unknown) {
if (error && typeof error === "object" && "msBeforeNext" in error) {
res.status(429).json({
ok: false,
code: "TTS_RATE_LIMITED",
message: "语音播报过于频繁,请稍后重试"
});
return;
}
const status = error instanceof TtsServiceError ? error.status : 500;
const code = error instanceof TtsServiceError ? error.code : "TTS_INTERNAL_ERROR";
const message = error instanceof Error ? error.message : "TTS 内部错误";
res.status(status).json({
ok: false,
code,
message
});
}
async function checkTtsRateLimit(userId: string, ip: string): Promise<void> {
await Promise.all([
userLimiter.consume(userId || "unknown_user", 1),
ipLimiter.consume(ip || "unknown_ip", 1)
]);
}
export function registerMiniprogramTtsRoutes(
app: Express,
requireSyncUser: (req: SyncAuthedRequest, res: Response, next: NextFunction) => void
): void {
app.post("/api/miniprogram/tts/synthesize", requireSyncUser, async (req: SyncAuthedRequest, res) => {
const userId = String(req.syncUser?.userId || "").trim();
if (!userId) {
res.status(401).json({ ok: false, code: "SYNC_TOKEN_INVALID", message: "同步令牌无效" });
return;
}
const parsed = miniprogramTtsSynthesizeBodySchema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ ok: false, code: "INVALID_BODY", message: "TTS 参数不合法" });
return;
}
try {
await checkTtsRateLimit(userId, req.socket.remoteAddress ?? "unknown");
const payload = await ttsService.synthesizeForUser(resolvePublicBaseUrl(req), userId, parsed.data);
res.json(payload);
} catch (error) {
logger.warn(
{
uid: userId,
ip: req.socket.remoteAddress ?? "unknown",
err: error
},
"小程序 TTS 合成失败"
);
sendTtsError(res, error);
}
});
app.get("/api/miniprogram/tts/status/:cacheKey", async (req, res) => {
const cacheKey = String(req.params.cacheKey || "").trim();
const ticket = String(req.query.ticket || "").trim();
if (!cacheKey || !ticket) {
res.status(400).json({ ok: false, code: "TTS_TICKET_INVALID", message: "缺少音频票据" });
return;
}
try {
ttsService.verifyAudioAccess(cacheKey, ticket);
const status = await ttsService.getSynthesisStatus(cacheKey);
if (status.state === "ready") {
res.json({ ok: true, status: "ready" });
return;
}
if (status.state === "pending") {
res.json({ ok: true, status: "pending" });
return;
}
if (status.state === "error") {
res.json({
ok: false,
status: "error",
code: status.code,
message: status.message
});
return;
}
res.json({
ok: false,
status: "missing",
message: "音频仍在生成,请稍后重试"
});
} catch (error) {
logger.warn(
{
cacheKey,
err: error
},
"小程序 TTS 状态查询失败"
);
sendTtsError(res, error);
}
});
app.get("/api/miniprogram/tts/audio/:cacheKey", async (req, res) => {
const cacheKey = String(req.params.cacheKey || "").trim();
const ticket = String(req.query.ticket || "").trim();
if (!cacheKey || !ticket) {
res.status(400).json({ ok: false, code: "TTS_TICKET_INVALID", message: "缺少音频票据" });
return;
}
try {
ttsService.verifyAudioAccess(cacheKey, ticket);
const cached = await ttsService.resolveCachedAudio(cacheKey);
if (!cached) {
res.status(404).json({ ok: false, code: "TTS_AUDIO_NOT_FOUND", message: "音频缓存不存在" });
return;
}
res.setHeader("Content-Type", cached.entry.contentType);
res.setHeader("Content-Length", String(cached.entry.bytes));
res.setHeader("Cache-Control", "private, max-age=300");
res.sendFile(cached.audioPath);
} catch (error) {
logger.warn(
{
cacheKey,
err: error
},
"小程序 TTS 音频读取失败"
);
sendTtsError(res, error);
}
});
}

View File

@@ -0,0 +1,13 @@
import { z } from "zod";
/**
* v1 只开放 Codex 终端播报场景,避免接口泛化过早。
*/
export const miniprogramTtsSynthesizeBodySchema = z.object({
text: z.string().trim().min(1).max(500),
scene: z.literal("codex_terminal"),
voice: z.string().trim().min(1).max(64).optional(),
speed: z.number().min(0.8).max(1.2).optional()
});
export type MiniprogramTtsSynthesizeBody = z.infer<typeof miniprogramTtsSynthesizeBodySchema>;

View File

@@ -0,0 +1,143 @@
import os from "node:os";
import path from "node:path";
import { mkdtemp, rm } from "node:fs/promises";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
describe("tts service", () => {
const originalEnv = { ...process.env };
const tempDirs: string[] = [];
interface MockProvider {
providerName: string;
synthesize: () => Promise<{ audio: Buffer; contentType: string }>;
}
beforeEach(() => {
process.env = {
...originalEnv,
TTS_PROVIDER: "volcengine",
TTS_APP_ID: "test-app-id",
TTS_ACCESS_TOKEN: "test-access-token",
GATEWAY_TOKEN: "test-gateway-token",
SYNC_SECRET_CURRENT: "test-sync-secret"
};
vi.resetModules();
});
afterEach(async () => {
process.env = { ...originalEnv };
await Promise.all(tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
});
async function createService(provider: MockProvider, options?: { inlineWaitMs?: number }) {
const tempDir = await mkdtemp(path.join(os.tmpdir(), "remoteconn-tts-"));
tempDirs.push(tempDir);
const [{ TtsService }, { TtsCacheStore }] = await Promise.all([import("./service"), import("./cache")]);
const cache = new TtsCacheStore({
cacheDir: tempDir,
ttlMs: 60 * 1000,
maxTotalBytes: 32 * 1024 * 1024,
maxFileBytes: 8 * 1024 * 1024
});
return new TtsService({
provider,
cache,
inlineWaitMs: options?.inlineWaitMs
});
}
async function waitForIdle(service: { getSynthesisStatus: (cacheKey: string) => Promise<{ state: string }> }, cacheKey: string) {
for (let i = 0; i < 20; i += 1) {
const status = await service.getSynthesisStatus(cacheKey);
if (status.state !== "pending") {
return status;
}
await new Promise((resolve) => {
setTimeout(resolve, 10);
});
}
throw new Error("后台任务未在预期时间内结束");
}
it("应在缓存未命中时立即返回 pending并在后台合成完成后变为 ready", async () => {
let resolveSynthesize: (value: { audio: Buffer; contentType: string }) => void = () => {};
const provider: MockProvider = {
providerName: "volcengine",
synthesize: vi.fn<MockProvider["synthesize"]>(() => {
return new Promise<{ audio: Buffer; contentType: string }>((resolve) => {
resolveSynthesize = resolve;
});
})
};
const service = await createService(provider, { inlineWaitMs: 20 });
const payload = await service.synthesizeForUser("https://gateway.example.com", "user-1", {
text: "连接成功,可以继续。",
scene: "codex_terminal"
});
expect(payload.status).toBe("pending");
expect(payload.cached).toBe(false);
expect((await service.getSynthesisStatus(payload.cacheKey)).state).toBe("pending");
resolveSynthesize({
audio: Buffer.from("fake-mp3-data"),
contentType: "audio/mpeg"
});
expect((await waitForIdle(service, payload.cacheKey)).state).toBe("ready");
const cachedPayload = await service.synthesizeForUser("https://gateway.example.com", "user-1", {
text: "连接成功,可以继续。",
scene: "codex_terminal"
});
expect(cachedPayload.status).toBe("ready");
expect(cachedPayload.cached).toBe(true);
});
it("应在短时间内完成合成时直接返回 ready减少小程序额外轮询", async () => {
const provider: MockProvider = {
providerName: "volcengine",
synthesize: vi.fn<MockProvider["synthesize"]>(
async () =>
await new Promise<{ audio: Buffer; contentType: string }>((resolve) => {
setTimeout(() => {
resolve({
audio: Buffer.from("fake-mp3-data"),
contentType: "audio/mpeg"
});
}, 10);
})
)
};
const service = await createService(provider, { inlineWaitMs: 80 });
const payload = await service.synthesizeForUser("https://gateway.example.com", "user-1", {
text: "连接成功,可以继续。",
scene: "codex_terminal"
});
expect(payload.status).toBe("ready");
expect(payload.cached).toBe(true);
});
it("应暴露后台合成失败状态,便于小程序轮询时停止等待", async () => {
const { TtsServiceError } = await import("./provider");
const provider: MockProvider = {
providerName: "volcengine",
synthesize: vi.fn<MockProvider["synthesize"]>(async () => {
throw new TtsServiceError("TTS_UPSTREAM_FAILED", "语音生成失败", 502);
})
};
const service = await createService(provider, { inlineWaitMs: 20 });
const payload = await service.synthesizeForUser("https://gateway.example.com", "user-1", {
text: "连接成功,可以继续。",
scene: "codex_terminal"
});
const status = await waitForIdle(service, payload.cacheKey);
expect(status).toMatchObject({
state: "error",
code: "TTS_UPSTREAM_FAILED",
message: "语音生成失败"
});
});
});

View File

@@ -0,0 +1,339 @@
import path from "node:path";
import { randomUUID } from "node:crypto";
import { config } from "../config";
import { logger } from "../logger";
import { TtsCacheStore } from "./cache";
import type { TtsNormalizedRequest, TtsProviderAdapter, TtsSynthesizeInput } from "./provider";
import { TtsServiceError, normalizeTtsRequest } from "./provider";
import { TencentTtsProvider } from "./providers/tencent";
import { VolcengineTtsProvider } from "./providers/volcengine";
import { createTtsAudioTicket, verifyTtsAudioTicket } from "./ticket";
const TTS_AUDIO_TICKET_TTL_MS = 10 * 60 * 1000;
const TTS_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
const TTS_CACHE_TOTAL_MAX_BYTES = 256 * 1024 * 1024;
const TTS_PROVIDER_CONCURRENCY = 4;
const TTS_PROVIDER_QUEUE_TIMEOUT_MS = 10 * 60 * 1000;
const TTS_BACKGROUND_FAILURE_TTL_MS = 5 * 60 * 1000;
const TTS_SYNTHESIZE_INLINE_WAIT_MS = 800;
interface TtsAudioAccess {
uid: string;
cacheKey: string;
exp: number;
}
interface TtsBackgroundFailure {
code: string;
message: string;
status: number;
expiresAt: number;
}
type TtsSynthesisStatus =
| { state: "ready" }
| { state: "pending" }
| { state: "missing" }
| { state: "error"; code: string; message: string; status: number };
interface TtsServiceOptions {
provider?: TtsProviderAdapter;
cache?: TtsCacheStore;
inlineWaitMs?: number;
}
function sleep(ms: number): Promise<void> {
const waitMs = Math.max(0, Math.round(Number(ms) || 0));
return new Promise((resolve) => {
setTimeout(resolve, waitMs);
});
}
/**
* 最小并发闸门:
* 1. 每实例只允许少量上游 TTS 并发;
* 2. 等待结束后立即唤醒下一个请求;
* 3. v1 不做复杂优先级,保持实现确定性。
*/
class AsyncSemaphore {
private capacity: number;
private active: number;
private queue: Array<() => void>;
constructor(capacity: number) {
this.capacity = Math.max(1, capacity);
this.active = 0;
this.queue = [];
}
async use<T>(task: () => Promise<T>, waitTimeoutMs: number): Promise<T> {
await this.acquire(waitTimeoutMs);
try {
return await task();
} finally {
this.release();
}
}
private acquire(waitTimeoutMs: number): Promise<void> {
if (this.active < this.capacity) {
this.active += 1;
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const timeoutMs = Math.max(0, Math.round(Number(waitTimeoutMs) || 0));
let timeout: NodeJS.Timeout | null = null;
const resume = () => {
if (timeout) {
clearTimeout(timeout);
timeout = null;
}
this.active += 1;
resolve();
};
this.queue.push(resume);
if (timeoutMs <= 0) {
return;
}
timeout = setTimeout(() => {
const index = this.queue.indexOf(resume);
if (index >= 0) {
this.queue.splice(index, 1);
}
reject(new TtsServiceError("TTS_BUSY", "语音生成繁忙,请稍后重试", 503));
}, timeoutMs);
});
}
private release(): void {
this.active = Math.max(0, this.active - 1);
const next = this.queue.shift();
if (next) next();
}
}
function createProvider(): TtsProviderAdapter {
const providerName = String(config.tts.provider || "tencent")
.trim()
.toLowerCase();
if (providerName === "volcengine") {
return new VolcengineTtsProvider();
}
if (providerName === "tencent") {
return new TencentTtsProvider();
}
throw new TtsServiceError("TTS_DISABLED", `不支持的 TTS provider: ${providerName}`, 503);
}
export class TtsService {
private provider: TtsProviderAdapter;
private cache: TtsCacheStore;
private semaphore: AsyncSemaphore;
private inflight: Map<string, Promise<void>>;
private failures: Map<string, TtsBackgroundFailure>;
private inlineWaitMs: number;
constructor(options?: TtsServiceOptions) {
this.provider = options?.provider ?? createProvider();
this.cache =
options?.cache ??
new TtsCacheStore({
cacheDir: path.resolve(process.cwd(), "data/tts-cache"),
ttlMs: TTS_CACHE_TTL_MS,
maxTotalBytes: TTS_CACHE_TOTAL_MAX_BYTES,
// 不同 TTS 供应商的分片/码率差异较大,单文件上限统一改由配置驱动。
maxFileBytes: config.tts.cacheFileMaxBytes
});
this.semaphore = new AsyncSemaphore(TTS_PROVIDER_CONCURRENCY);
this.inflight = new Map();
this.failures = new Map();
this.inlineWaitMs = Math.max(0, Math.round(Number(options?.inlineWaitMs) || TTS_SYNTHESIZE_INLINE_WAIT_MS));
}
private ticketSecret(): string {
const secret = `${config.sync.secretCurrent}:${config.gatewayToken}`;
if (!config.sync.secretCurrent) {
throw new TtsServiceError("TTS_DISABLED", "同步密钥未配置,无法签发音频票据", 503);
}
return secret;
}
private ensureEnabled(): void {
if (!config.tts.enabled) {
throw new TtsServiceError("TTS_DISABLED", "TTS 服务未配置", 503);
}
}
private buildAudioAccessUrls(baseUrl: string, uid: string, cacheKey: string) {
const exp = Date.now() + TTS_AUDIO_TICKET_TTL_MS;
const ticket = createTtsAudioTicket(this.ticketSecret(), { uid, cacheKey, exp });
return {
audioUrl: `${baseUrl}/api/miniprogram/tts/audio/${cacheKey}?ticket=${encodeURIComponent(ticket)}`,
statusUrl: `${baseUrl}/api/miniprogram/tts/status/${cacheKey}?ticket=${encodeURIComponent(ticket)}`,
expiresAt: new Date(exp).toISOString()
};
}
private getRecentFailure(cacheKey: string): TtsBackgroundFailure | null {
const row = this.failures.get(cacheKey);
if (!row) {
return null;
}
if (row.expiresAt <= Date.now()) {
this.failures.delete(cacheKey);
return null;
}
return row;
}
private rememberFailure(cacheKey: string, error: unknown): void {
const failure =
error instanceof TtsServiceError
? {
code: error.code,
message: error.message,
status: error.status,
expiresAt: Date.now() + TTS_BACKGROUND_FAILURE_TTL_MS
}
: {
code: "TTS_INTERNAL_ERROR",
message: error instanceof Error && error.message ? error.message : "语音生成失败",
status: 500,
expiresAt: Date.now() + TTS_BACKGROUND_FAILURE_TTL_MS
};
this.failures.set(cacheKey, failure);
}
private async synthesizeCacheMiss(normalized: TtsNormalizedRequest): Promise<void> {
const result = await this.semaphore.use(async () => {
return await this.provider.synthesize({
text: normalized.normalizedText,
voice: normalized.voice,
speed: normalized.speed,
traceId: randomUUID()
});
}, TTS_PROVIDER_QUEUE_TIMEOUT_MS);
await this.cache.put(normalized.cacheKey, result.audio, result.contentType);
}
private ensureBackgroundSynthesis(normalized: TtsNormalizedRequest): void {
if (this.inflight.has(normalized.cacheKey)) {
return;
}
this.failures.delete(normalized.cacheKey);
const job = (async () => {
try {
await this.synthesizeCacheMiss(normalized);
} catch (error) {
this.rememberFailure(normalized.cacheKey, error);
logger.warn(
{
scene: normalized.scene,
textHash: normalized.textHash,
textLength: normalized.normalizedText.length,
cacheKey: normalized.cacheKey,
provider: this.provider.providerName,
err: error
},
"小程序 TTS 后台合成失败"
);
} finally {
this.inflight.delete(normalized.cacheKey);
}
})();
job.catch(() => {
// 后台任务的错误已经在内部收口到日志与 failure map这里只防止未处理拒绝。
});
this.inflight.set(normalized.cacheKey, job);
}
/**
* 首次 miss 时短暂等待后台任务:
* 1. 短文本常在 1 秒内就能合成完成,直接返回 ready 可省掉一轮轮询;
* 2. 等待窗口很短,慢请求仍按原有 pending 模式异步完成;
* 3. 这里复用同一个 inflight 任务,不会增加上游并发。
*/
private async waitInlineForReady(cacheKey: string): Promise<void> {
if (this.inlineWaitMs <= 0) {
return;
}
const inflight = this.inflight.get(cacheKey);
if (!inflight) {
return;
}
await Promise.race([
inflight.catch(() => {
// 失败状态仍交给后续 status 查询和 failure map 处理。
}),
sleep(this.inlineWaitMs)
]);
}
async synthesizeForUser(baseUrl: string, uid: string, input: TtsSynthesizeInput) {
this.ensureEnabled();
const normalized = normalizeTtsRequest(input);
let cached = await this.cache.get(normalized.cacheKey);
if (!cached) {
this.ensureBackgroundSynthesis(normalized);
await this.waitInlineForReady(normalized.cacheKey);
cached = await this.cache.get(normalized.cacheKey);
}
const ticketResult = this.buildAudioAccessUrls(baseUrl, uid, normalized.cacheKey);
const status = cached ? "ready" : "pending";
logger.info(
{
uid,
scene: normalized.scene,
textHash: normalized.textHash,
textLength: normalized.normalizedText.length,
cacheKey: normalized.cacheKey,
provider: this.provider.providerName,
cacheHit: !!cached,
synthStatus: status
},
cached ? "小程序 TTS 合成完成" : "小程序 TTS 合成任务已提交"
);
return {
ok: true,
cacheKey: normalized.cacheKey,
cached: !!cached,
status,
audioUrl: ticketResult.audioUrl,
statusUrl: ticketResult.statusUrl,
expiresAt: ticketResult.expiresAt
};
}
async getSynthesisStatus(cacheKey: string): Promise<TtsSynthesisStatus> {
// 先看内存态,再碰磁盘,避免轮询刚好撞在 cache.put 的写入窗口里把半成品误删。
if (this.inflight.has(cacheKey)) {
return { state: "pending" };
}
const cached = await this.cache.get(cacheKey);
if (cached) {
return { state: "ready" };
}
const failure = this.getRecentFailure(cacheKey);
if (failure) {
return {
state: "error",
code: failure.code,
message: failure.message,
status: failure.status
};
}
return { state: "missing" };
}
verifyAudioAccess(cacheKey: string, ticket: string): TtsAudioAccess {
const payload = verifyTtsAudioTicket(this.ticketSecret(), ticket);
if (payload.cacheKey !== cacheKey) {
throw new TtsServiceError("TTS_TICKET_INVALID", "音频票据无效", 403);
}
return payload;
}
async resolveCachedAudio(cacheKey: string) {
return await this.cache.get(cacheKey);
}
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { createTtsAudioTicket, verifyTtsAudioTicket } from "./ticket";
describe("tts ticket", () => {
it("应能签发并校验短时音频票据", () => {
const ticket = createTtsAudioTicket("ticket-secret", {
uid: "user-1",
cacheKey: "cache-1",
exp: Date.now() + 60_000
});
expect(verifyTtsAudioTicket("ticket-secret", ticket)).toMatchObject({
uid: "user-1",
cacheKey: "cache-1"
});
});
it("签名不一致时应拒绝通过", () => {
const ticket = createTtsAudioTicket("ticket-secret", {
uid: "user-1",
cacheKey: "cache-1",
exp: Date.now() + 60_000
});
expect(() => verifyTtsAudioTicket("other-secret", ticket)).toThrow(/signature invalid/);
});
});

View File

@@ -0,0 +1,64 @@
import { createHmac, timingSafeEqual } from "node:crypto";
export interface TtsTicketPayload {
uid: string;
cacheKey: string;
exp: number;
}
function base64UrlEncode(input: string): string {
return Buffer.from(input, "utf8")
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
function base64UrlDecode(input: string): string {
const normalized = String(input || "")
.replace(/-/g, "+")
.replace(/_/g, "/");
const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "=");
return Buffer.from(padded, "base64").toString("utf8");
}
function signPayload(secret: string, payload: string): string {
return createHmac("sha256", secret).update(payload, "utf8").digest("base64url");
}
export function createTtsAudioTicket(secret: string, payload: TtsTicketPayload): string {
const normalizedPayload = JSON.stringify({
uid: String(payload.uid || ""),
cacheKey: String(payload.cacheKey || ""),
exp: Math.max(0, Math.round(Number(payload.exp) || 0))
});
const encodedPayload = base64UrlEncode(normalizedPayload);
const signature = signPayload(secret, encodedPayload);
return `${encodedPayload}.${signature}`;
}
export function verifyTtsAudioTicket(secret: string, ticket: string): TtsTicketPayload {
const [encodedPayload, signature] = String(ticket || "").split(".");
if (!encodedPayload || !signature) {
throw new Error("ticket malformed");
}
const expected = signPayload(secret, encodedPayload);
const signatureBuffer = Buffer.from(signature, "utf8");
const expectedBuffer = Buffer.from(expected, "utf8");
if (signatureBuffer.length !== expectedBuffer.length || !timingSafeEqual(signatureBuffer, expectedBuffer)) {
throw new Error("ticket signature invalid");
}
const payload = JSON.parse(base64UrlDecode(encodedPayload)) as Partial<TtsTicketPayload>;
const exp = Math.max(0, Math.round(Number(payload.exp) || 0));
if (!payload.uid || !payload.cacheKey || !exp) {
throw new Error("ticket payload invalid");
}
if (Date.now() >= exp) {
throw new Error("ticket expired");
}
return {
uid: String(payload.uid),
cacheKey: String(payload.cacheKey),
exp
};
}

View File

@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { extractAsrText } from "./asrText";
describe("extractAsrText", () => {
it("支持 result.text 结构", () => {
expect(
extractAsrText({
result: { text: "你好世界" }
})
).toBe("你好世界");
});
it("支持 result 数组结构", () => {
expect(
extractAsrText({
result: [{ text: "数组文本" }]
})
).toBe("数组文本");
});
it("支持 utterances 结构", () => {
expect(
extractAsrText({
result: { utterances: [{ text: "分句一" }, { text: "分句二" }] }
})
).toBe("分句一");
});
it("支持 payload_msg.result 结构", () => {
expect(
extractAsrText({
payload_msg: {
result: [{ text: "包装字段文本" }]
}
})
).toBe("包装字段文本");
});
it("支持 alternatives.transcript 结构", () => {
expect(
extractAsrText({
result: {
alternatives: [{ transcript: "候选转写文本" }]
}
})
).toBe("候选转写文本");
});
it("支持嵌套 data.result.sentence 结构", () => {
expect(
extractAsrText({
data: {
result: {
sentence: "嵌套句子文本"
}
}
})
).toBe("嵌套句子文本");
});
it("无可识别文本时返回空串", () => {
expect(extractAsrText({ result: [{ start_time: 1 }] })).toBe("");
});
});

View File

@@ -0,0 +1,114 @@
function asRecord(input: unknown): Record<string, unknown> | null {
if (!input || typeof input !== "object" || Array.isArray(input)) {
return null;
}
return input as Record<string, unknown>;
}
function firstNonEmpty(items: unknown[]): string {
for (const item of items) {
const text = pickText(item);
if (text) {
return text;
}
}
return "";
}
/**
* 从未知结构中提取识别文本:
* 兼容 result.text / result[] / utterances[] / payload_msg 等常见形态。
*/
function pickText(input: unknown): string {
if (typeof input === "string") {
return input.trim() ? input : "";
}
if (Array.isArray(input)) {
return firstNonEmpty(input);
}
const record = asRecord(input);
if (!record) {
return "";
}
const directText = record.text;
if (typeof directText === "string" && directText.trim()) {
return directText;
}
const aliasTextKeys = ["transcript", "sentence", "content", "utterance", "final_text", "display_text"];
for (const key of aliasTextKeys) {
const candidate = record[key];
if (typeof candidate === "string" && candidate.trim()) {
return candidate;
}
}
const utterances = record.utterances;
if (Array.isArray(utterances)) {
const utterText = firstNonEmpty(utterances);
if (utterText) {
return utterText;
}
}
const arrayLikeKeys = ["alternatives", "results", "hypotheses", "nbest", "sentences", "segments", "list"];
for (const key of arrayLikeKeys) {
const candidateList = record[key];
if (Array.isArray(candidateList)) {
const text = firstNonEmpty(candidateList);
if (text) {
return text;
}
}
}
const nestedKeys = ["result", "payload_msg", "data", "value"];
for (const key of nestedKeys) {
const nested = record[key];
const text = pickText(nested);
if (text) {
return text;
}
}
return "";
}
export function extractAsrText(payload: unknown): string {
if (!payload) {
return "";
}
if (Buffer.isBuffer(payload)) {
const text = payload.toString("utf8");
return text.trim() ? text : "";
}
const root = asRecord(payload);
if (!root) {
return pickText(payload);
}
// 按优先级尝试常见字段,命中即返回。
const candidates: unknown[] = [
root.result,
asRecord(root.result)?.result,
root.payload_msg,
asRecord(root.payload_msg)?.result,
root.data,
asRecord(root.data)?.result,
root
];
for (const candidate of candidates) {
const text = pickText(candidate);
if (text) {
return text;
}
}
return "";
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { parseVoiceClientFrame } from "./clientProtocol";
describe("parseVoiceClientFrame", () => {
it("文本控制帧即使以 Buffer 形式到达也能解析为 start", () => {
const raw = Buffer.from(
JSON.stringify({
type: "start",
payload: {
audio: { format: "pcm", rate: 16000, bits: 16, channel: 1 }
}
}),
"utf8"
);
const frame = parseVoiceClientFrame(raw, false);
expect(frame.type).toBe("start");
});
it("二进制帧应解析为 audio", () => {
const raw = Buffer.from([1, 2, 3, 4]);
const frame = parseVoiceClientFrame(raw, true);
expect(frame.type).toBe("audio");
if (frame.type !== "audio") {
return;
}
expect(frame.payload.length).toBe(4);
});
});

View File

@@ -0,0 +1,68 @@
import { z } from "zod";
import type { RawData } from "ws";
const startPayloadSchema = z
.object({
user: z.record(z.string(), z.unknown()).optional(),
audio: z
.object({
format: z.enum(["pcm", "wav", "ogg", "mp3"]).optional(),
codec: z.enum(["raw", "opus"]).optional(),
rate: z.number().int().positive().optional(),
bits: z.number().int().positive().optional(),
channel: z.number().int().positive().optional(),
language: z.string().min(2).max(16).optional()
})
.optional(),
request: z.record(z.string(), z.unknown()).optional()
})
.optional();
const controlFrameSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("start"), payload: startPayloadSchema }),
z.object({ type: z.literal("stop") }),
z.object({ type: z.literal("cancel") }),
z.object({ type: z.literal("ping") })
]);
export type VoiceClientControlFrame = z.infer<typeof controlFrameSchema>;
export type VoiceClientFrame = VoiceClientControlFrame | { type: "audio"; payload: Buffer };
function rawToBuffer(raw: RawData): Buffer {
if (Buffer.isBuffer(raw)) {
return raw;
}
if (raw instanceof ArrayBuffer) {
return Buffer.from(raw);
}
if (Array.isArray(raw)) {
const chunks = raw.map((chunk) => (Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
return Buffer.concat(chunks);
}
return Buffer.from(raw);
}
/**
* 前端协议:
* - 文本帧JSON 控制消息start/stop/cancel/ping
* - 二进制帧原始音频分片PCM16LE
*/
export function parseVoiceClientFrame(raw: RawData, isBinary: boolean): VoiceClientFrame {
if (!isBinary) {
const text = typeof raw === "string" ? raw : rawToBuffer(raw).toString("utf8");
return controlFrameSchema.parse(JSON.parse(text));
}
const asBuffer = rawToBuffer(raw);
if (asBuffer.length === 0) {
throw new Error("audio frame is empty");
}
return {
type: "audio",
payload: asBuffer
};
}
export function safeSendVoiceFrame(ws: { send: (data: string) => void }, frame: unknown): void {
ws.send(JSON.stringify(frame));
}

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { inferAsrJsonFinal, parseLooseJsonPayloads } from "./upstreamPayload";
describe("parseLooseJsonPayloads", () => {
it("支持标准 JSON 文本", () => {
expect(parseLooseJsonPayloads('{"result":{"text":"你好"}}')).toEqual([{ result: { text: "你好" } }]);
});
it("支持 NDJSON 文本", () => {
expect(parseLooseJsonPayloads('{"result":{"text":"a"}}\n{"result":{"text":"b"}}')).toEqual([
{ result: { text: "a" } },
{ result: { text: "b" } }
]);
});
it("支持 JSON 粘包文本", () => {
expect(parseLooseJsonPayloads('{"result":{"text":"a"}}{"result":{"text":"b"}}')).toEqual([
{ result: { text: "a" } },
{ result: { text: "b" } }
]);
});
it("对明显非 JSON 内容返回空数组", () => {
expect(parseLooseJsonPayloads("not-a-json-frame")).toEqual([]);
});
});
describe("inferAsrJsonFinal", () => {
it("识别根节点 final 标记", () => {
expect(inferAsrJsonFinal({ is_final: true })).toBe(true);
});
it("识别嵌套状态完成标记", () => {
expect(inferAsrJsonFinal({ result: { status: "completed" } })).toBe(true);
});
it("无完成标记时返回 false", () => {
expect(inferAsrJsonFinal({ result: { text: "partial" } })).toBe(false);
});
});

View File

@@ -0,0 +1,175 @@
const JSON_FALLBACK_MAX_SCAN_CHARS = 512 * 1024;
function isTruthyFlag(value: unknown): boolean {
if (value === true) {
return true;
}
if (typeof value === "number") {
return value === 1;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
return ["1", "true", "yes", "on", "final", "finished", "done", "end", "completed"].includes(normalized);
}
return false;
}
/**
* 兼容上游偶发“非标准文本帧”:
* 1) 标准 JSON
* 2) NDJSON一行一个 JSON
* 3) 多个 JSON 粘包({"a":1}{"b":2})。
*
* 额外约束:
* - 对超大文本直接放弃兼容扫描,避免 CPU 被异常帧拖垮;
* - 仅从首个 `{` / `[` 开始扫描,跳过前缀噪音(例如日志前缀)。
*/
export function parseLooseJsonPayloads(rawText: string): unknown[] {
const trimmed = rawText.trim();
if (!trimmed || trimmed.length > JSON_FALLBACK_MAX_SCAN_CHARS) {
return [];
}
const firstJsonTokenIndex = trimmed.search(/[{[]/);
if (firstJsonTokenIndex < 0) {
return [];
}
const text = trimmed.slice(firstJsonTokenIndex);
if (!text) {
return [];
}
try {
return [JSON.parse(text)];
} catch {
// 继续尝试 line-delimited / 拼接 JSON 形态。
}
const linePayloads: unknown[] = [];
const lines = text
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (lines.length > 1) {
for (const line of lines) {
if (!line.startsWith("{") && !line.startsWith("[")) {
continue;
}
try {
linePayloads.push(JSON.parse(line));
} catch {
// 某一行不是 JSON 时忽略,继续尝试其他行。
}
}
if (linePayloads.length > 0) {
return linePayloads;
}
}
const chunkPayloads: unknown[] = [];
let start = -1;
let depth = 0;
let quote: '"' | "'" | null = null;
let escaped = false;
for (let i = 0; i < text.length; i += 1) {
const ch = text[i] ?? "";
if (start < 0) {
if (ch === "{" || ch === "[") {
start = i;
depth = 1;
quote = null;
escaped = false;
}
continue;
}
if (quote) {
if (escaped) {
escaped = false;
continue;
}
if (ch === "\\") {
escaped = true;
continue;
}
if (ch === quote) {
quote = null;
}
continue;
}
if (ch === '"' || ch === "'") {
quote = ch as '"' | "'";
continue;
}
if (ch === "{" || ch === "[") {
depth += 1;
continue;
}
if (ch === "}" || ch === "]") {
depth -= 1;
if (depth === 0 && start >= 0) {
const segment = text.slice(start, i + 1);
start = -1;
try {
chunkPayloads.push(JSON.parse(segment));
} catch {
// 片段不是有效 JSON 时忽略。
}
}
}
}
return chunkPayloads;
}
export function inferAsrJsonFinal(payload: unknown): boolean {
const queue: unknown[] = [payload];
const visited = new Set<object>();
const finalKeys = ["is_final", "isFinal", "final", "finished", "end", "is_end", "isEnd", "complete", "completed"];
while (queue.length > 0) {
const current = queue.shift();
if (!current || typeof current !== "object") {
continue;
}
if (visited.has(current)) {
continue;
}
visited.add(current);
if (Array.isArray(current)) {
queue.push(...current);
continue;
}
const record = current as Record<string, unknown>;
for (const key of finalKeys) {
if (isTruthyFlag(record[key])) {
return true;
}
}
if (typeof record.status === "string" && isTruthyFlag(record.status)) {
return true;
}
if (typeof record.type === "string" && isTruthyFlag(record.type)) {
return true;
}
queue.push(
record.result,
record.payload_msg,
record.data,
record.payload,
record.message,
record.messages,
record.utterances,
record.alternatives
);
}
return false;
}

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from "vitest";
import { gzipSync } from "node:zlib";
import {
VolcCompression,
VolcMessageFlags,
VolcMessageType,
VolcSerialization,
buildAudioOnlyRequestFrame,
buildVolcHeader,
isFinalServerResponse,
parseVolcServerFrame
} from "./volcAsrProtocol";
describe("volcAsrProtocol", () => {
it("audio-only 最后一包应携带 LAST_PACKAGE flag", () => {
const frame = buildAudioOnlyRequestFrame(Buffer.from([1, 2, 3, 4]), true);
const flag = (frame[1] ?? 0) & 0x0f;
expect(flag).toBe(VolcMessageFlags.LAST_PACKAGE);
});
it("可解析 gzip + json 的 full server response", () => {
const payloadObj = {
result: {
text: "测试文本"
}
};
const payload = gzipSync(Buffer.from(JSON.stringify(payloadObj), "utf8"));
const header = buildVolcHeader({
messageType: VolcMessageType.FULL_SERVER_RESPONSE,
flags: VolcMessageFlags.POSITIVE_SEQUENCE,
serialization: VolcSerialization.JSON,
compression: VolcCompression.GZIP
});
const sequence = Buffer.alloc(4);
sequence.writeInt32BE(2, 0);
const payloadSize = Buffer.alloc(4);
payloadSize.writeUInt32BE(payload.length, 0);
const parsed = parseVolcServerFrame(Buffer.concat([header, sequence, payloadSize, payload]));
expect(parsed.kind).toBe("server_response");
if (parsed.kind !== "server_response") {
return;
}
expect(parsed.sequence).toBe(2);
expect((parsed.payload as { result: { text: string } }).result.text).toBe("测试文本");
});
it("flags=LAST_PACKAGE 且包含 sequence 时可兼容解析", () => {
const payloadObj = {
result: {
text: "结束包"
}
};
const payload = gzipSync(Buffer.from(JSON.stringify(payloadObj), "utf8"));
const header = buildVolcHeader({
messageType: VolcMessageType.FULL_SERVER_RESPONSE,
flags: VolcMessageFlags.LAST_PACKAGE,
serialization: VolcSerialization.JSON,
compression: VolcCompression.GZIP
});
const sequence = Buffer.alloc(4);
sequence.writeInt32BE(7, 0);
const payloadSize = Buffer.alloc(4);
payloadSize.writeUInt32BE(payload.length, 0);
const parsed = parseVolcServerFrame(Buffer.concat([header, sequence, payloadSize, payload]));
expect(parsed.kind).toBe("server_response");
if (parsed.kind !== "server_response") {
return;
}
expect(parsed.sequence).toBe(7);
expect((parsed.payload as { result: { text: string } }).result.text).toBe("结束包");
});
it("可解析 error response", () => {
const errorPayload = Buffer.from("invalid request", "utf8");
const header = buildVolcHeader({
messageType: VolcMessageType.ERROR_RESPONSE,
flags: VolcMessageFlags.NONE,
serialization: VolcSerialization.NONE,
compression: VolcCompression.NONE
});
const errorCode = Buffer.alloc(4);
errorCode.writeUInt32BE(45000001, 0);
const payloadSize = Buffer.alloc(4);
payloadSize.writeUInt32BE(errorPayload.length, 0);
const parsed = parseVolcServerFrame(Buffer.concat([header, errorCode, payloadSize, errorPayload]));
expect(parsed.kind).toBe("error");
if (parsed.kind !== "error") {
return;
}
expect(parsed.errorCode).toBe(45000001);
expect(Buffer.isBuffer(parsed.payload)).toBe(true);
expect((parsed.payload as Buffer).toString("utf8")).toBe("invalid request");
});
it("final flag 判定覆盖 LAST_PACKAGE 与 NEGATIVE_SEQUENCE", () => {
expect(isFinalServerResponse(VolcMessageFlags.LAST_PACKAGE)).toBe(true);
expect(isFinalServerResponse(VolcMessageFlags.NEGATIVE_SEQUENCE)).toBe(true);
expect(isFinalServerResponse(VolcMessageFlags.POSITIVE_SEQUENCE)).toBe(false);
});
});

View File

@@ -0,0 +1,265 @@
import { gunzipSync, gzipSync } from "node:zlib";
export const VOLC_PROTOCOL_VERSION = 0b0001;
export const VOLC_HEADER_SIZE_WORDS = 0b0001; // 1 * 4 bytes
export const enum VolcMessageType {
FULL_CLIENT_REQUEST = 0b0001,
AUDIO_ONLY_REQUEST = 0b0010,
FULL_SERVER_RESPONSE = 0b1001,
ERROR_RESPONSE = 0b1111
}
export const enum VolcMessageFlags {
NONE = 0b0000,
POSITIVE_SEQUENCE = 0b0001,
LAST_PACKAGE = 0b0010,
NEGATIVE_SEQUENCE = 0b0011
}
export const enum VolcSerialization {
NONE = 0b0000,
JSON = 0b0001
}
export const enum VolcCompression {
NONE = 0b0000,
GZIP = 0b0001
}
export interface VolcFullClientRequestPayload {
user?: Record<string, unknown>;
audio: {
format: "pcm" | "wav" | "ogg" | "mp3";
codec?: "raw" | "opus";
rate?: number;
bits?: number;
channel?: number;
language?: string;
};
request: Record<string, unknown> & {
model_name: string;
};
}
export interface ParsedVolcServerResponse {
kind: "server_response";
flags: number;
sequence: number | null;
payload: unknown;
}
export interface ParsedVolcServerError {
kind: "error";
flags: number;
errorCode: number;
payload: unknown;
}
export interface ParsedVolcUnknownFrame {
kind: "unknown";
messageType: number;
flags: number;
payload: unknown;
}
export type ParsedVolcServerFrame = ParsedVolcServerResponse | ParsedVolcServerError | ParsedVolcUnknownFrame;
function decodePayload(serialization: number, compression: number, payload: Buffer): unknown {
const inflated = compression === VolcCompression.GZIP ? gunzipSync(payload) : payload;
if (serialization === VolcSerialization.NONE) {
return inflated;
}
if (serialization === VolcSerialization.JSON) {
const rawText = inflated.toString("utf8");
return JSON.parse(rawText);
}
throw new Error(`unsupported serialization method: ${serialization}`);
}
function encodeJsonPayload(payload: unknown, compression: VolcCompression): Buffer {
const raw = Buffer.from(JSON.stringify(payload), "utf8");
if (compression === VolcCompression.GZIP) {
return gzipSync(raw);
}
return raw;
}
function encodeBinaryPayload(payload: Buffer, compression: VolcCompression): Buffer {
if (compression === VolcCompression.GZIP) {
return gzipSync(payload);
}
return payload;
}
export function buildVolcHeader(params: {
messageType: number;
flags: number;
serialization: number;
compression: number;
}): Buffer {
const header = Buffer.alloc(4);
header[0] = ((VOLC_PROTOCOL_VERSION & 0x0f) << 4) | (VOLC_HEADER_SIZE_WORDS & 0x0f);
header[1] = ((params.messageType & 0x0f) << 4) | (params.flags & 0x0f);
header[2] = ((params.serialization & 0x0f) << 4) | (params.compression & 0x0f);
header[3] = 0;
return header;
}
/**
* 构造 full client request
* 1) JSON 序列化;
* 2) 使用 GZIP 压缩;
* 3) payload size 使用 4 字节大端无符号整数。
*/
export function buildFullClientRequestFrame(payload: VolcFullClientRequestPayload): Buffer {
const compressedPayload = encodeJsonPayload(payload, VolcCompression.GZIP);
const header = buildVolcHeader({
messageType: VolcMessageType.FULL_CLIENT_REQUEST,
flags: VolcMessageFlags.NONE,
serialization: VolcSerialization.JSON,
compression: VolcCompression.GZIP
});
const payloadSize = Buffer.alloc(4);
payloadSize.writeUInt32BE(compressedPayload.length, 0);
return Buffer.concat([header, payloadSize, compressedPayload]);
}
/**
* 构造 audio-only request
* - payload 直接是二进制音频PCM16LE
* - 与 full request 保持一致,启用 GZIP 压缩;
* - final=true 时设置 LAST_PACKAGE 标记。
*/
export function buildAudioOnlyRequestFrame(audioPayload: Buffer, final: boolean): Buffer {
const compressedPayload = encodeBinaryPayload(audioPayload, VolcCompression.GZIP);
const header = buildVolcHeader({
messageType: VolcMessageType.AUDIO_ONLY_REQUEST,
flags: final ? VolcMessageFlags.LAST_PACKAGE : VolcMessageFlags.NONE,
serialization: VolcSerialization.NONE,
compression: VolcCompression.GZIP
});
const payloadSize = Buffer.alloc(4);
payloadSize.writeUInt32BE(compressedPayload.length, 0);
return Buffer.concat([header, payloadSize, compressedPayload]);
}
/**
* 根据文档约定判断“服务端是否为最后一包结果”。
*/
export function isFinalServerResponse(flags: number): boolean {
return flags === VolcMessageFlags.LAST_PACKAGE || flags === VolcMessageFlags.NEGATIVE_SEQUENCE;
}
/**
* 解析服务端二进制帧:
* - FULL_SERVER_RESPONSE: [header][sequence?][payload_size][payload]
* - ERROR_RESPONSE: [header][error_code][payload_size][payload]
*/
export function parseVolcServerFrame(frame: Buffer): ParsedVolcServerFrame {
if (frame.length < 8) {
throw new Error("invalid volc frame: too short");
}
const headerByte0 = frame[0] ?? 0;
const headerByte1 = frame[1] ?? 0;
const headerByte2 = frame[2] ?? 0;
const headerSizeWords = headerByte0 & 0x0f;
const headerSizeBytes = headerSizeWords * 4;
if (headerSizeWords < 1 || frame.length < headerSizeBytes + 4) {
throw new Error("invalid volc frame: bad header size");
}
const messageType = (headerByte1 & 0xf0) >> 4;
const flags = headerByte1 & 0x0f;
const serialization = (headerByte2 & 0xf0) >> 4;
const compression = headerByte2 & 0x0f;
if (messageType === VolcMessageType.FULL_SERVER_RESPONSE) {
const parseServerResponseVariant = (
withSequence: boolean
): { ok: true; value: ParsedVolcServerResponse } | { ok: false; reason: string } => {
let offset = headerSizeBytes;
let sequence: number | null = null;
if (withSequence) {
if (frame.length < offset + 4) {
return { ok: false, reason: "invalid volc frame: missing sequence" };
}
sequence = frame.readInt32BE(offset);
offset += 4;
}
if (frame.length < offset + 4) {
return { ok: false, reason: "invalid volc frame: missing payload size" };
}
const payloadSize = frame.readUInt32BE(offset);
offset += 4;
if (frame.length < offset + payloadSize) {
return { ok: false, reason: "invalid volc frame: payload truncated" };
}
const payloadBuffer = frame.subarray(offset, offset + payloadSize);
try {
return {
ok: true,
value: {
kind: "server_response",
flags,
sequence,
payload: decodePayload(serialization, compression, payloadBuffer)
}
};
} catch (error) {
return {
ok: false,
reason: `invalid volc frame: ${(error as Error).message}`
};
}
};
const preferSequenceFirst = flags === VolcMessageFlags.POSITIVE_SEQUENCE || flags === VolcMessageFlags.NEGATIVE_SEQUENCE;
const firstTry = parseServerResponseVariant(preferSequenceFirst);
if (firstTry.ok) {
return firstTry.value;
}
const secondTry = parseServerResponseVariant(!preferSequenceFirst);
if (secondTry.ok) {
return secondTry.value;
}
throw new Error(secondTry.reason || firstTry.reason);
}
if (messageType === VolcMessageType.ERROR_RESPONSE) {
let offset = headerSizeBytes;
if (frame.length < offset + 8) {
throw new Error("invalid volc frame: bad error frame");
}
const errorCode = frame.readUInt32BE(offset);
offset += 4;
const payloadSize = frame.readUInt32BE(offset);
offset += 4;
if (frame.length < offset + payloadSize) {
throw new Error("invalid volc frame: error payload truncated");
}
const payloadBuffer = frame.subarray(offset, offset + payloadSize);
return {
kind: "error",
flags,
errorCode,
payload: decodePayload(serialization, compression, payloadBuffer)
};
}
let payload: unknown = Buffer.alloc(0);
if (frame.length >= headerSizeBytes + 4) {
const payloadSize = frame.readUInt32BE(headerSizeBytes);
const payloadOffset = headerSizeBytes + 4;
if (frame.length >= payloadOffset + payloadSize) {
payload = decodePayload(serialization, compression, frame.subarray(payloadOffset, payloadOffset + payloadSize));
}
}
return {
kind: "unknown",
messageType,
flags,
payload
};
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it, vi } from "vitest";
import { createAssistTxnDeduper } from "./assistTxnDeduper";
describe("assistTxnDeduper", () => {
it("仅对 assist + txnId 生效,其它输入不去重", () => {
const dedupe = createAssistTxnDeduper({ ttlMs: 1000, cacheLimit: 4 });
expect(dedupe("keyboard", "a")).toBe(false);
expect(dedupe("assist", undefined)).toBe(false);
expect(dedupe(undefined, "a")).toBe(false);
});
it("相同 txnId 在 ttl 内应判定为重复", () => {
const dedupe = createAssistTxnDeduper({ ttlMs: 1000, cacheLimit: 4 });
expect(dedupe("assist", "txn-1")).toBe(false);
expect(dedupe("assist", "txn-1")).toBe(true);
});
it("超过 ttl 后同一 txnId 可重新通过", () => {
vi.useFakeTimers();
try {
const dedupe = createAssistTxnDeduper({ ttlMs: 1000, cacheLimit: 4 });
expect(dedupe("assist", "txn-1")).toBe(false);
vi.advanceTimersByTime(1001);
expect(dedupe("assist", "txn-1")).toBe(false);
} finally {
vi.useRealTimers();
}
});
it("超过 cacheLimit 时应淘汰最旧记录", () => {
const dedupe = createAssistTxnDeduper({ ttlMs: 60_000, cacheLimit: 2 });
expect(dedupe("assist", "txn-1")).toBe(false);
expect(dedupe("assist", "txn-2")).toBe(false);
expect(dedupe("assist", "txn-3")).toBe(false);
expect(dedupe("assist", "txn-2")).toBe(true);
expect(dedupe("assist", "txn-3")).toBe(true);
expect(dedupe("assist", "txn-1")).toBe(false);
});
});

View File

@@ -0,0 +1,35 @@
export interface AssistTxnDeduperOptions {
ttlMs: number;
cacheLimit: number;
}
export function createAssistTxnDeduper(options: AssistTxnDeduperOptions): (source?: string, txnId?: string) => boolean {
const seenAt = new Map<string, number>();
return (source?: string, txnId?: string): boolean => {
if (source !== "assist" || !txnId) {
return false;
}
const now = Date.now();
for (const [id, at] of seenAt.entries()) {
if (now - at > options.ttlMs) {
seenAt.delete(id);
}
}
if (seenAt.has(txnId)) {
return true;
}
seenAt.set(txnId, now);
if (seenAt.size > options.cacheLimit) {
const oldestId = seenAt.keys().next().value as string | undefined;
if (oldestId) {
seenAt.delete(oldestId);
}
}
return false;
};
}

View File

@@ -0,0 +1,88 @@
import { describe, expect, it } from "vitest";
import { parseInboundFrame } from "./protocol";
describe("gateway protocol", () => {
it("解析 init 帧", () => {
const parsed = parseInboundFrame(
JSON.stringify({
type: "init",
payload: {
host: "127.0.0.1",
port: 22,
username: "root",
resumeGraceMs: 900000,
credential: { type: "password", password: "x" },
pty: { cols: 80, rows: 24 }
}
})
);
expect(parsed.type).toBe("init");
if (parsed.type === "init") {
expect(parsed.payload.resumeGraceMs).toBe(900000);
}
});
it("解析带跳板机的 init 帧", () => {
const parsed = parseInboundFrame(
JSON.stringify({
type: "init",
payload: {
host: "10.0.0.10",
port: 22,
username: "deploy",
credential: { type: "privateKey", privateKey: "TARGET_KEY" },
jumpHost: {
host: "10.0.0.1",
port: 2222,
username: "bastion",
credential: { type: "password", password: "secret" }
},
pty: { cols: 120, rows: 32 }
}
})
);
expect(parsed.type).toBe("init");
if (parsed.type === "init") {
expect(parsed.payload.jumpHost?.host).toBe("10.0.0.1");
expect(parsed.payload.jumpHost?.port).toBe(2222);
expect(parsed.payload.jumpHost?.username).toBe("bastion");
}
});
it("解析带 meta 的 stdin 帧", () => {
const parsed = parseInboundFrame(
JSON.stringify({
type: "stdin",
payload: {
data: "测试",
meta: {
source: "assist",
txnId: "assist-1"
}
}
})
);
expect(parsed.type).toBe("stdin");
if (parsed.type === "stdin") {
expect(parsed.payload.meta?.source).toBe("assist");
expect(parsed.payload.meta?.txnId).toBe("assist-1");
}
});
it("解析带原因的 disconnect 控制帧", () => {
const parsed = parseInboundFrame(
JSON.stringify({
type: "control",
payload: {
action: "disconnect",
reason: "manual"
}
})
);
expect(parsed.type).toBe("control");
if (parsed.type === "control") {
expect(parsed.payload.action).toBe("disconnect");
expect(parsed.payload.reason).toBe("manual");
}
});
});

View File

@@ -0,0 +1,94 @@
import { z } from "zod";
const initPayloadSchema = z.object({
host: z.string().min(1),
port: z.number().int().positive().max(65535),
username: z.string().min(1),
clientSessionKey: z.string().min(1).max(128).optional(),
/**
* 续接驻留窗口(毫秒):
* - 允许客户端按连接声明“离开页面后保留 SSH 会话多久”;
* - 服务器侧仍会做最小/最大值裁剪。
*/
resumeGraceMs: z
.number()
.int()
.positive()
.max(24 * 60 * 60 * 1000)
.optional(),
credential: z.union([
z.object({ type: z.literal("password"), password: z.string().min(1) }),
z.object({
type: z.literal("privateKey"),
privateKey: z.string().min(1),
passphrase: z.string().optional()
}),
z.object({
type: z.literal("certificate"),
privateKey: z.string().min(1),
passphrase: z.string().optional(),
certificate: z.string().min(1)
})
]),
jumpHost: z
.object({
host: z.string().min(1),
port: z.number().int().positive().max(65535),
username: z.string().min(1),
credential: z.union([
z.object({ type: z.literal("password"), password: z.string().min(1) }),
z.object({
type: z.literal("privateKey"),
privateKey: z.string().min(1),
passphrase: z.string().optional()
}),
z.object({
type: z.literal("certificate"),
privateKey: z.string().min(1),
passphrase: z.string().optional(),
certificate: z.string().min(1)
})
]),
knownHostFingerprint: z.string().optional()
})
.optional(),
knownHostFingerprint: z.string().optional(),
pty: z.object({ cols: z.number().int().positive(), rows: z.number().int().positive() })
});
const stdinMetaSchema = z.object({
source: z.enum(["keyboard", "assist"]),
txnId: z.string().min(1).max(128).optional()
});
const inboundFrameSchema = z.union([
z.object({ type: z.literal("init"), payload: initPayloadSchema }),
z.object({
type: z.literal("stdin"),
payload: z.object({
data: z.string(),
meta: stdinMetaSchema.optional()
})
}),
z.object({
type: z.literal("resize"),
payload: z.object({ cols: z.number().int().positive(), rows: z.number().int().positive() })
}),
z.object({
type: z.literal("control"),
payload: z.object({
action: z.enum(["ping", "pong", "disconnect"]),
reason: z.string().min(1).max(128).optional()
})
})
]);
export type InboundFrame = z.infer<typeof inboundFrameSchema>;
export function parseInboundFrame(raw: string): InboundFrame {
return inboundFrameSchema.parse(JSON.parse(raw));
}
export function safeSend(socket: { send: (data: string) => void }, frame: unknown): void {
socket.send(JSON.stringify(frame));
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@remoteconn/shared": ["../../packages/shared/src/index.ts"]
}
},
"include": ["src/**/*.ts"]
}