refactor(web): adopt modular Vue architecture and isolate bearer sessions

This commit is contained in:
eezd committed 2026-10-25 20:47:32 +08:00
1 parent 1b2b2b12ff
commit 3cd641a380
379 files changed
+19736 -8712

No files matched your search

+43
View File
@@ -0,0 +1,43 @@
import type { HighlightRequest } from './protocol'
// @vitest-environment happy-dom
import { expect, it, vi } from 'vitest'
import { requestTokens } from './client'
const control = vi.hoisted(() => ({ startupError: undefined as Error | undefined, workers: [] as { dispatchEvent: (event: Event) => boolean, request?: HighlightRequest }[] }))
vi.mock('./worker?worker', () => ({
default: class extends EventTarget {
request?: HighlightRequest
constructor() {
super()
if (control.startupError) throw control.startupError
control.workers.push(this)
}
postMessage(request: HighlightRequest) {
this.request = request
}
terminate() {}
},
}))
it('启动和运行失败均结束请求,失败不会伪装成缓存结果且后续请求可恢复', async () => {
const startupError = new Error('无法创建 Worker')
control.startupError = startupError
await expect(requestTokens('python', 'print(1)')).rejects.toBe(startupError)
control.startupError = undefined
const failed = requestTokens('python', 'print(1)')
const error = new Error('语言加载失败')
const worker = control.workers[0]!
const rejected = expect(failed).rejects.toBe(error)
worker.dispatchEvent(new MessageEvent('message', { data: { id: worker.request!.id, error } }))
await rejected
const interrupted = requestTokens('python', 'print(1)')
const crashed = expect(interrupted).rejects.toMatchObject({ cause: error })
worker.dispatchEvent(new ErrorEvent('error', { error }))
await crashed
const recovered = requestTokens('python', 'print(1)')
const replacement = control.workers[1]!
replacement.dispatchEvent(new MessageEvent('message', { data: { id: replacement.request!.id, tokens: [{ classes: 'hljs-title function_', content: ['print'] }] } }))
await expect(recovered).resolves.toEqual([{ classes: 'hljs-title function_', content: ['print'] }])
})
+59
View File
@@ -0,0 +1,59 @@
import type { HighlightRequest, HighlightResponse, TokenRun } from './protocol'
import HighlightWorker from './worker?worker'
export type { TokenRun }
const CACHE_LIMIT = 500
let worker: Worker | undefined
let nextId = 1
const pending = new Map<number, { resolve: (tokens: TokenRun | null) => void, reject: (reason: unknown) => void }>()
// 以"语言 + 代码"为键缓存结果;Map 保持插入顺序,超限时淘汰最早的条目
const cache = new Map<string, TokenRun | null>()
function getWorker(): Worker {
if (!worker) {
worker = new HighlightWorker()
worker.addEventListener('message', (e: MessageEvent<HighlightResponse>) => {
const request = pending.get(e.data.id)
pending.delete(e.data.id)
if ('error' in e.data) request?.reject(e.data.error)
else request?.resolve(e.data.tokens)
})
worker.addEventListener('error', (event) => {
const error = new Error('高亮 Worker 运行失败', { cause: event.error ?? event.message })
for (const request of pending.values()) request.reject(error)
pending.clear()
worker?.terminate()
worker = undefined
})
}
return worker
}
export function cachedTokens(lang: string, code: string): TokenRun | null | undefined {
return cache.get(`${lang}\0${code}`)
}
export async function requestTokens(lang: string, code: string): Promise<TokenRun | null> {
const key = `${lang}\0${code}`
if (cache.has(key)) return Promise.resolve(cache.get(key)!)
const id = nextId++
const req: HighlightRequest = { id, lang, code }
const target = getWorker()
return new Promise((resolve, reject) => {
pending.set(id, {
resolve: (tokens) => {
cache.set(key, tokens)
if (cache.size > CACHE_LIMIT) cache.delete(cache.keys().next().value!)
resolve(tokens)
},
reject,
})
try {
target.postMessage(req)
} catch (error) {
pending.delete(id); reject(error)
}
})
}
+13
View File
@@ -0,0 +1,13 @@
// 主线程与高亮 Worker 之间的消息格式
export interface HighlightRequest {
id: number
lang: string
code: string
}
// 保留 highlight.js 的嵌套 token 与原文,编辑器使用官方 token 类样式。
export type TokenRun = (string | { classes: string, content: TokenRun })[]
// 语言不受支持时 tokens 为 null;实际失败必须与纯文本结果区分。
export type HighlightResponse = { id: number, tokens: TokenRun | null } | { id: number, error: Error }
+100
View File
@@ -0,0 +1,100 @@
/* eslint-disable no-template-curly-in-string -- 测试样例需要保留源码中的模板插值。 */
import type { HighlightResponse, TokenRun } from './protocol'
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
const replies = new Map<number, HighlightResponse>()
const worker = new class extends EventTarget {
postMessage(response: HighlightResponse) {
replies.set(response.id, response)
}
}()
let nextId = 1
beforeAll(async () => {
vi.stubGlobal('addEventListener', worker.addEventListener.bind(worker))
vi.stubGlobal('postMessage', worker.postMessage.bind(worker))
await import('./worker')
})
afterAll(() => {
vi.restoreAllMocks(); vi.unstubAllGlobals()
})
async function highlight(lang: string, code: string) {
const id = nextId++
worker.dispatchEvent(new MessageEvent('message', { data: { id, lang, code } }))
await vi.waitFor(() => expect(replies.has(id)).toBe(true))
const reply = replies.get(id)!
if ('error' in reply) throw reply.error
return reply.tokens
}
function text(tokens: TokenRun): string {
return tokens.map(token => typeof token === 'string' ? token : text(token.content)).join('')
}
function hasToken(tokens: TokenRun, classes: string, content: string): boolean {
return tokens.some(token => typeof token !== 'string' && ((token.classes === classes && text(token.content) === content) || hasToken(token.content, classes, content)))
}
it('首次高亮 HTML 就包含内嵌脚本和样式,不依赖其他代码块先加载语法', async () => {
const source = '<script>const value = 1</script><style>a { color: red }</style>'
const tokens = await highlight('html', source)
expect(tokens).not.toBeNull()
expect(text(tokens!)).toBe(source)
expect(hasToken(tokens!, 'hljs-keyword', 'const')).toBe(true)
expect(hasToken(tokens!, 'hljs-attribute', 'color')).toBe(true)
})
it('highlight.js token 保留原文、语言别名与嵌套语法,不再返回旧颜色', async () => {
const source = 'const value = `中文😀 ${name}` // comment\n'
const tokens = await highlight('js', source)
expect(tokens).not.toBeNull()
expect(text(tokens!)).toBe(source)
expect(hasToken(tokens!, 'hljs-keyword', 'const')).toBe(true)
expect(hasToken(tokens!, 'hljs-string', '`中文😀 ${name}`')).toBe(true)
expect(hasToken(tokens!, 'hljs-subst', '${name}')).toBe(true)
expect(hasToken(tokens!, 'hljs-comment', '// comment')).toBe(true)
expect(await highlight('javascript', source)).toEqual(tokens)
})
it('并发请求和常见别名得到实际高亮', async () => {
const samples = [
['python', 'print("hello")', 'hljs-string', '"hello"'],
['tsx', 'const App = () => <div title="hello">你好</div>', 'hljs-string', '"hello"'],
['sh', 'echo "$HOME"', 'hljs-variable', '$HOME'],
['yaml', 'name: hello', 'hljs-attr', 'name:'],
['json', '{"value": true}', 'hljs-keyword', 'true'],
['go', 'package main\nfunc main() {}', 'hljs-keyword', 'package'],
['php', '<?php echo $value; ?><div>中文😀</div>', 'hljs-keyword', 'echo'],
]
await Promise.all(samples.map(async ([lang, source, classes, content]) => {
const tokens = await highlight(lang, source)
expect(tokens).not.toBeNull()
expect(text(tokens!)).toBe(source)
expect(hasToken(tokens!, classes, content), lang).toBe(true)
}))
})
it('未知语言保持纯文本,真实分词失败返回原始原因且不阻断下一次请求', async () => {
for (const lang of ['unknown-language', 'constructor', '__proto__']) expect(await highlight(lang, 'text')).toBeNull()
const { default: hljs } = await import('highlight.js')
const cause = new Error('分词失败')
vi.spyOn(hljs, 'highlight').mockImplementationOnce(() => {
throw cause
})
await expect(highlight('python', 'print(1)')).rejects.toMatchObject({ cause })
expect(await highlight('python', 'print(1)')).not.toBeNull()
})
it('hTML 特殊字符、转义实体和伪造 span 原样保留,不作为标记解析', async () => {
const source = `const value = "&lt; &amp; &quot; &#x27; <span class='hljs-keyword'>中文😀</span>"; // > & '\n`
const tokens = await highlight('js', source)
expect(text(tokens!)).toBe(source)
expect(hasToken(tokens!, 'hljs-keyword', '中文😀')).toBe(false)
})
it('库安全模式返回的实际异常不伪装成成功的纯文本结果', async () => {
const { default: hljs } = await import('highlight.js')
const cause = new Error('语法运行失败')
const original = hljs.highlight('value', { language: 'js' })
vi.spyOn(hljs, 'highlight').mockReturnValueOnce({ ...original, errorRaised: cause })
await expect(highlight('js', 'value')).rejects.toMatchObject({ cause })
})
+39
View File
@@ -0,0 +1,39 @@
import type { HighlightRequest, HighlightResponse, TokenRun } from './protocol'
import hljs from 'highlight.js'
// 只转换 highlight.js 生成的 span 和转义文本,不向编辑器插入 HTML。
function serialize(html: string): TokenRun {
const root: TokenRun = []
const stack = [root]
const entities: Record<string, string> = { '&amp;': '&', '&lt;': '<', '&gt;': '>', '&quot;': '"', '&#x27;': '\'' }
for (const match of html.matchAll(/<span class="([^"]+)">|(<\/span>)|([^<]+)/g)) {
if (match[1]) {
const content: TokenRun = []
stack[stack.length - 1].push({ classes: match[1], content })
stack.push(content)
} else if (match[2]) {
stack.pop()
} else {
stack[stack.length - 1].push(match[3].replace(/&(?:amp|lt|gt|quot|#x27);/g, entity => entities[entity]))
}
}
return root
}
function highlight(req: HighlightRequest): TokenRun | null {
if (!hljs.getLanguage(req.lang)) return null
const result = hljs.highlight(req.code, { language: req.lang, ignoreIllegals: true })
// 库的安全模式会随纯文本结果返回异常,仍需传递真实失败原因。
if (result.errorRaised) throw result.errorRaised
return serialize(result.value)
}
globalThis.addEventListener('message', (e: MessageEvent<HighlightRequest>) => {
let res: HighlightResponse
try {
res = { id: e.data.id, tokens: highlight(e.data) }
} catch (cause) {
res = { id: e.data.id, error: new Error('代码高亮失败', { cause }) }
}
globalThis.postMessage(res)
})