Prodios Labs

$ ./projects/prodios-forge-document-editor

05Intermediate

Prodios Forge Document Editor

Notion-like collaborative document editor for creating, organizing, and sharing structured documents inside Prodios Forge

// docs

README

Prodios Forge Document Editor

The Prodios Forge Document Editor is a Notion-like collaborative document editor — think of it as your own Notion page editor, built inside the existing Prodios Forge workspace platform. Team members create structured documents in the workspace drive, write in a block-based editor with slash commands and rich content, and — the headline feature — edit the same document simultaneously, watching each other's cursors move in real time, with every keystroke merging conflict-free.

Under the hood, the editor is Tiptap (a headless React wrapper around ProseMirror), and collaboration is powered by Yjs — a CRDT (conflict-free replicated data type) library. The document's canonical state during an editing session is a shared Y.Doc: every client applies edits locally first (zero latency), Yjs encodes them as updates, and a Socket.IO relay broadcasts them to everyone else in the document's room. CRDT math guarantees all replicas converge to the same result regardless of message order — no locks, no "someone else is editing" banners, no merge conflicts. Prodios Forge already ships this exact architecture for its collaborative Excalidraw diagrams (y-socket.io over a shared custom server, with session-cookie auth on the Yjs namespace), so the project is about applying a proven in-repo pattern to a new, richer surface: text.

The goal of the hackathon project is to build a working slice of this editor inside Prodios Forge: a Tiptap block editor with Notion-style slash commands, wired to Yjs over the existing Socket.IO server, so that two people can type in the same document at once — with live remote cursors, presence avatars, authorized room access, and content persisted to Postgres.


What you are building

At a high level, a user of the document editor should be able to:

  1. Create a document — a document node in the workspace drive, opening into a full-page editor.
  2. Write in blocks — paragraphs, headings, bullet/ordered/task lists, quotes, code blocks, dividers — the Notion editing vocabulary, with Markdown-style input shortcuts (# → heading, - → list).
  3. Use slash commands — type / to open a filterable block-insertion menu (/heading, /todo, /code, /image), and drag blocks to reorder them.
  4. Collaborate live — open the same document in two browsers: keystrokes appear on both sides instantly, remote carets and selections render with each collaborator's name and color, and presence avatars show who's in the document.
  5. Trust the access rules — only workspace members with access to the document can join its collaboration room; read-only viewers watch live edits but cannot type.
  6. Never lose work — content is persisted server-side, so a document survives everyone closing their tabs and loads instantly for the next visitor.
  7. Enrich content — mention teammates with @, and embed images uploaded to object storage.

Architecture overview

The editor rides on Prodios Forge's existing infrastructure: a custom Next.js server that shares one HTTP port between Next and Socket.IO, with y-socket.io handling Yjs sync in dedicated per-document namespaces. Clients bind Tiptap to a shared Y.Doc via a SocketIOProvider; an auth middleware guards every room; and Postgres — not the Y.Doc — remains the durable source of truth.

Rendering diagram…

The pieces

1. The Tiptap editor (the surface) Tiptap is headless: it manages the ProseMirror document, schema, and commands; you own every pixel of UI. The editor composes extensions — StarterKit (paragraphs, headings, lists, code, history), TaskList/TaskItem, Placeholder, Mention — plus custom ones for the Notion feel: a slash-command extension (a ProseMirror suggestion plugin that opens a filterable menu and replaces /query with the chosen block) and a drag handle for reordering blocks. Prodios Forge already depends on Tiptap 3 with several of these extensions.

2. Yjs and the shared document (the brain) A Y.Doc holds the document as a Y.XmlFragment — a CRDT tree mirroring ProseMirror's node structure. Tiptap's Collaboration extension (built on y-prosemirror) keeps the two in sync in both directions: local ProseMirror transactions become Yjs updates; incoming Yjs updates become ProseMirror transactions. Two consequences to internalize early: the Y.Doc — not React state, not the database — is the live source of truth while editing, and undo must become Yjs-aware (the Collaboration extension replaces Tiptap's default history so you only undo your own changes, not a collaborator's).

3. The Socket.IO relay (y-socket.io) Prodios Forge runs a custom server (src/custom-server.ts) that serves Next.js and a Socket.IO server on the same port — with an upgrade-event guard so Socket.IO and Next's dev HMR websockets coexist. YSocketIO (from y-socket.io/dist/server) attaches to it and manages dynamic namespaces named /yjs|<roomName>: it keeps a server-side Y.Doc per active room, relays document updates and awareness messages between clients, and handles the initial sync protocol (a joining client exchanges state vectors and receives exactly the updates it's missing). The document editor reuses this attachment point with rooms named document:<documentId>, alongside the existing diagram:<id> rooms.

4. Namespace auth middleware (the gate) The Yjs transport is permissionless once a socket is inside a namespace — so the gate is a Socket.IO middleware on the /yjs|* namespace, exactly as the diagram collab does it: parse the room name, read the session cookie from the socket handshake and resolve it via better-auth, confirm the user is a member of the workspace passed in the handshake's auth payload, then check document-level access (owner, public visibility, or the nearest ancestor grant in the drive tree). Reject with an error and the provider never syncs. Read access admits you to observe the room; write authorization is enforced where persistence happens.

5. The client provider & awareness (presence) On the client, a SocketIOProvider connects the local Y.Doc to the room — with withCredentials: true so the session cookie rides along, the workspace ID in the handshake auth payload, and autoConnect: false so listeners are attached before connecting. The provider's awareness instance carries ephemeral, non-persisted state: each client sets { user: { id, name, color, avatarUrl } }, Tiptap's CollaborationCaret extension renders remote selections from it, and a presence bar maps awareness states to avatars. On teardown, the client removes its awareness state before destroying the provider so peers see the leave immediately instead of waiting for the ~30 s outdated-state timeout.

6. Persistence (Postgres is the source of truth) The Y.Doc lives in memory; documents must not. Following the in-repo pattern, durability comes from persisting content to Postgres (Drizzle) rather than trusting the relay's memory: clients debounce-save the document through a tRPC mutation that enforces write access server-side. Two viable formats — ProseMirror JSON (easy to render/search, needs the seed-once discipline below) or the encoded Yjs update binary (lossless CRDT history, becomes the true canonical form). Loading is the mirror image: fetch the persisted content, connect the provider, and seed only after the initial sync reports the shared doc is empty — if the room already has content, the server's Y.Doc wins and the snapshot is discarded. Skipping that check is the classic bug that duplicates the entire document on every join.

7. Uploads & mentions (the workspace integrations) Images drop into the editor, upload to the existing S3-backed storage, and land as image nodes whose src points at the stored object. Mentions use Tiptap's Mention extension with a suggestion popup backed by a workspace-member search — stored as nodes carrying the member ID, rendered as styled chips.


How an edit flows

Understanding this end-to-end path is the heart of the project:

  1. Open — a user opens /drive/<documentId>. The page loads the persisted content from Postgres via tRPC, mounts Tiptap with the Collaboration extension bound to a fresh Y.Doc, and the SocketIOProvider connects to namespace /yjs|document:<documentId>.
  2. Authorize — the namespace middleware resolves the session cookie to a user, checks workspace membership and document access, and admits the socket to the room.
  3. Sync — the y-socket.io initial sync runs: client and server exchange state vectors and the client receives the room's current state. If the shared doc turns out to be empty (first visitor since the server started), the client seeds it from the Postgres snapshot inside a single transaction tagged with a collab transaction origin; otherwise the synced state simply replaces the local view.
  4. Type — the user types a character. ProseMirror applies it locally (instant), y-prosemirror translates the transaction into a Yjs update, and the provider emits it to the room.
  5. Merge — every other client receives the update and applies it to its replica; CRDT semantics resolve concurrent edits at the same position deterministically, and remote changes enter each editor as mapped ProseMirror transactions — collaborators' cursors don't jump.
  6. Show presence — alongside document updates, awareness messages carry each user's cursor position and identity; CollaborationCaret renders a colored caret with a name label at every remote selection.
  7. Persist — after a debounce, the client saves the document through a tRPC mutation that re-checks write access and updates the row in Postgres. The DB now reflects the merged state.
  8. Leave — the last user closes the tab; awareness states are removed, the room's server doc is eventually dropped. The next visitor loads from Postgres and the cycle restarts.

Technologies to use

The editor lives inside Prodios Forge, so most of the stack is already chosen — the project is integration, not greenfield setup.

LayerOptions
App frameworkNext.js (App Router) inside the existing Prodios Forge codebase, custom server entry (src/custom-server.ts)
EditorTiptap 3 (@tiptap/react, @tiptap/starter-kit, TaskList/TaskItem, Placeholder, Mention — already installed)
Collaborationyjs + Tiptap's Collaboration & CollaborationCaret extensions (y-prosemirror under the hood)
Transportsocket.io / socket.io-client + y-socket.io (YSocketIO server, SocketIOProvider client — already installed)
PresenceYjs awareness (y-protocols/awareness) — user identity, color, cursor
Authbetter-auth session cookies, resolved in the Socket.IO namespace middleware
PersistencePostgreSQL via Drizzle, writes through tRPC mutations with server-side access checks
Slash menu / UIProseMirror suggestion plugin + cmdk or a floating menu (shadcn/ui, Radix — in repo); @dnd-kit for block drag
Image uploadsExisting S3-compatible storage layer (@aws-sdk/client-s3)
Markdowntiptap-markdown for paste/shortcut support (already installed)

Concepts to understand

Before writing code, make sure the team is comfortable with these ideas.

CRDTs and Yjs

  • Why CRDTs — the alternative histories: locking (Google Docs circa 2005), operational transforms (needs a central sequencing server), CRDTs (updates commute — any order, same result). Know the trade-off you're accepting: convergence is guaranteed, intent preservation is best-effort.
  • The Y.Doc and shared typesY.XmlFragment for rich text (what Tiptap binds to), Y.Map/Y.Array for structured data (what the diagram collab uses). One doc, many shared types.
  • Updates and state vectors — edits encode as compact binary updates; a state vector says "what I've seen," making initial sync a diff exchange rather than a full transfer.
  • Transaction origins — tagging transactions with an origin so your own observers can tell local edits from remote ones and from programmatic seeding (the in-repo diagram binding leans on this heavily).
  • Awareness is not the document — presence state is ephemeral, never persisted, and disappears with the client; document state is permanent. Different protocol, different lifecycle.

ProseMirror & Tiptap

  • Schema, nodes, and marks — a ProseMirror document is a typed tree; blocks are nodes, inline formatting is marks. Tiptap extensions contribute schema + commands + keybindings.
  • Transactions — every change is a transaction; y-prosemirror is a two-way transaction↔update translator.
  • Why history must be replaced — native undo would revert other people's work; the Collaboration extension swaps in Yjs's UndoManager, which tracks only your origin's changes.
  • Suggestion plugins — the machinery behind / and @ menus: trigger character, query extraction, positioned popup, command on select.

The transport & auth pattern (read the in-repo implementation)

  • One port, two protocols — how the custom server hands non-Socket.IO upgrades to Next (HMR) and Socket.IO traffic to the engine; why destroyUpgrade: false matters.
  • Dynamic namespaces — y-socket.io's /yjs|<room> naming convention, one namespace per document, created on demand.
  • Auth at the handshake — a Socket.IO middleware is the only gate; validate the cookie session, workspace membership, and document grants there, and log every rejection with a reason. Study src/server/workspace-diagram-collab-socket.ts — the document editor's gate is the same shape with a different room prefix and access rule.
  • Observe vs. write — the relay can't distinguish readers from writers, so read access gates the room while write access is enforced at the persistence mutation. Understand why that's acceptable (the DB is canonical) and what it doesn't protect (a hostile reader can pollute the live session — a stretch goal fixes this server-side).

Persistence strategy

  • Memory vs. durability — the relay's server doc is a cache, not a store; a restart empties it. Postgres holds the real document.
  • Seed-once-after-sync — the load sequence (fetch snapshot → connect → wait for sync → seed only if empty) and the duplicate-content bug that ignoring it causes.
  • Snapshot format trade-off — ProseMirror JSON (readable, queryable, works with existing rendering) vs. encoded Y.Doc updates (lossless, order-independent, enables server-side persistence later).
  • Debounce + last-write-wins is fine here — because every client converges to the same doc, concurrent snapshot saves write the same content.

Collaborative UX

  • Local-first responsiveness — the local edit never waits on the network; sync is asynchronous.
  • Cursor presence — mapping awareness states to rendered carets, generating stable per-user colors, and cleaning up state on leave before destroying the provider.
  • Offline & reconnect — Yjs buffers local edits while disconnected and syncs on reconnect; know what the provider does and where the gaps are.

Suggested milestones

A realistic path for a hackathon, from smallest useful slice to full loop:

  1. A great single-user editor — Tiptap page in the drive: StarterKit + task lists + placeholder, Markdown input shortcuts, content saved to Postgres via tRPC on debounce and loaded on open. No Yjs yet.
  2. Notion feel — the slash-command menu inserting every supported block type, plus a block drag handle for reordering.
  3. First collaboration — add the Collaboration extension + SocketIOProvider, attach a document:<id> room next to the existing diagram rooms on the custom server, and (temporarily) skip persistence: two browsers, one document, live typing.
  4. Auth the room — port the diagram collab's namespace middleware for document rooms: session cookie → workspace member → document grant, with rejected sockets never syncing.
  5. Presence — awareness user state, CollaborationCaret remote cursors with names and colors, an avatar stack of active collaborators, and clean leave behavior.
  6. Close the loop — reconcile collaboration with persistence: seed-after-initial-sync from the Postgres snapshot, debounced authorized saves of the merged state, and Yjs-aware undo. (This is the full product.) Then polish: image uploads to S3, @ mentions of workspace members.

Stretch goals

  • Server-side persistence of Y.Doc updates — persist encoded updates from the relay itself (y-socket.io callbacks or a small custom hook), making the CRDT state canonical and closing the "hostile reader pollutes the live session" gap with server-side write checks.
  • Comments & discussions — anchor comment threads to text ranges that survive concurrent edits (relative positions in Yjs).
  • Document version history — periodic snapshots with a diff viewer and restore.
  • Nested pages — Notion's defining primitive: a page block that links to a child document in the drive tree.
  • Read-only enforcement in the editor — viewers get editable: false plus a server that drops their document updates.
  • Live document previews — show a live-updating excerpt of each document on drive cards via a lightweight read-only Y.Doc subscription.
  • AI in the editor — a /ai slash command that streams a completion into the document as a collaborator (with its own awareness identity).
  • Export — Markdown and PDF export of any document (tiptap-markdown already gets you halfway).