并行开发前的清场:白名单、读流模块、流量格式化
任务书承诺这些在分发前已存在。/traffic /logs 补进读白名单(在流式清单 里却不在读白名单里,仪表盘和请求页一开工就 403),PUT /providers/rules 补进写白名单(规则页的立刻更新)。web/stream.ts 把分块流缝成完整的行, 分块边界和行边界无关是它存在的全部理由;shared/format.ts 是连接页和 仪表盘共用的流量数字格式化。
This commit is contained in:
@@ -47,6 +47,8 @@ const WRITABLE: Array<{ method: 'PUT' | 'DELETE'; pattern: RegExp }> = [
|
|||||||
{ method: 'DELETE', pattern: /^\/connections(\/[^/]+)?$/ },
|
{ method: 'DELETE', pattern: /^\/connections(\/[^/]+)?$/ },
|
||||||
// 让内核重新拉取某个订阅
|
// 让内核重新拉取某个订阅
|
||||||
{ method: 'PUT', pattern: /^\/providers\/proxies\/[^/]+$/ },
|
{ method: 'PUT', pattern: /^\/providers\/proxies\/[^/]+$/ },
|
||||||
|
// 让内核重新拉取某个规则组
|
||||||
|
{ method: 'PUT', pattern: /^\/providers\/rules\/[^/]+$/ },
|
||||||
]
|
]
|
||||||
|
|
||||||
/** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */
|
/** 允许透传的管理接口路径。白名单而不是黑名单——多开一个口子要有意为之。 */
|
||||||
@@ -59,6 +61,8 @@ const READABLE_KERNEL_PATHS = new Set([
|
|||||||
'/providers/rules',
|
'/providers/rules',
|
||||||
'/connections',
|
'/connections',
|
||||||
'/memory',
|
'/memory',
|
||||||
|
'/traffic',
|
||||||
|
'/logs',
|
||||||
])
|
])
|
||||||
|
|
||||||
function json(body: unknown, status = 200): Response {
|
function json(body: unknown, status = 200): Response {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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]}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { readLines } from './stream.ts'
|
||||||
|
|
||||||
|
function streamOf(chunks: string[]): ReadableStream<Uint8Array> {
|
||||||
|
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<string[]> {
|
||||||
|
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<Uint8Array>({
|
||||||
|
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'])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
/**
|
||||||
|
* 把持续吐数据的 HTTP 响应体读成一行一行。
|
||||||
|
*
|
||||||
|
* 内核的 /traffic、/memory、/logs 在没有 WebSocket 升级时退化成分块流,一行一个
|
||||||
|
* JSON。分块的边界和行的边界无关:一次 read 可能拿到半行,也可能拿到好几行。
|
||||||
|
* 这里把它们缝成完整的行再交出去,空行丢弃。
|
||||||
|
*
|
||||||
|
* 停止读取的方式是中止 fetch(AbortController)或 break 出循环,两种都会走到
|
||||||
|
* finally 里把流取消掉,不留悬着的连接。
|
||||||
|
*/
|
||||||
|
export async function* readLines(body: ReadableStream<Uint8Array>): AsyncGenerator<string> {
|
||||||
|
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(() => {})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user