import { randomUUID } from 'node:crypto' import { Database } from '@hocuspocus/extension-database' import { Hocuspocus } from '@hocuspocus/server' import * as Y from 'yjs' import { META_DOC, boardCards, boardDocName, createCardMap, docFromUpdate, metaSpaces, readCards, readSpace, readSpaces, replaceBoardContent, spaceIdFromDocName, type CardFields, type SpaceInfo, } from '../shared/board.ts' import { SESSION_COOKIE, readCookie, type SessionManager } from './auth.ts' import type { SnapshotReason, Storage } from './db.ts' /** 自动历史版本的最小间隔 */ const AUTO_SNAPSHOT_INTERVAL = 10 * 60 * 1000 export interface SearchHit { spaceId: string cardId: string title: string snippet: string } type DirectConnection = Awaited> export class Collab { readonly hocuspocus: Hocuspocus private readonly storage: Storage private readonly sessions: SessionManager private meta: DirectConnection | null = null constructor(storage: Storage, sessions: SessionManager) { this.storage = storage this.sessions = sessions this.hocuspocus = new Hocuspocus({ name: 'boundless', quiet: true, debounce: 1000, maxDebounce: 5000, extensions: [ new Database({ fetch: async ({ documentName }) => this.storage.loadDocument(documentName), store: async ({ documentName, state, document }) => { this.persist(documentName, state, document) }, }), ], onAuthenticate: async ({ requestHeaders, documentName, connectionConfig }) => { const token = readCookie(requestHeaders.get('cookie'), SESSION_COOKIE) if (!this.sessions.verify(token)) throw rejection('unauthorized') if (documentName === META_DOC) { // 空间列表只能由服务端修改 connectionConfig.readOnly = true return } const spaceId = spaceIdFromDocName(documentName) if (!spaceId || !this.spaceExists(spaceId)) throw rejection('not-found') }, }) } async start() { this.meta = await this.hocuspocus.openDirectConnection(META_DOC, { system: true }) if (metaSpaces(this.metaDoc).size === 0) await this.seed() } async stop() { await this.meta?.disconnect() this.meta = null } private get metaDoc(): Y.Doc { const doc = this.meta?.document if (!doc) throw new Error('collab not started') return doc } // ---------------------------------------------------------------- 空间 spaceExists(spaceId: string) { return metaSpaces(this.metaDoc).has(spaceId) } listSpaces(): SpaceInfo[] { return readSpaces(this.metaDoc) } async createSpace(name: string, emoji: string): Promise { const id = randomUUID() const now = Date.now() await this.meta!.transact((doc) => { const map = new Y.Map() map.set('name', name) map.set('emoji', emoji) map.set('createdAt', now) map.set('updatedAt', now) map.set('cardCount', 0) metaSpaces(doc).set(id, map) }) return readSpace(id, metaSpaces(this.metaDoc).get(id)!) } async updateSpace(spaceId: string, patch: { name?: string; emoji?: string }) { const map = metaSpaces(this.metaDoc).get(spaceId) if (!map) return null await this.meta!.transact(() => { if (patch.name !== undefined) map.set('name', patch.name) if (patch.emoji !== undefined) map.set('emoji', patch.emoji) map.set('updatedAt', Date.now()) }) return readSpace(spaceId, map) } async deleteSpace(spaceId: string) { if (!this.spaceExists(spaceId)) return false await this.meta!.transact((doc) => { metaSpaces(doc).delete(spaceId) }) const name = boardDocName(spaceId) // 断开仍打开这个空间的设备;persist() 会忽略已删除空间的延迟写入 this.hocuspocus.closeConnections(name) this.storage.deleteDocument(name) this.storage.deleteSnapshots(spaceId) return true } // ---------------------------------------------------------------- 持久化 private persist(documentName: string, state: Uint8Array, document: Y.Doc) { const spaceId = spaceIdFromDocName(documentName) if (spaceId) { if (!this.spaceExists(spaceId)) return this.autoSnapshot(spaceId, documentName) } this.storage.saveDocument(documentName, state) if (spaceId) this.updateCardCount(spaceId, boardCards(document).size) } /** * 写入新状态前,把上一次保存的状态存成历史版本。 * 保存的是“改动之前”的样子,误删一大片之后也能找回来。 */ private autoSnapshot(spaceId: string, documentName: string) { const latest = this.storage.latestSnapshot(spaceId) if (latest && Date.now() - latest.createdAt < AUTO_SNAPSHOT_INTERVAL) return const previous = this.storage.loadDocument(documentName) if (!previous) return this.snapshotFromDoc(spaceId, docFromUpdate(previous), 'auto', latest?.stateVector) } private snapshotFromDoc( spaceId: string, doc: Y.Doc, reason: SnapshotReason, skipIfStateVector?: Uint8Array, ) { const stateVector = Y.encodeStateVector(doc) if (skipIfStateVector && sameBytes(stateVector, skipIfStateVector)) return null const cards = readCards(doc) const preview = cards .map((card) => card.title.trim()) .filter(Boolean) .slice(0, 4) .join('、') return this.storage.addSnapshot({ spaceId, data: Y.encodeStateAsUpdate(doc), stateVector, cardCount: cards.length, preview, reason, }) } private updateCardCount(spaceId: string, count: number) { const map = metaSpaces(this.metaDoc).get(spaceId) if (!map || map.get('cardCount') === count) return void this.meta!.transact(() => { map.set('cardCount', count) }) } /** 读取空间画布:已在内存中的用实时文档,否则从数据库加载一份只读副本。 */ private boardDoc(spaceId: string): Y.Doc | null { const name = boardDocName(spaceId) const live = this.hocuspocus.documents.get(name) if (live) return live const saved = this.storage.loadDocument(name) return saved ? docFromUpdate(saved) : null } // ---------------------------------------------------------------- 历史版本 listSnapshots(spaceId: string) { return this.storage.listSnapshots(spaceId) } saveSnapshot(spaceId: string, reason: SnapshotReason = 'manual') { const doc = this.boardDoc(spaceId) if (!doc) return null return this.snapshotFromDoc(spaceId, doc, reason) } async restoreSnapshot(spaceId: string, snapshotId: number) { const data = this.storage.loadSnapshot(spaceId, snapshotId) if (!data) return false // 先备份当前状态,恢复操作本身也可以撤回 this.saveSnapshot(spaceId, 'restore') const connection = await this.hocuspocus.openDirectConnection(boardDocName(spaceId), { system: true, }) try { await connection.transact((doc) => replaceBoardContent(doc, docFromUpdate(data))) } finally { await connection.disconnect() } return true } // ---------------------------------------------------------------- 搜索 search(query: string, limit = 60): SearchHit[] { const needle = query.trim().toLocaleLowerCase() if (!needle) return [] const hits: SearchHit[] = [] for (const space of this.listSpaces()) { const doc = this.boardDoc(space.id) if (!doc) continue for (const card of readCards(doc)) { const haystack = `${card.title}\n${card.content}` const index = haystack.toLocaleLowerCase().indexOf(needle) if (index === -1) continue hits.push({ spaceId: space.id, cardId: card.id, title: card.title, snippet: snippetAround(haystack, index, needle.length), }) if (hits.length >= limit) return hits } } return hits } // ---------------------------------------------------------------- 初始内容 private async seed() { const space = await this.createSpace('我的宇宙', '✦') const now = Date.now() const cards: Array> = [ { kind: 'note', title: '欢迎来到无界笺', content: '双击空白处,马上记下一件事。\n\n这里的内容保存在你的服务器上,在任何一台设备上打开都是同一张画布。', color: 'paper', x: -180, y: -80, width: 330, height: 240, }, { kind: 'note', title: '今天想做的事', content: '□ 收集灵感\n□ 整理一个想法\n□ 留一点时间发呆', color: 'sun', x: 220, y: -140, width: 290, height: 210, }, { kind: 'note', title: '一个小提示', content: '拖入或粘贴图片,卡片右侧圆点可以拉出连线。Shift+拖拽批量框选,右上角可以找回历史版本。', color: 'mint', x: 120, y: 170, width: 300, height: 200, }, ] const connection = await this.hocuspocus.openDirectConnection(boardDocName(space.id), { system: true, }) try { await connection.transact((doc) => { const map = boardCards(doc) cards.forEach((card, index) => { map.set( randomUUID(), createCardMap({ ...card, z: index, createdAt: now + index, updatedAt: now + index }), ) }) }) } finally { await connection.disconnect() } } } /** Hocuspocus 会把 error.reason 原样发给客户端,客户端据此区分“未登录”和“空间不存在” */ function rejection(reason: 'unauthorized' | 'not-found') { return Object.assign(new Error(reason), { reason }) } function sameBytes(a: Uint8Array, b: Uint8Array) { if (a.length !== b.length) return false for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false return true } function snippetAround(text: string, index: number, length: number) { const start = Math.max(0, index - 24) const end = Math.min(text.length, index + length + 48) const body = text.slice(start, end).replace(/\s+/g, ' ').trim() return `${start > 0 ? '…' : ''}${body}${end < text.length ? '…' : ''}` }