Files
boundless/server/collab.ts
T
吴璨 fad9760265 自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器
- 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作
- 空间(标签)有独立地址,增删改通过接口同步到所有设备
- 口令登录(签名 Cookie 会话、登录限流、同源校验)
- 图片上传到服务器,过大照片上传前自动压缩
- 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备
- 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush)
- 选中样式与拖拽性能优化:拖动结束后才同步位置
- Docker / docker-compose 部署,README 与 CLAUDE.md 更新
2026-10-21 18:09:03 +08:00

332 lines
10 KiB
TypeScript

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<ReturnType<Hocuspocus['openDirectConnection']>>
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<SpaceInfo> {
const id = randomUUID()
const now = Date.now()
await this.meta!.transact((doc) => {
const map = new Y.Map<unknown>()
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<Omit<CardFields, 'createdAt' | 'updatedAt' | 'z'>> = [
{
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 ? '…' : ''}`
}