132 lines
3.7 KiB
JavaScript
132 lines
3.7 KiB
JavaScript
import { Server } from "@hocuspocus/server"
|
|
import { TiptapTransformer } from "@hocuspocus/transformer"
|
|
import * as Y from "yjs"
|
|
import { createRequire } from "node:module"
|
|
|
|
const require = createRequire(import.meta.url)
|
|
|
|
const StarterKit = require("@tiptap/starter-kit").default
|
|
|
|
const PORT = Number(process.env.HOCUSPOCUS_PORT || 1234)
|
|
const SECRET = process.env.HOCUSPOCUS_SECRET || ""
|
|
const ULTID_URL = (process.env.ULTID_INTERNAL_URL || "http://127.0.0.1").replace(/\/$/, "")
|
|
|
|
function decodeJwtPayload(token) {
|
|
const parts = token.split(".")
|
|
if (parts.length < 2) return null
|
|
try {
|
|
const json = Buffer.from(parts[1], "base64url").toString("utf8")
|
|
return JSON.parse(json)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function hookContext(payload) {
|
|
return payload?.lastContext ?? payload?.context ?? {}
|
|
}
|
|
|
|
async function loadFromUltid(context) {
|
|
if (!context?.path || !context?.user) return null
|
|
const params = new URLSearchParams({ user: context.user, path: context.path })
|
|
const res = await fetch(`${ULTID_URL}/api/v1/richtext/internal/document?${params}`, {
|
|
headers: SECRET ? { "X-Hocuspocus-Secret": SECRET } : {},
|
|
})
|
|
if (res.status === 404) return null
|
|
if (!res.ok) throw new Error(`load failed: ${res.status}`)
|
|
const raw = await res.text()
|
|
if (!raw.trim()) return null
|
|
try {
|
|
const doc = JSON.parse(raw)
|
|
if (doc.yjsState) {
|
|
return Buffer.from(doc.yjsState, "base64")
|
|
}
|
|
if (doc.content) {
|
|
const ydoc = TiptapTransformer.toYdoc(doc.content, "default", [StarterKit])
|
|
return Buffer.from(Y.encodeStateAsUpdate(ydoc))
|
|
}
|
|
} catch (err) {
|
|
console.error("[onLoadDocument] parse", err)
|
|
}
|
|
return null
|
|
}
|
|
|
|
async function storeToUltid(context, document) {
|
|
if (!context?.path || !context?.user) {
|
|
throw new Error("store missing path or user in context")
|
|
}
|
|
const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64")
|
|
let tiptapJson = null
|
|
try {
|
|
tiptapJson = TiptapTransformer.fromYdoc(document, "default")
|
|
} catch {
|
|
/* optional */
|
|
}
|
|
const body = {
|
|
room: context.room ?? context.path,
|
|
path: context.path,
|
|
user: context.user,
|
|
sub: context.sub ?? "",
|
|
yjsState: state,
|
|
document: tiptapJson,
|
|
}
|
|
const res = await fetch(`${ULTID_URL}/api/v1/richtext/hooks/store`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...(SECRET ? { "X-Hocuspocus-Secret": SECRET } : {}),
|
|
},
|
|
body: JSON.stringify(body),
|
|
})
|
|
if (!res.ok) {
|
|
const detail = await res.text().catch(() => "")
|
|
throw new Error(`store failed: ${res.status}${detail ? ` ${detail}` : ""}`)
|
|
}
|
|
}
|
|
|
|
const server = new Server({
|
|
port: PORT,
|
|
debounce: 3000,
|
|
maxDebounce: 10000,
|
|
|
|
async onAuthenticate(data) {
|
|
const { token, documentName } = data
|
|
if (!SECRET) return {}
|
|
const payload = decodeJwtPayload(token)
|
|
if (!payload?.room || payload.room !== documentName) {
|
|
console.error("[onAuthenticate] forbidden", { documentName, room: payload?.room })
|
|
throw new Error("forbidden")
|
|
}
|
|
if (payload.exp && payload.exp * 1000 < Date.now()) {
|
|
console.error("[onAuthenticate] token expired", { documentName })
|
|
throw new Error("token expired")
|
|
}
|
|
return {
|
|
room: payload.room,
|
|
path: payload.path,
|
|
user: payload.user,
|
|
sub: payload.sub,
|
|
name: payload.name,
|
|
mode: payload.mode,
|
|
}
|
|
},
|
|
|
|
async onLoadDocument(data) {
|
|
const ctx = hookContext(data)
|
|
return await loadFromUltid(ctx)
|
|
},
|
|
|
|
async onStoreDocument(data) {
|
|
const ctx = hookContext(data)
|
|
if (ctx.mode === "view") return
|
|
try {
|
|
await storeToUltid(ctx, data.document)
|
|
} catch (err) {
|
|
console.error("[onStoreDocument]", err)
|
|
}
|
|
},
|
|
})
|
|
|
|
server.listen()
|
|
console.log(`Hocuspocus listening on :${PORT}`)
|