From 6c8a1c4e0e9205b1314fb05b67c43789d84faf3d Mon Sep 17 00:00:00 2001 From: Jz0ojiang Date: Tue, 11 Aug 2026 18:02:21 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E5=BC=80=E5=8F=91=E5=89=8D?= =?UTF-8?q?=E7=9A=84=E6=B8=85=E5=9C=BA=EF=BC=9A=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E3=80=81=E8=AF=BB=E6=B5=81=E6=A8=A1=E5=9D=97=E3=80=81=E6=B5=81?= =?UTF-8?q?=E9=87=8F=E6=A0=BC=E5=BC=8F=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 任务书承诺这些在分发前已存在。/traffic /logs 补进读白名单(在流式清单 里却不在读白名单里,仪表盘和请求页一开工就 403),PUT /providers/rules 补进写白名单(规则页的立刻更新)。web/stream.ts 把分块流缝成完整的行, 分块边界和行边界无关是它存在的全部理由;shared/format.ts 是连接页和 仪表盘共用的流量数字格式化。 --- server/routes.ts | 4 ++++ shared/format.test.ts | 30 +++++++++++++++++++++++ shared/format.ts | 20 ++++++++++++++++ web/stream.test.ts | 55 +++++++++++++++++++++++++++++++++++++++++++ web/stream.ts | 33 ++++++++++++++++++++++++++ 5 files changed, 142 insertions(+) create mode 100644 shared/format.test.ts create mode 100644 shared/format.ts create mode 100644 web/stream.test.ts create mode 100644 web/stream.ts diff --git a/server/routes.ts b/server/routes.ts index ca74bdd..c626298 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -47,6 +47,8 @@ const WRITABLE: Array<{ method: 'PUT' | 'DELETE'; pattern: RegExp }> = [ { method: 'DELETE', pattern: /^\/connections(\/[^/]+)?$/ }, // 让内核重新拉取某个订阅 { method: 'PUT', pattern: /^\/providers\/proxies\/[^/]+$/ }, + // 让内核重新拉取某个规则组 + { method: 'PUT', pattern: /^\/providers\/rules\/[^/]+$/ }, ] /** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */ @@ -59,6 +61,8 @@ const READABLE_KERNEL_PATHS = new Set([ '/providers/rules', '/connections', '/memory', + '/traffic', + '/logs', ]) function json(body: unknown, status = 200): Response { diff --git a/shared/format.test.ts b/shared/format.test.ts new file mode 100644 index 0000000..eb75367 --- /dev/null +++ b/shared/format.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'bun:test' +import { formatBytes } from './format.ts' + +describe('流量数字', () => { + it('零和不满 1KB 的按字节显示,不带小数', () => { + expect(formatBytes(0)).toBe('0 B') + expect(formatBytes(512)).toBe('512 B') + expect(formatBytes(1023)).toBe('1023 B') + }) + + it('1024 的边界正好换档', () => { + expect(formatBytes(1024)).toBe('1 KB') + expect(formatBytes(1024 * 1024)).toBe('1 MB') + }) + + it('不满 100 保留一位小数,够 100 取整', () => { + expect(formatBytes(23.4 * 1024 * 1024)).toBe('23.4 MB') + expect(formatBytes(234.7 * 1024 * 1024)).toBe('235 MB') + }) + + it('超大数停在 TB 档', () => { + expect(formatBytes(5 * 1024 ** 4)).toBe('5 TB') + expect(formatBytes(5000 * 1024 ** 4)).toBe('5000 TB') + }) + + it('脏输入不炸', () => { + expect(formatBytes(-1)).toBe('0 B') + expect(formatBytes(Number.NaN)).toBe('0 B') + }) +}) diff --git a/shared/format.ts b/shared/format.ts new file mode 100644 index 0000000..6be1949 --- /dev/null +++ b/shared/format.ts @@ -0,0 +1,20 @@ +/** + * 流量数字显示成人读的样子。连接页和仪表盘共用。 + * + * 1024 进制。B 一档不带小数;其余不满 100 的保留一位小数,够 100 的取整—— + * 「23.4 MB」有信息量,「234.7 MB」的小数就只是噪音了。 + */ +const UNITS = ['B', 'KB', 'MB', 'GB', 'TB'] as const + +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes < 0) return '0 B' + let value = bytes + let unit = 0 + while (value >= 1024 && unit < UNITS.length - 1) { + value /= 1024 + unit += 1 + } + const digits = unit === 0 || value >= 100 ? 0 : 1 + // Number() 去掉无意义的尾零:1.0 KB → 1 KB + return `${String(Number(value.toFixed(digits)))} ${UNITS[unit]}` +} diff --git a/web/stream.test.ts b/web/stream.test.ts new file mode 100644 index 0000000..aeb17b4 --- /dev/null +++ b/web/stream.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'bun:test' +import { readLines } from './stream.ts' + +function streamOf(chunks: string[]): ReadableStream { + const encoder = new TextEncoder() + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) controller.enqueue(encoder.encode(chunk)) + controller.close() + }, + }) +} + +async function collect(chunks: string[]): Promise { + const lines: string[] = [] + for await (const line of readLines(streamOf(chunks))) lines.push(line) + return lines +} + +describe('按行读流', () => { + it('一块里有好几行就拆开', async () => { + expect(await collect(['a\nb\nc\n'])).toEqual(['a', 'b', 'c']) + }) + + it('半行留在缓冲里,等下一块补齐', async () => { + // 分块边界和行边界无关,这是这个模块存在的全部理由 + expect(await collect(['{"up":1,"do', 'wn":2}\n{"up":3}\n'])).toEqual([ + '{"up":1,"down":2}', + '{"up":3}', + ]) + }) + + it('流结束时缓冲里剩的半行也交出去', async () => { + expect(await collect(['a\nb'])).toEqual(['a', 'b']) + }) + + it('空行丢弃,\\r\\n 换行的 \\r 去掉', async () => { + expect(await collect(['a\r\n\n\nb\n'])).toEqual(['a', 'b']) + }) + + it('多字节字符跨块也不会读坏', async () => { + const bytes = new TextEncoder().encode('香港01\n') + const stream = new ReadableStream({ + start(controller) { + // 故意从「港」字中间切开 + controller.enqueue(bytes.slice(0, 4)) + controller.enqueue(bytes.slice(4)) + controller.close() + }, + }) + const lines: string[] = [] + for await (const line of readLines(stream)) lines.push(line) + expect(lines).toEqual(['香港01']) + }) +}) diff --git a/web/stream.ts b/web/stream.ts new file mode 100644 index 0000000..77a6913 --- /dev/null +++ b/web/stream.ts @@ -0,0 +1,33 @@ +/** + * 把持续吐数据的 HTTP 响应体读成一行一行。 + * + * 内核的 /traffic、/memory、/logs 在没有 WebSocket 升级时退化成分块流,一行一个 + * JSON。分块的边界和行的边界无关:一次 read 可能拿到半行,也可能拿到好几行。 + * 这里把它们缝成完整的行再交出去,空行丢弃。 + * + * 停止读取的方式是中止 fetch(AbortController)或 break 出循环,两种都会走到 + * finally 里把流取消掉,不留悬着的连接。 + */ +export async function* readLines(body: ReadableStream): AsyncGenerator { + const reader = body.getReader() + const decoder = new TextDecoder() + let buffer = '' + try { + for (;;) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const parts = buffer.split('\n') + // 最后一段可能是半行,留在缓冲里等下一块补齐 + buffer = parts.pop() ?? '' + for (const part of parts) { + const line = part.endsWith('\r') ? part.slice(0, -1) : part + if (line) yield line + } + } + const rest = buffer.trim() + if (rest) yield rest + } finally { + await reader.cancel().catch(() => {}) + } +}