自部署多端同步:服务端、实时协作、口令登录与油画加载动画
- 新增 Node 服务端(Hono + Hocuspocus + node:sqlite),数据保存在服务器 - 基于 Yjs 的多端实时同步,本机 IndexedDB 离线缓存,撤销只针对本机操作 - 空间(标签)有独立地址,增删改通过接口同步到所有设备 - 口令登录(签名 Cookie 会话、登录限流、同源校验) - 图片上传到服务器,过大照片上传前自动压缩 - 历史版本(自动/手动保存、一键恢复)、跨空间搜索、在线设备 - 随机油画加载动画(Painting Loaders,本地收录 p5 / p5.brush) - 选中样式与拖拽性能优化:拖动结束后才同步位置 - Docker / docker-compose 部署,README 与 CLAUDE.md 更新
This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
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 ? '…' : ''}`
|
||||
}
|
||||
Reference in New Issue
Block a user