ultisuite-backend/services/hocuspocus/server.mjs
R3D347HR4Y 3b387e7e4a
Some checks are pending
CI / Go tests (push) Waiting to run
CI / Integration tests (push) Waiting to run
CI / DB migrations (push) Waiting to run
hocuspocus lol
2026-06-09 14:29:58 +02:00

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}`)