@sleep2agi/commhub-server 0.9.0-preview.52 → 0.9.0-preview.54

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.52",
3
+ "version": "0.9.0-preview.54",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -35,6 +35,15 @@ describe("validateRuntime / validateModel (§4.2.2)", () => {
35
35
  expect(() => validateModel("gpt-4o")).not.toThrow();
36
36
  expect(() => validateModel("bad model")).toThrow(ValidationError);
37
37
  expect(() => validateModel("x;rm")).toThrow(ValidationError);
38
+ // provider/model(OpenCode 共存;桌面向导 0.2.61 起发这种形状,Vincent 2026-09-08 撞到 model_invalid)
39
+ expect(() => validateModel("opencode/mimo-v2.5-free")).not.toThrow();
40
+ expect(() => validateModel("anthropic/claude-sonnet-4")).not.toThrow();
41
+ expect(() => validateModel("a/b/c")).toThrow(ValidationError); // 只许一个斜杠
42
+ expect(() => validateModel("/model")).toThrow(ValidationError); // 不能以斜杠开头
43
+ expect(() => validateModel("provider/")).toThrow(ValidationError); // 不能以斜杠结尾
44
+ expect(() => validateModel("../x")).toThrow(ValidationError); // 纯点段(路径穿越形状)
45
+ expect(() => validateModel("x/.")).toThrow(ValidationError);
46
+ expect(() => validateModel("open code/m")).toThrow(ValidationError);
38
47
  expect(() => validateModel("")).toThrow(ValidationError);
39
48
  });
40
49
  });
@@ -58,7 +58,11 @@ const ENV_KEY_RE = /^[A-Z][A-Z0-9_]{0,63}$/;
58
58
 
59
59
  // §4.2.2 name + model.
60
60
  const NAME_RE = /^[a-z][a-z0-9_-]{0,63}$/;
61
- const MODEL_RE = /^[a-zA-Z0-9._:\-]+$/;
61
+ // OpenCode 共存的模型带 provider 前缀(`opencode/mimo-v2.5-free`,agent-node parseModelRef 按第一个 `/` 切),
62
+ // 桌面向导 0.2.61 起就发这种形状 —— 允许**恰好一个**斜杠分隔的两段;每段仍是原字符集,且不能是纯点(`..`/`.`)。
63
+ const MODEL_SEGMENT = "[a-zA-Z0-9._:\\-]+";
64
+ const MODEL_RE = new RegExp(`^${MODEL_SEGMENT}(?:/${MODEL_SEGMENT})?$`);
65
+ const MODEL_DOT_ONLY_SEGMENT = /(^|\/)\.+(\/|$)/;
62
66
 
63
67
  export function validateName(s: unknown): asserts s is string {
64
68
  if (typeof s !== "string" || !NAME_RE.test(s)) {
@@ -90,7 +94,7 @@ export function validateRuntime(s: unknown): asserts s is Runtime {
90
94
 
91
95
  export function validateModel(s: unknown): asserts s is string | undefined | null {
92
96
  if (s === undefined || s === null) return;
93
- if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s)) {
97
+ if (typeof s !== "string" || s.length === 0 || s.length > 100 || !MODEL_RE.test(s) || MODEL_DOT_ONLY_SEGMENT.test(s)) {
94
98
  throw new ValidationError("model_invalid", { value: typeof s === "string" ? s.slice(0, 80) : typeof s });
95
99
  }
96
100
  }
@@ -0,0 +1,73 @@
1
+ import { expect, test } from "bun:test";
2
+ import { maybeGzipResponse, wantsGzip, isCompressibleResponse, trimLightTask, GZIP_MIN_BYTES, LIGHT_TASK_MAX_CHARS } from "./http-gzip";
3
+
4
+ const big = JSON.stringify({ sessions: Array.from({ length: 200 }, (_, i) => ({ alias: `节点${i}`, task: "x".repeat(50) })) });
5
+ const json = (body: string, extra: Record<string, string> = {}) =>
6
+ new Response(body, { status: 200, headers: { "Content-Type": "application/json; charset=utf-8", ...extra } });
7
+ const reqWith = (ae?: string, method = "GET") =>
8
+ new Request("http://hub/api/status?light=1", { method, headers: ae === undefined ? {} : { "Accept-Encoding": ae } });
9
+
10
+ test("wantsGzip reads Accept-Encoding, including q-values", () => {
11
+ expect(wantsGzip(reqWith("gzip, deflate, br"))).toBe(true);
12
+ expect(wantsGzip(reqWith("br;q=1.0, gzip;q=0.8"))).toBe(true);
13
+ expect(wantsGzip(reqWith("identity"))).toBe(false);
14
+ expect(wantsGzip(reqWith("gzip;q=0"))).toBe(false);
15
+ expect(wantsGzip(reqWith())).toBe(false);
16
+ });
17
+
18
+ test("a large JSON body is gzipped only when the client asked, and round-trips", async () => {
19
+ const out = await maybeGzipResponse(reqWith("gzip"), json(big));
20
+ expect(out!.headers.get("Content-Encoding")).toBe("gzip");
21
+ expect(out!.headers.get("Vary")).toContain("Accept-Encoding");
22
+ const bytes = new Uint8Array(await out!.arrayBuffer());
23
+ expect(bytes.byteLength).toBeLessThan(big.length / 3);
24
+ expect(Number(out!.headers.get("Content-Length"))).toBe(bytes.byteLength);
25
+ expect(new TextDecoder().decode(Bun.gunzipSync(bytes))).toBe(big);
26
+ expect(out!.headers.get("Content-Type")).toContain("application/json");
27
+
28
+ const plain = json(big);
29
+ expect(await maybeGzipResponse(reqWith(), plain)).toBe(plain); // untouched object
30
+ });
31
+
32
+ test("small, streamed, already-encoded, SSE, 204 and HEAD responses are left alone", async () => {
33
+ const small = json("{\"ok\":true}");
34
+ const outSmall = await maybeGzipResponse(reqWith("gzip"), small);
35
+ expect(outSmall!.headers.get("Content-Encoding")).toBeNull();
36
+ expect(await outSmall!.text()).toBe("{\"ok\":true}");
37
+ expect(GZIP_MIN_BYTES).toBe(1024);
38
+
39
+ const sse = new Response(new ReadableStream(), { headers: { "Content-Type": "text/event-stream" } });
40
+ expect(isCompressibleResponse(sse)).toBe(false);
41
+ expect(await maybeGzipResponse(reqWith("gzip"), sse)).toBe(sse);
42
+
43
+ const encoded = json(big, { "Content-Encoding": "br" });
44
+ expect(await maybeGzipResponse(reqWith("gzip"), encoded)).toBe(encoded);
45
+
46
+ const noContent = new Response(null, { status: 204 });
47
+ expect(await maybeGzipResponse(reqWith("gzip"), noContent)).toBe(noContent);
48
+
49
+ const head = json(big);
50
+ expect(await maybeGzipResponse(reqWith("gzip", "HEAD"), head)).toBe(head);
51
+
52
+ // /api/files downloads: Range replies and attachments keep their exact byte lengths (CI #509/#514 caught this)
53
+ const partial = new Response("x".repeat(4096), { status: 206, headers: { "Content-Type": "text/plain", "Content-Range": "bytes 0-4095/8192" } });
54
+ expect(await maybeGzipResponse(reqWith("gzip"), partial)).toBe(partial);
55
+ const attachment = new Response("y".repeat(4096), { status: 200, headers: { "Content-Type": "text/plain", "Content-Disposition": "attachment; filename=\"a.txt\"" } });
56
+ expect(await maybeGzipResponse(reqWith("gzip"), attachment)).toBe(attachment);
57
+
58
+ const binary = new Response(new Uint8Array(4096), { headers: { "Content-Type": "image/png" } });
59
+ expect(await maybeGzipResponse(reqWith("gzip"), binary)).toBe(binary);
60
+
61
+ expect(await maybeGzipResponse(reqWith("gzip"), undefined)).toBeUndefined();
62
+ });
63
+
64
+ test("trimLightTask keeps a one-line preview under the cap", () => {
65
+ expect(trimLightTask(null)).toBeNull();
66
+ expect(trimLightTask(" ")).toBeNull();
67
+ expect(trimLightTask("a\n b")).toBe("a b");
68
+ const long = "字".repeat(LIGHT_TASK_MAX_CHARS + 40);
69
+ const t = trimLightTask(long)!;
70
+ expect(t.endsWith("…")).toBe(true);
71
+ expect([...t].length).toBe(LIGHT_TASK_MAX_CHARS + 1);
72
+ expect(trimLightTask("short")).toBe("short");
73
+ });
@@ -0,0 +1,60 @@
1
+ // 2026-09-16(Vincent:「发一个消息都要被吞一段时间」「打开窗口历史很久才出来」):
2
+ // 桌面端经 RELAY 连 hub,链路实测只有 ~13 KB/s;/api/status?light=1 128 KB 要 17 s,
3
+ // 一页 20 条任务 41 KB 要 5.6 s,而 hub 从不压缩。gzip 后 128 KB → 31 KB、137 KB → 49 KB。
4
+ // 只在客户端明确 `Accept-Encoding: gzip` 时压;text/event-stream(SSE)与流式 body 一律不碰。
5
+ export const GZIP_MIN_BYTES = 1024;
6
+
7
+ const COMPRESSIBLE = /^(application\/json|text\/(plain|html|css|javascript)|application\/javascript)\b/i;
8
+
9
+ export function wantsGzip(req: Request): boolean {
10
+ const ae = req.headers.get("accept-encoding") || "";
11
+ for (const part of ae.split(",")) {
12
+ const [coding, ...params] = part.trim().split(";").map((x) => x.trim());
13
+ if (coding.toLowerCase() !== "gzip") continue;
14
+ const q = params.find((x) => /^q=/i.test(x));
15
+ if (!q) return true;
16
+ const n = Number(q.slice(2));
17
+ return Number.isFinite(n) && n > 0;
18
+ }
19
+ return false;
20
+ }
21
+
22
+ export function isCompressibleResponse(res: Response): boolean {
23
+ // 只压完整的 200:206 Partial Content / Content-Range / 附件下载(Content-Disposition)都有自己的
24
+ // 字节语义(Range 客户端按 Content-Length 数字节),压了就错 —— CI 的 /api/files Range 测试抓到的。
25
+ if (res.status !== 200 || !res.body) return false;
26
+ if (res.headers.get("content-encoding") || res.headers.get("content-range") || res.headers.get("content-disposition")) return false;
27
+ const ct = res.headers.get("content-type") || "";
28
+ if (/text\/event-stream/i.test(ct)) return false;
29
+ return COMPRESSIBLE.test(ct);
30
+ }
31
+
32
+ /**
33
+ * Gzip a finished response when the client asked for it and the body is worth it.
34
+ * Reads the body once; callers must not have consumed it. Anything streamed or
35
+ * not compressible is returned untouched (same object).
36
+ */
37
+ export async function maybeGzipResponse(req: Request, res: Response | undefined | void): Promise<Response | undefined> {
38
+ if (!res) return res as undefined;
39
+ if (req.method === "HEAD" || !wantsGzip(req) || !isCompressibleResponse(res)) return res;
40
+ const raw = new Uint8Array(await res.arrayBuffer());
41
+ if (raw.byteLength < GZIP_MIN_BYTES) {
42
+ return new Response(raw, { status: res.status, statusText: res.statusText, headers: res.headers });
43
+ }
44
+ const gz = Bun.gzipSync(raw);
45
+ const headers = new Headers(res.headers);
46
+ headers.set("Content-Encoding", "gzip");
47
+ headers.set("Content-Length", String(gz.byteLength));
48
+ const vary = headers.get("Vary");
49
+ headers.set("Vary", vary && !/accept-encoding/i.test(vary) ? `${vary}, Accept-Encoding` : (vary || "Accept-Encoding"));
50
+ return new Response(gz, { status: res.status, statusText: res.statusText, headers });
51
+ }
52
+
53
+ /** 轻量列表里的 task 只给列表预览用;全文占了 light 载荷的一半以上。 */
54
+ export const LIGHT_TASK_MAX_CHARS = 160;
55
+ export function trimLightTask(task: unknown): string | null {
56
+ if (typeof task !== "string") return null;
57
+ const t = task.replace(/\s+/g, " ").trim();
58
+ if (!t) return null;
59
+ return t.length > LIGHT_TASK_MAX_CHARS ? `${t.slice(0, LIGHT_TASK_MAX_CHARS)}…` : t;
60
+ }
package/src/server.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { buildServeErrorResponse } from "./serve-error.js";
2
+ import { maybeGzipResponse, trimLightTask } from "./http-gzip";
2
3
  import { redactMessageRow } from "./redact-tokens.js";
3
4
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
5
  import { parseDbTimestampMs } from "./db-timestamp.js";
@@ -792,6 +793,8 @@ return Bun.serve({
792
793
  error: (err) => buildServeErrorResponse(err),
793
794
 
794
795
  async fetch(req, server) {
796
+ // 2026-09-16 —— 所有 JSON/文本响应在客户端要求时 gzip(见 http-gzip.ts);SSE/流式/二进制原样。
797
+ return maybeGzipResponse(req, await (async (): Promise<Response | undefined> => {
795
798
  const url = new URL(req.url, `http://localhost:${PORT}`);
796
799
 
797
800
  // ── CORS preflight ──
@@ -1661,7 +1664,8 @@ return Bun.serve({
1661
1664
  alias: s.alias,
1662
1665
  status: s.status,
1663
1666
  agent: s.agent ?? null,
1664
- task: s.task ?? null,
1667
+ // 列表预览只要一行;全文 task 占 light 载荷 52%(294 节点 128 KB)。全量 /api/status 不变。
1668
+ task: trimLightTask(s.task),
1665
1669
  server: s.server ?? null,
1666
1670
  updated_at: s.updated_at ?? null,
1667
1671
  runtime: normalizeRuntime(s.agent),
@@ -3571,6 +3575,7 @@ Security: ${SECURITY_LABEL}
3571
3575
  `,
3572
3576
  { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8" } } // #426
3573
3577
  ));
3578
+ })());
3574
3579
  },
3575
3580
 
3576
3581
  // ── WebSocket handler for tmux terminal streaming ──