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 { Node } = require("@tiptap/core") 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 /** Keep in sync with lib/drive/extensions/docs-graphic.ts graphicAttributes */ const graphicAttributes = { graphicType: { default: "image" }, src: { default: null }, alt: { default: "" }, shapeType: { default: "rect" }, fill: { default: "#4285f4" }, stroke: { default: "#1a73e8" }, strokeWidth: { default: 2 }, gradientCss: { default: "" }, gradientAngle: { default: 180 }, gradientColor1: { default: "#4285f4" }, gradientColor2: { default: "#34a853" }, width: { default: 240 }, height: { default: 160 }, placement: { default: "block" }, wrap: { default: "square" }, floatSide: { default: "left" }, x: { default: 0 }, y: { default: 0 }, positionMode: { default: "move-with-text" }, anchorPos: { default: -1 }, pageIndex: { default: 0 }, pageX: { default: 0 }, pageY: { default: 0 }, wrapMarginMm: { default: 3 }, rotationDeg: { default: 0 }, zIndex: { default: 0 }, cropX: { default: 0 }, cropY: { default: 0 }, cropWidth: { default: 1 }, cropHeight: { default: 1 }, cropShape: { default: "rect" }, lockAspectRatio: { default: true }, imageFit: { default: "contain" }, imageFitAnchorH: { default: 0.5 }, imageFitAnchorV: { default: 0.5 }, assetId: { default: null }, opacity: { default: 1 }, shadow: { default: "" }, brightness: { default: 0 }, contrast: { default: 0 }, recolor: { default: "" }, altTitle: { default: "" }, drawScene: { default: null }, } const DocsGraphic = Node.create({ name: "docsGraphic", group: "block", atom: true, addAttributes() { return graphicAttributes }, }) const DocsInlineGraphic = Node.create({ name: "docsInlineGraphic", group: "inline", inline: true, atom: true, addAttributes() { return graphicAttributes }, }) /** Match TipTap editor extensions so imported DOCX content survives Yjs seeding. */ const transformerExtensions = [ StarterKit.configure({ undoRedo: false, link: false, underline: 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 }), DocsGraphic, DocsInlineGraphic, ] 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) } function tipTapContentHasBody(content) { if (!content || typeof content !== "object") return false if (tipTapContentHasText(content)) return true const walk = (node) => { if (!node || typeof node !== "object") return false const type = node.type if (type && type !== "doc" && type !== "paragraph") return true if (Array.isArray(node.content)) { if (node.content.length === 0) return false if (node.content.length > 1) return true return walk(node.content[0]) } return false } return walk(content) } function yjsToExcalidrawElements(yArray) { if (!yArray || yArray.length === 0) return [] return yArray .toArray() .sort((a, b) => { const key1 = a.get("pos") const key2 = b.get("pos") return key1 > key2 ? 1 : key1 < key2 ? -1 : 0 }) .map((x) => x.get("el")) .filter((el) => el && typeof el.id === "string" && typeof el.type === "string") } function exportUltidrawScene(ydoc) { const yElements = ydoc.getArray("elements") const yAssets = ydoc.getMap("assets") const elements = yjsToExcalidrawElements(yElements) const files = {} yAssets.forEach((value, key) => { files[key] = value }) return { elements, appState: { gridSize: null, viewBackgroundColor: "#ffffff" }, files, } } function seedYdocFromJson(ydoc, elements, files, generateNKeysBetween) { const yElements = ydoc.getArray("elements") const yAssets = ydoc.getMap("assets") if (!Array.isArray(elements) || elements.length === 0 || yElements.length > 0) return const keys = generateNKeysBetween(null, null, elements.length) ydoc.transact(() => { for (let i = 0; i < elements.length; i++) { const el = elements[i] if (!el || typeof el.id !== "string") continue yElements.push([ new Y.Map(Object.entries({ pos: keys[i], el: { ...el } })), ]) } if (files && typeof files === "object") { for (const [id, asset] of Object.entries(files)) { yAssets.set(id, asset) } } }) } async function loadFromUltidraw(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/ultidraw/internal/document?${params}`, { headers: SECRET ? { "X-Hocuspocus-Secret": SECRET } : {}, }) if (res.status === 404) return null if (!res.ok) throw new Error(`ultidraw load failed: ${res.status}`) const raw = await res.text() if (!raw.trim()) return null try { const doc = JSON.parse(raw) const ydoc = new Y.Doc() if (doc.yjsState) { Y.applyUpdate(ydoc, Buffer.from(doc.yjsState, "base64")) } const { generateNKeysBetween } = await import("fractional-indexing") seedYdocFromJson(ydoc, doc.elements, doc.files, generateNKeysBetween) if (ydoc.getArray("elements").length === 0) { return null } return Buffer.from(Y.encodeStateAsUpdate(ydoc)) } catch (err) { console.error("[onLoadDocument] ultidraw parse", err) } return null } async function storeToUltidraw(context, document) { if (!context?.path || !context?.user) { throw new Error("ultidraw store missing path or user in context") } const state = Buffer.from(Y.encodeStateAsUpdate(document)).toString("base64") const scene = exportUltidrawScene(document) const body = { room: context.room ?? context.path, path: context.path, user: context.user, sub: context.sub ?? "", yjsState: state, document: scene, } const res = await fetch(`${ULTID_URL}/api/v1/ultidraw/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(`ultidraw store failed: ${res.status}${detail ? ` ${detail}` : ""}`) } } function isDrawRoom(name) { return typeof name === "string" && name.startsWith("draw:") } 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 hasStoredBody = tipTapContentHasBody(doc.content) if (doc.content && hasStoredBody) { const ydoc = TiptapTransformer.toYdoc(doc.content, "default", transformerExtensions) return Buffer.from(Y.encodeStateAsUpdate(ydoc)) } if (doc.yjsState && !hasStoredBody) { 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) if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) { return await loadFromUltidraw(ctx) } return await loadFromUltid(ctx) }, async onStoreDocument(data) { const ctx = hookContext(data) if (ctx.mode === "view") return try { if (isDrawRoom(data.documentName) || isDrawRoom(ctx.room)) { await storeToUltidraw(ctx, data.document) } else { await storeToUltid(ctx, data.document) } } catch (err) { console.error("[onStoreDocument]", err) } }, }) server.listen() console.log(`Hocuspocus listening on :${PORT}`)