@repo/realtime
Yjs collaboration and presence on Durable Objects.
@repo/realtime provides realtime collaboration and presence. One room per
organizationId:page speaks the Yjs sync and awareness protocols over a
WebSocket: the shared Y.Doc carries page state (task lists, the notes
document) and awareness carries who is on the page. Documents persist to the
database, connections are authenticated and scoped to the caller's active
organization by the existing Worker wiring, and local dev simulates the
Durable Object. On the page you work with the React client; on the server the
same documents are readable without a connection at all.
Providing a room
Mount PageRoomProvider once per page so presence and page features share a
single WebSocket. The app layout in apps/web keys it on the pathname:
// apps/web/src/routes/_app.tsx
import { PageRoomProvider } from '@repo/realtime/client';
<PageRoomProvider
page={pathname}
user={{ id: user.id, image: user.image ?? null, name: user.name }}
>
{/* page content, presence avatars, ... */}
</PageRoomProvider>;The provider is preconfigured with the right connection URL, so consumers never build URLs themselves.
Consuming a room
usePageRoomContext() returns { room, users }. room is null until the
connection is established on the client, and it exposes doc (the shared
Y.Doc), provider, and page. Always check room.page before binding
anything to the doc: during navigation the shared provider swaps rooms while
the outgoing page is still mounted.
// apps/web/src/routes/_app/notes.tsx
import { usePageRoomContext } from '@repo/realtime/client';
function NotesPage() {
const { room } = usePageRoomContext();
// Treat a room for a different page as "not connected yet".
const notesRoom = room !== null && room.page === Route.fullPath ? room : null;
// Bind notesRoom.doc to Tiptap Collaboration, a shared task list, etc.
}Presence is just the users array:
const { users } = usePageRoomContext();
// Render an avatar per PresenceUser currently on this page.PresenceUser (id, name, image) and the isPresenceUser guard for
validating awareness states from other clients come from
@repo/realtime/types.
Outside the provider
usePageRoom (also in @repo/realtime/client) is the underlying hook if you
need a room connection outside the shared provider, for example a second room
on the same page. Prefer the provider for normal pages so everything shares
one WebSocket.
Reading a document on the server
A room mirrors its document to the document table a few seconds after each
change, which is what lets code with no session and no socket read it:
import { loadDocument } from '@repo/realtime/document';
const saved = await loadDocument({ organizationId, page: '/app/notes' });
if (saved !== null) {
const text = notesText(getNotesFragment(saved.doc));
}The rehydrated doc is the same document the browser holds, so the accessors
a page already uses work unchanged; roots arrive untyped, so name them as the
client does (doc.getMap, doc.getXmlFragment). Keep those accessors in a
module that imports nothing but yjs and both sides can share them, as
apps/web/src/notes/doc.ts does.
updatedAt comes back with the document because this is a last-saved view:
it trails an in-progress editing session by up to one flush interval. The
notes page reports it directly ("Saved to the database 2m ago"), and
GET /api/v1/org/notes serves the same row to API keys.
Rooms that carry no shared state never write a row, so using a page's room for presence alone costs nothing. When a room's Durable Object storage is empty it reloads from the database, which is what makes the row the copy of record rather than a cache.
No secrets are required. Authentication reuses the app session.