refactor(web): adopt modular Vue architecture and isolate bearer sessions
This commit is contained in:
1 parent
1b2b2b12ff
commit
3cd641a380
379 files changed
+19736
-8712
No files matched your search
@@ -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'] }])
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 = "< & " ' <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 })
|
||||
})
|
||||
@@ -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> = { '&': '&', '<': '<', '>': '>', '"': '"', ''': '\'' }
|
||||
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)
|
||||
})
|
||||
Reference in new issue
Block a user