ultisuite-backend/services/hocuspocus/server.mjs
R3D347HR4Y 20c4fef3c6
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
docxi import lol
2026-06-10 00:27:21 +02:00

168 lines
5.1 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 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}`)