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