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