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 Underline = require("@tiptap/extension-underline").default const Link = require("@tiptap/extension-link").default const { TextStyle } = require("@tiptap/extension-text-style") const Highlight = require("@tiptap/extension-highlight").default const TextAlign = require("@tiptap/extension-text-align").default const { Table, TableRow, TableCell, TableHeader } = require("@tiptap/extension-table") const Image = require("@tiptap/extension-image").default /** Match TipTap editor extensions so imported DOCX content survives Yjs seeding. */ const transformerExtensions = [ StarterKit.configure({ undoRedo: false }), Underline, Link.configure({ openOnClick: false }), TextStyle, Highlight.configure({ multicolor: true }), TextAlign.configure({ types: ["heading", "paragraph"] }), Table.configure({ resizable: true }), TableRow, TableCell, TableHeader, Image.configure({ inline: true, allowBase64: true }), ] 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 ?? {} } function tipTapContentHasText(content) { if (!content || typeof content !== "object") return false const walk = (node) => { if (!node || typeof node !== "object") return false if (typeof node.text === "string" && node.text.trim()) return true if (Array.isArray(node.content)) { return node.content.some(walk) } return false } return walk(content) } 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) const hasStoredText = tipTapContentHasText(doc.content) if (doc.content && hasStoredText) { const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions) return Buffer.from(Y.encodeStateAsUpdate(ydoc)) } if (doc.yjsState) { return Buffer.from(doc.yjsState, "base64") } } 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", transformerExtensions) } 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}`)