diff --git a/backend/api/pages.ts b/backend/api/pages.ts index ea9b43e60..47ea6cb14 100644 --- a/backend/api/pages.ts +++ b/backend/api/pages.ts @@ -642,6 +642,16 @@ async function routes(app: FastifyInstance) { if (!page) { return reply.notFound('This page does not exist.') } + /* + Anyone else editing this page right now is looking at the text that was just stored, so their + editor should stop calling it unsaved. Told through the collaboration room rather than answered + here, since they are on their own requests — and, quite possibly, on another instance. + */ + WIKI.collab.pageSaved(page.id, { + versionDate: page.updatedAt.toTemporalInstant().toString({ smallestUnit: 'millisecond' }), + authorId: actor.id, + authorName: page.authorName ?? '' + }) return { ok: true, message: 'Page updated successfully.', diff --git a/backend/api/schemas/site.ts b/backend/api/schemas/site.ts index 4bb60b8d0..e0b47c0f6 100644 --- a/backend/api/schemas/site.ts +++ b/backend/api/schemas/site.ts @@ -68,6 +68,9 @@ export async function registerSchemas(app: FastifyInstance): Promise { browse: { type: 'boolean' }, + collaborativeEditing: { + type: 'boolean' + }, ratings: { type: 'boolean' }, diff --git a/backend/controllers/collab.ts b/backend/controllers/collab.ts new file mode 100644 index 000000000..4f4560f8b --- /dev/null +++ b/backend/controllers/collab.ts @@ -0,0 +1,70 @@ +import { validate as uuidValidate } from 'uuid' + +import { mayOnPage } from '../api/pages.ts' + +import type { FastifyInstance, FastifyRequest } from 'fastify' +import type { WebSocket } from 'ws' + +/** + * _collab Routes + * + * The websocket behind live collaborative editing. One socket per editor, one room per page — see + * `core/collab.ts` for what a room is and how rooms find each other across instances. + * + * Unlike its neighbours under `controllers/`, nothing here is public. A room carries a page's unsaved + * text and everyone's cursor, so joining one takes a session that may edit that page — the same + * `write:pages` the save itself takes, checked against the page rather than against the group's + * permission list. Whoever may only *suggest* edits does not qualify, which is what keeps a suggestion + * the private draft it is meant to be. + * + * The handshake is the only place authorization happens: a socket is checked once, when it opens, and + * a permission taken away mid-session takes effect the next time the editor is opened. + */ +async function routes(app: FastifyInstance) { + app.get<{ Params: { siteId: string; pageId: string } }>( + '/:siteId/:pageId', + { websocket: true, schema: { hide: true } }, + async ( + socket: WebSocket, + req: FastifyRequest<{ Params: { siteId: string; pageId: string } }> + ) => { + const { siteId, pageId } = req.params + + /* + Before the first `await`, and that is the point: the client starts talking as soon as the + socket is open, which is well before the checks below have finished asking the database + anything. See `capture` in `core/collab.ts`. + */ + const session = WIKI.collab.capture(socket) + + /* + Refusals close the socket rather than answering with anything: the client is y-websocket, which + speaks the sync protocol and nothing else, and it treats a close as the signal to back off. + The codes are in the private 4000 range, where the browser hands them to the page — which is + how the editor tells "you may not edit this" apart from "the connection dropped" and knows not + to reconnect. See `composables/collab.js`. + */ + if (!uuidValidate(siteId) || !uuidValidate(pageId)) { + return socket.close(4400, 'Invalid site or page id') + } + if (!req.session?.authenticated) { + return socket.close(4401, 'Authentication is required') + } + if (!WIKI.sites[siteId]?.config?.features?.collaborativeEditing) { + return socket.close(4403, 'Collaborative editing is disabled on this site') + } + + const page = await WIKI.models.pages.getPage({ siteId, id: pageId }) + if (!page) { + return socket.close(4404, 'This page does not exist') + } + if (!mayOnPage(req, 'write:pages', page)) { + return socket.close(4403, 'You are not allowed to edit this page') + } + + await WIKI.collab.join(socket, { id: pageId, siteId }, session) + } + ) +} + +export default routes diff --git a/backend/core/collab.ts b/backend/core/collab.ts new file mode 100644 index 000000000..6dede3392 --- /dev/null +++ b/backend/core/collab.ts @@ -0,0 +1,733 @@ +import { sql } from 'drizzle-orm' +import * as decoding from 'lib0/decoding' +import * as encoding from 'lib0/encoding' +import * as awarenessProtocol from 'y-protocols/awareness' +import * as syncProtocol from 'y-protocols/sync' +import * as Y from 'yjs' + +import type { PoolClient } from 'pg' +import type { WebSocket } from 'ws' + +/** + * Live collaborative editing. + * + * A room is one page being edited by more than one person at a time. It holds a Yjs document — the + * markdown source as a `Y.Text`, the header fields as a `Y.Map` — and the awareness state that carries + * everyone's cursor and identity. Clients speak the y-websocket protocol to it, which is why the + * message framing below is byte-compatible with `y-websocket`'s client rather than something of our + * own: the browser side is that library, unmodified. + * + * **A room is not storage.** Nothing here is ever written back to the page — saving is still an + * explicit act, `PATCH /pages/:id` as it always was, and a room that empties out takes any unsaved text + * with it exactly as closing the editor always has. What a room adds is that the text survives *one* + * participant leaving, because the others are still holding it. + * + * ## Across instances + * + * Rooms live in memory, so two people served by different instances would otherwise never meet. Their + * updates are relayed over postgres LISTEN/NOTIFY on a channel of this module's own, separate from the + * `wiki` channel that carries the general event bus: these are frequent, binary, and worthless a + * second after they are sent, and none of that describes an event bus message. + * + * NOTIFY caps a payload at 8000 bytes, so a relayed message is base64'd and split into chunks that fit + * — see {@link relay}. Chunks of one message arrive in order, postgres guaranteeing that much per + * connection. + * + * ## Where a room's starting state comes from + * + * This is the one genuinely delicate part. A Yjs document cannot simply be seeded twice: two instances + * that each insert the page's text into their own replica produce two *different* sets of operations + * that both say "insert this text", and merging those replicas concatenates them — the document ends + * up holding the page twice. So a room being created asks the cluster first ({@link peerState}) and + * only falls back to the stored page when nobody answers. + * + * Two instances cold-starting the same room in the same instant would still both fall back, so that + * seed is made *deterministic*: it is built in a scratch document pinned to client id 0, and two seeds + * of identical text therefore produce byte-identical operations, which merge as one. That is also what + * lets a client reconnect after a network blip and push back the edits it made while it was away — its + * local copy of the seed is the same seed a freshly created room builds. + */ + +/** y-websocket message types. The values are that protocol's, not ours. */ +const MESSAGE_SYNC = 0 +const MESSAGE_AWARENESS = 1 + +const NOTIFY_CHANNEL = 'wiki_collab' + +/** + * Base64 characters per NOTIFY payload. Postgres refuses a payload over 8000 bytes, and the JSON + * envelope around the chunk fits comfortably in the slack this leaves. + */ +const RELAY_CHUNK_SIZE = 5000 + +/** How long a half-assembled relay message waits for the rest of its chunks before being dropped. */ +const RELAY_REASSEMBLY_TIMEOUT = 10 * 1000 + +/** + * How long a new room waits for a peer to hand over the state it already has, before seeding itself + * from the stored page. Only paid when this instance does not already have the room open, and skipped + * entirely when no other instance is running — which is the ordinary case. + */ +const PEER_STATE_TIMEOUT = 500 + +/** How long the "is anyone else running?" answer is trusted before it is looked up again. */ +const PEER_PRESENCE_TTL = 15 * 1000 + +/** Keepalive interval. An idle websocket is what a reverse proxy cuts first. */ +const PING_INTERVAL = 30 * 1000 + +/** + * Marks a document or awareness change as having arrived over the relay, so that applying it here does + * not send it straight back out to the instance it came from. + */ +const RELAYED = Symbol('collabRelayed') + +interface CollabConn { + /** Awareness client ids this socket is responsible for, so a disconnect can retract exactly those. */ + clients: Set + /** Answered the last keepalive ping. */ + alive: boolean +} + +interface CollabSession { + /** The room this socket ended up in, or null while it is still being decided. */ + room: CollabRoom | null + /** Frames that arrived before there was a room to hand them to. */ + pending: Uint8Array[] +} + +interface CollabRoom { + pageId: string + siteId: string + doc: Y.Doc + awareness: awarenessProtocol.Awareness + conns: Map + /** Resolves once the document holds its starting state and clients may be synced against it. */ + ready: Promise + /** Whether this room is still filling itself, i.e. has nothing worth handing to a peer yet. */ + provisional: boolean +} + +interface SaveInfo { + versionDate: string + authorId: string + authorName: string +} + +interface RelayEnvelope { + /** Instance the message came from. */ + i: string + /** Room, i.e. page id. */ + r: string + t: 'update' | 'awareness' | 'hello' | 'state' | 'saved' + /** Payload: base64 for the binary kinds, JSON for `saved`, absent for `hello`. */ + p?: string + /** Instance this is addressed to, when it is a reply rather than a broadcast. */ + to?: string + /** Chunking: message id, chunk index, chunk count. Absent on a message that fits in one. */ + m?: string + c?: number + n?: number +} + +interface PartialRelay { + parts: (string | undefined)[] + remaining: number + timer: NodeJS.Timeout +} + +/** + * A websocket frame as bytes, whatever shape `ws` handed it over in. + * + * A fragmented message arrives as an array of buffers, and a whole one as a single `Buffer` — which is + * a view into a larger pool, so its offset and length matter. The result is a view over that same + * memory and is only safe to read during the event that delivered it; anything held on to has to be + * copied first. + */ +function toBytes(data: unknown): Uint8Array { + if (Array.isArray(data)) { + return new Uint8Array(Buffer.concat(data)) + } + if (Buffer.isBuffer(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength) + } + return new Uint8Array(data as ArrayBuffer) +} + +/** + * The state a room starts from when it has to build one itself, as a Yjs update. + * + * Built in a scratch document whose client id is pinned to 0, so that the bytes depend on nothing but + * the page — see the note at the top of this file on why that matters. + */ +function buildSeed(page: { + content?: string | null + title?: string | null + description?: string | null + icon?: string | null +}): Uint8Array { + const seed = new Y.Doc() + seed.clientID = 0 + seed.transact(() => { + seed.getText('content').insert(0, page.content ?? '') + const props = seed.getMap('props') + props.set('title', page.title ?? '') + props.set('description', page.description ?? '') + props.set('icon', page.icon ?? '') + }) + const update = Y.encodeStateAsUpdate(seed) + seed.destroy() + return update +} + +export default { + rooms: new Map(), + listenClient: null as PoolClient | null, + /** Chunked relay messages still waiting for the rest of themselves, keyed by sender and message id. */ + partials: new Map(), + /** Rooms this instance is waiting on a peer's state for, by page id. */ + awaitingState: new Map void>(), + relaySeq: 0, + peerPresence: { known: false, checkedAt: 0 }, + pingTimer: null as NodeJS.Timeout | null, + + /** + * Open the relay connection. + * + * A client of its own rather than the event bus's: these messages are far more frequent than events + * are, and a slow consumer on one channel should not hold up the other. + */ + async init(): Promise { + this.listenClient = await WIKI.dbManager.pool!.connect() + await this.listenClient.query(`SET application_name = 'Wiki.js - ${WIKI.INSTANCE_ID}:COLLAB'`) + this.listenClient.on('notification', (msg) => { + if (msg.channel !== NOTIFY_CHANNEL || !msg.payload) { + return + } + try { + this.receiveRelay(JSON.parse(msg.payload) as RelayEnvelope) + } catch (err: any) { + WIKI.logger.warn(`Malformed collaboration relay message: ${err.message}`) + } + }) + await this.listenClient.query(`LISTEN ${NOTIFY_CHANNEL}`) + + this.pingTimer = setInterval(() => { + for (const room of this.rooms.values()) { + for (const [conn, state] of room.conns) { + // -> A socket whose peer stopped answering is dropped by the `close` handler that + // `terminate()` triggers, which is also what takes its cursor off everyone's screen + if (!state.alive) { + conn.terminate() + continue + } + state.alive = false + try { + conn.ping() + } catch {} + } + } + }, PING_INTERVAL) + + WIKI.logger.info('Collaborative editing initialized successfully: [ OK ]') + }, + + async shutdown(): Promise { + if (this.pingTimer) { + clearInterval(this.pingTimer) + this.pingTimer = null + } + for (const partial of this.partials.values()) { + clearTimeout(partial.timer) + } + this.partials.clear() + for (const room of this.rooms.values()) { + for (const conn of room.conns.keys()) { + conn.close(1001, 'Server is shutting down') + } + room.awareness.destroy() + room.doc.destroy() + } + this.rooms.clear() + if (this.listenClient) { + this.listenClient.release(true) + this.listenClient = null + } + }, + + /** + * Whether another instance is currently running. + * + * Asked so that the single-instance case — very much the common one — does not spend + * {@link PEER_STATE_TIMEOUT} waiting for an answer that cannot come. Instances are not registered + * anywhere, so this reads what the admin area's instance list reads: our own connections name + * themselves in `pg_stat_activity`. + */ + async hasPeers(): Promise { + const now = Date.now() + if (now - this.peerPresence.checkedAt < PEER_PRESENCE_TTL) { + return this.peerPresence.known + } + const ownName = `Wiki.js - ${WIKI.INSTANCE_ID}:COLLAB` + try { + const result = await WIKI.db.execute( + sql`SELECT 1 FROM pg_stat_activity WHERE datname = current_database() + AND application_name LIKE 'Wiki.js - %:COLLAB' + AND application_name <> ${ownName} LIMIT 1` + ) + this.peerPresence = { known: result.rows.length > 0, checkedAt: now } + } catch (err: any) { + // -> Assume company: waiting 500ms is a far smaller mistake than duplicating a page's text + WIKI.logger.warn(`Could not determine whether other instances are running: ${err.message}`) + this.peerPresence = { known: true, checkedAt: now } + } + return this.peerPresence.known + }, + + /** + * Start listening to a socket before anything is known about it. + * + * Called the instant the socket opens, and synchronously — the client does not wait to be welcomed. + * y-websocket sends its first sync message immediately, while the route is still away asking the + * database whether this user may edit this page at all, and an event nobody is listening for is + * simply gone. That one message is the whole handshake: miss it and the client sits there holding an + * empty document, because it is never going to ask twice. + * + * So the frames are collected here and replayed by {@link join} once there is a room to put them to. + */ + capture(conn: WebSocket): CollabSession { + const session: CollabSession = { room: null, pending: [] } + conn.on('message', (data: unknown) => { + if (session.room) { + this.onMessage(session.room, conn, toBytes(data)) + } else { + // -> Copied, not referenced: `toBytes` hands back a view into a buffer `ws` owns, which is + // only good for the length of this event + session.pending.push(new Uint8Array(toBytes(data))) + } + }) + conn.on('close', () => { + if (session.room) { + this.onClose(session.room, conn) + } + }) + conn.on('error', (err: Error) => { + WIKI.logger.debug(`Collaboration socket error: ${err.message}`) + }) + return session + }, + + /** + * Put a socket into a page's room, syncing it against whatever state that room holds. + * + * The caller is responsible for having decided that this user may edit this page — see + * `controllers/collab.ts`. Nothing below re-checks it. + */ + async join( + conn: WebSocket, + page: { id: string; siteId: string }, + session: CollabSession + ): Promise { + /* + Asked for repeatedly, because a room can be dropped while this socket was waiting for it: another + socket that gave up during the same setup takes the still-empty room down with it. Joining that + one would put this editor in a room nothing else can find. + */ + let room = await this.ensureRoom(page) + for (let attempt = 0; this.rooms.get(page.id) !== room && attempt < 3; attempt++) { + room = await this.ensureRoom(page) + } + + // -> The socket may well have gone away while the room was being set up + if (conn.readyState !== conn.OPEN) { + this.closeRoomIfEmpty(room) + return + } + + const state: CollabConn = { clients: new Set(), alive: true } + room.conns.set(conn, state) + conn.on('pong', () => { + state.alive = true + }) + session.room = room + + // -> Sync step 1: what this room has, so the client can say what it is missing + const syncEncoder = encoding.createEncoder() + encoding.writeVarUint(syncEncoder, MESSAGE_SYNC) + syncProtocol.writeSyncStep1(syncEncoder, room.doc) + this.send(conn, encoding.toUint8Array(syncEncoder)) + + // -> And everyone already in the room, so their cursors are there from the first frame + const states = room.awareness.getStates() + if (states.size > 0) { + const awarenessEncoder = encoding.createEncoder() + encoding.writeVarUint(awarenessEncoder, MESSAGE_AWARENESS) + encoding.writeVarUint8Array( + awarenessEncoder, + awarenessProtocol.encodeAwarenessUpdate(room.awareness, [...states.keys()]) + ) + this.send(conn, encoding.toUint8Array(awarenessEncoder)) + } + + for (const message of session.pending) { + this.onMessage(room, conn, message) + } + session.pending = [] + }, + + /** + * The room for a page, creating and populating it if this instance does not have it open. + * + * Concurrent joiners share one room *and one initialization*: the room goes into the map before it + * has any state, and `ready` is what everything else waits on. + */ + async ensureRoom(page: { id: string; siteId: string }): Promise { + const existing = this.rooms.get(page.id) + if (existing) { + await existing.ready + return existing + } + + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) + // -> The server is not a participant. Left as it comes, its own empty state would show up in the + // room as a cursor nobody owns, and be relayed to every other instance as one. + awareness.setLocalState(null) + + const room: CollabRoom = { + pageId: page.id, + siteId: page.siteId, + doc, + awareness, + conns: new Map(), + ready: Promise.resolve(), + provisional: true + } + this.rooms.set(page.id, room) + + doc.on('update', (update: Uint8Array, origin: unknown) => { + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, MESSAGE_SYNC) + syncProtocol.writeUpdate(encoder, update) + const message = encoding.toUint8Array(encoder) + for (const conn of room.conns.keys()) { + this.send(conn, message) + } + if (origin !== RELAYED) { + this.relay({ r: room.pageId, t: 'update', p: Buffer.from(update).toString('base64') }) + } + }) + + awareness.on( + 'update', + ( + { added, updated, removed }: { added: number[]; updated: number[]; removed: number[] }, + origin: unknown + ) => { + const changed = [...added, ...updated, ...removed] + // -> Remember whose cursors these are, so that a disconnect can retract exactly them + const owner = room.conns.get(origin as WebSocket) + if (owner) { + for (const clientId of added) { + owner.clients.add(clientId) + } + for (const clientId of removed) { + owner.clients.delete(clientId) + } + } + const update = awarenessProtocol.encodeAwarenessUpdate(awareness, changed) + const encoder = encoding.createEncoder() + encoding.writeVarUint(encoder, MESSAGE_AWARENESS) + encoding.writeVarUint8Array(encoder, update) + const message = encoding.toUint8Array(encoder) + for (const conn of room.conns.keys()) { + this.send(conn, message) + } + if (origin !== RELAYED) { + this.relay({ + r: room.pageId, + t: 'awareness', + p: Buffer.from(update).toString('base64') + }) + } + } + ) + + room.ready = this.initRoom(room) + await room.ready + return room + }, + + /** + * Fill a newly created room with the state it should start from: a peer's copy if the cluster + * already has this page open, and the stored page if not. + */ + async initRoom(room: CollabRoom): Promise { + try { + const fromPeer = (await this.hasPeers()) ? await this.peerState(room.pageId) : null + if (fromPeer) { + Y.applyUpdate(room.doc, fromPeer, RELAYED) + } else { + const page = await WIKI.models.pages.getPage({ + siteId: room.siteId, + id: room.pageId, + withContent: true + }) + // -> A page that went away between the permission check and here leaves an empty room, which + // the first disconnect clears away again + Y.applyUpdate(room.doc, buildSeed(page ?? {}), RELAYED) + } + } catch (err: any) { + WIKI.logger.warn( + `Failed to initialize the collaboration room for page ${room.pageId}: ${err.message}` + ) + } finally { + room.provisional = false + this.awaitingState.delete(room.pageId) + } + }, + + /** Ask the cluster for a room's current state, resolving to null if nobody answers in time. */ + peerState(pageId: string): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + this.awaitingState.delete(pageId) + resolve(null) + }, PEER_STATE_TIMEOUT) + this.awaitingState.set(pageId, (update) => { + clearTimeout(timer) + this.awaitingState.delete(pageId) + resolve(update) + }) + this.relay({ r: pageId, t: 'hello' }) + }) + }, + + onMessage(room: CollabRoom, conn: WebSocket, message: Uint8Array): void { + try { + const decoder = decoding.createDecoder(message) + const encoder = encoding.createEncoder() + switch (decoding.readVarUint(decoder)) { + case MESSAGE_SYNC: { + encoding.writeVarUint(encoder, MESSAGE_SYNC) + // -> The socket is the origin, which is how the awareness bookkeeping above knows whose + // cursors an update carries + syncProtocol.readSyncMessage(decoder, encoder, room.doc, conn) + if (encoding.length(encoder) > 1) { + this.send(conn, encoding.toUint8Array(encoder)) + } + break + } + case MESSAGE_AWARENESS: { + awarenessProtocol.applyAwarenessUpdate( + room.awareness, + decoding.readVarUint8Array(decoder), + conn + ) + break + } + } + } catch (err: any) { + WIKI.logger.warn( + `Failed to handle a collaboration message on page ${room.pageId}: ${err.message}` + ) + } + }, + + onClose(room: CollabRoom, conn: WebSocket): void { + const state = room.conns.get(conn) + room.conns.delete(conn) + if (state && state.clients.size > 0) { + // -> Announced as an awareness change, which is what takes the avatar out of the header and the + // cursor out of the text for everyone else, here and on every other instance + awarenessProtocol.removeAwarenessStates(room.awareness, [...state.clients], null) + } + this.closeRoomIfEmpty(room) + }, + + /** + * Drop a room nobody on this instance is in. + * + * Immediately, with no grace period: an editor closed without saving has always lost its unsaved + * text, and a room outliving its last participant would quietly resurrect it on the next visit. + * Discarding an edit is that same act and needs nothing of its own — the socket closes and the state + * goes with it. + * + * Peers are not told. A room elsewhere is a replica in its own right whose participants are still + * editing; this instance simply asks for their state again next time someone here opens the page. + */ + closeRoomIfEmpty(room: CollabRoom): void { + if (room.conns.size > 0 || this.rooms.get(room.pageId) !== room) { + return + } + this.rooms.delete(room.pageId) + room.awareness.destroy() + room.doc.destroy() + }, + + /** + * Tell everyone editing a page that it has just been saved. + * + * Written into the document rather than sent as a message of its own, so that it reaches the other + * instances the way an edit does and a client joining a moment later sees the same thing. Nothing + * about the text changes — this only tells the other editors that what they are looking at is now + * what is stored, and their Save button can go quiet. + * + * The save does not necessarily land on an instance that has the room, so an instance without one + * passes the news along instead. + */ + pageSaved(pageId: string, info: SaveInfo): void { + const room = this.rooms.get(pageId) + if (room) { + room.doc.getMap('meta').set('lastSave', info) + } else { + this.relay({ r: pageId, t: 'saved', p: JSON.stringify(info) }) + } + }, + + // ---------------------------------------- + // Relay + // ---------------------------------------- + + /** Publish a message to the other instances, split into chunks postgres will accept. */ + relay(message: Omit): void { + if (!this.listenClient) { + return + } + const envelope: RelayEnvelope = { ...message, i: WIKI.INSTANCE_ID } + const payload = envelope.p + if (!payload || payload.length <= RELAY_CHUNK_SIZE) { + this.publish(envelope) + return + } + const count = Math.ceil(payload.length / RELAY_CHUNK_SIZE) + const messageId = `${this.relaySeq++}` + for (let index = 0; index < count; index++) { + this.publish({ + ...envelope, + p: payload.slice(index * RELAY_CHUNK_SIZE, (index + 1) * RELAY_CHUNK_SIZE), + m: messageId, + c: index, + n: count + }) + } + }, + + publish(envelope: RelayEnvelope): void { + this.listenClient + ?.query('SELECT pg_notify($1, $2)', [NOTIFY_CHANNEL, JSON.stringify(envelope)]) + .catch((err: any) => { + WIKI.logger.warn(`Failed to relay a collaboration message: ${err.message}`) + }) + }, + + receiveRelay(envelope: RelayEnvelope): void { + if (envelope.i === WIKI.INSTANCE_ID) { + return + } + if (envelope.to && envelope.to !== WIKI.INSTANCE_ID) { + return + } + if (envelope.m !== undefined && envelope.n !== undefined) { + const assembled = this.reassemble(envelope) + if (assembled === null) { + return + } + envelope.p = assembled + } + switch (envelope.t) { + case 'hello': { + // -> Somewhere else is opening this page and has nothing yet. Only a room that is past its own + // setup is worth answering with; one still filling itself would hand over an empty document. + const room = this.rooms.get(envelope.r) + if (!room || room.provisional) { + return + } + this.relay({ + r: envelope.r, + t: 'state', + to: envelope.i, + p: Buffer.from(Y.encodeStateAsUpdate(room.doc)).toString('base64') + }) + break + } + case 'state': { + const waiting = this.awaitingState.get(envelope.r) + if (!waiting) { + // -> Too late to be adopted, and merging it now is exactly the duplication this handshake + // exists to avoid. See the note at the top of this file. + WIKI.logger.debug( + `Ignoring a late collaboration state for page ${envelope.r} from instance ${envelope.i}` + ) + return + } + waiting(Buffer.from(envelope.p ?? '', 'base64')) + break + } + case 'update': { + const room = this.rooms.get(envelope.r) + if (room) { + Y.applyUpdate(room.doc, Buffer.from(envelope.p ?? '', 'base64'), RELAYED) + } + break + } + case 'awareness': { + const room = this.rooms.get(envelope.r) + if (room) { + awarenessProtocol.applyAwarenessUpdate( + room.awareness, + Buffer.from(envelope.p ?? '', 'base64'), + RELAYED + ) + } + break + } + case 'saved': { + const room = this.rooms.get(envelope.r) + if (room && envelope.p) { + room.doc.getMap('meta').set('lastSave', JSON.parse(envelope.p) as SaveInfo) + } + break + } + } + }, + + /** Collect a chunked message, returning the whole payload once the last chunk lands. */ + reassemble(envelope: RelayEnvelope): string | null { + const key = `${envelope.i}:${envelope.m}` + let partial = this.partials.get(key) + if (!partial) { + partial = { + parts: Array.from({ length: envelope.n! }), + remaining: envelope.n!, + timer: setTimeout(() => { + // -> An instance that died mid-message would otherwise leave its chunks here for good + this.partials.delete(key) + }, RELAY_REASSEMBLY_TIMEOUT) + } + this.partials.set(key, partial) + } + if (partial.parts[envelope.c!] !== undefined) { + return null + } + partial.parts[envelope.c!] = envelope.p ?? '' + partial.remaining-- + if (partial.remaining > 0) { + return null + } + clearTimeout(partial.timer) + this.partials.delete(key) + return partial.parts.join('') + }, + + send(conn: WebSocket, message: Uint8Array): void { + if (conn.readyState !== conn.OPEN) { + return + } + try { + conn.send(message) + } catch { + conn.close() + } + } +} diff --git a/backend/index.ts b/backend/index.ts index b89886d0b..a99f601ce 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -23,12 +23,14 @@ import fastifyStatic from '@fastify/static' import fastifySwagger from '@fastify/swagger' import fastifySwaggerUi from '@fastify/swagger-ui' import fastifyView from '@fastify/view' +import fastifyWebsocket from '@fastify/websocket' import gracefulServer from '@gquittet/graceful-server' import ajvFormats from 'ajv-formats' import pug from 'pug' import Emittery from 'emittery' import NodeCache from 'node-cache' +import collab from './core/collab.ts' import configSvc from './core/config.ts' import dbManager from './core/db.ts' import logger from './core/logger.ts' @@ -58,6 +60,7 @@ const WIKI = { groups: {}, strategies: {} }, + collab, configSvc, sites: {}, sitesMappings: {}, @@ -157,6 +160,10 @@ async function postBoot() { await WIKI.models.icons.ensureCacheDir() await WIKI.dbManager.subscribeToNotifications() + // -> Its own postgres listener, on its own channel: collaboration traffic is far heavier than the + // event bus's and has nothing to do with it. Must follow the sites cache, which the websocket + // handshake reads the per-site feature toggle from. + await WIKI.collab.init() await WIKI.scheduler.start() } @@ -219,6 +226,15 @@ async function initHTTPServer() { app.register(fastifySensible) app.register(fastifyCompress, { global: true }) + /* + Websocket upgrades, for live collaborative editing (`controllers/collab.ts`). Registered on the + root instance because the upgrade handler is installed on the HTTP server itself, and before the + routes below because a route declaring `websocket: true` needs it already there. + + `maxPayload` bounds a single frame: these carry keystrokes and cursor positions, and the largest + legitimate one is a client handing over a document it edited while offline. + */ + app.register(fastifyWebsocket, { options: { maxPayload: 5242880 } }) // ---------------------------------------- // Handle graceful server shutdown @@ -227,6 +243,9 @@ async function initHTTPServer() { WIKI.server.on(gracefulServer.SHUTTING_DOWN, () => { WIKI.logger.info('Shutting down HTTP Server... [ STOPPING ]') WIKI.dbManager.unsubscribeFromNotifications() + // -> Closes every editing socket with a going-away code, so the editors reconnect to whichever + // instance takes over rather than sitting on a dead connection + WIKI.collab.shutdown() }) WIKI.server.on(gracefulServer.SHUTDOWN, (err: Error) => { @@ -591,6 +610,7 @@ async function initHTTPServer() { // }) app.register(import('./api/index.ts'), { prefix: '/_api' }) + app.register(import('./controllers/collab.ts'), { prefix: '/_collab' }) app.register(import('./controllers/site.ts'), { prefix: '/_site' }) app.register(import('./controllers/icons.ts'), { prefix: '/_icons' }) app.register(import('./controllers/render.ts'), { prefix: '/_render' }) diff --git a/backend/locales/en.json b/backend/locales/en.json index 3cf2c3177..76924922d 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -280,6 +280,8 @@ "admin.flags.warn.label": "Do NOT enable these flags unless you know what you're doing!", "admin.general.allowBrowse": "Allow Browsing", "admin.general.allowBrowseHint": "Can users browse using the tree structure of the site to pages they have access to?", + "admin.general.allowCollaborativeEditing": "Allow Collaborative Editing", + "admin.general.allowCollaborativeEditingHint": "Can several people edit the same page at the same time, seeing each other's cursors and changes live? Applies to the markdown editor. Changes are still only stored when someone saves the page.", "admin.general.allowComments": "Allow Comments", "admin.general.allowCommentsHint": "Can users leave comments on pages? Can be restricted using Page Rules.", "admin.general.allowProfile": "Allow Profile Editing", @@ -1647,6 +1649,12 @@ "editor.ckeditor.stats": "{chars} chars, {words} words", "editor.codeBlock.filter": "Filter languages...", "editor.codeBlock.noResults": "No language matches that.", + "editor.collab.disconnected": "Live collaboration is unavailable. Your changes are kept locally and can still be saved.", + "editor.collab.editingWithYou": "{name} is editing this page with you.", + "editor.collab.notAllowed": "You are no longer allowed to edit this page collaboratively.", + "editor.collab.participants": "People editing this page", + "editor.collab.savedBy": "{name} saved this page.", + "editor.collab.you": "You", "editor.conflict.editable": "(editable)", "editor.conflict.infoGeneric": "A more recent version of this page was saved by {authorName}, {date}", "editor.conflict.leftPanelInfo": "Your current edit, based on page version from {date}", diff --git a/backend/models/sites.ts b/backend/models/sites.ts index afa2fb403..3a65ed042 100644 --- a/backend/models/sites.ts +++ b/backend/models/sites.ts @@ -83,6 +83,7 @@ class Sites { }, features: { browse: true, + collaborativeEditing: true, ratings: false, ratingsMode: 'off', comments: false, @@ -275,6 +276,7 @@ class Sites { }, features: { browse: true, + collaborativeEditing: true, ratings: false, ratingsMode: 'off', comments: false, diff --git a/backend/package-lock.json b/backend/package-lock.json index 4f4941ad4..ede6e8b8c 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -21,6 +21,7 @@ "@fastify/swagger": "9.7.0", "@fastify/swagger-ui": "6.0.0", "@fastify/view": "12.0.0", + "@fastify/websocket": "11.3.0", "@gquittet/graceful-server": "6.0.10", "@iconify/utils": "3.1.4", "@simplewebauthn/server": "13.3.2", @@ -38,6 +39,7 @@ "filesize": "11.0.17", "fs-extra": "11.3.5", "js-yaml": "4.2.0", + "lib0": "0.2.117", "mime": "4.1.0", "nanoid": "5.1.11", "node-cache": "5.1.2", @@ -48,7 +50,9 @@ "qrcode": "1.5.4", "sanitize-html": "2.17.6", "semver": "7.8.4", - "uuid": "14.0.0" + "uuid": "14.0.0", + "y-protocols": "1.0.7", + "yjs": "13.6.31" }, "devDependencies": { "@types/fs-extra": "11.0.4", @@ -60,6 +64,7 @@ "@types/qrcode": "1.5.6", "@types/sanitize-html": "2.16.1", "@types/semver": "7.7.1", + "@types/ws": "8.18.1", "drizzle-kit": "1.0.0-beta.15-859cf75", "nodemon": "3.1.14", "npm-check-updates": "22.2.3", @@ -1331,6 +1336,69 @@ "toad-cache": "^3.7.0" } }, + "node_modules/@fastify/websocket": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/@fastify/websocket/-/websocket-11.3.0.tgz", + "integrity": "sha512-g89ag4BCcD9YP5wBZXixzoLnuf5j89p/sXFcfpCiv2pdEkYYukBEoK3heVzqsp0EAtszVDc2BBZG0KZqeAShIA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.3", + "fastify-plugin": "^6.0.0", + "ws": "^8.16.0" + } + }, + "node_modules/@fastify/websocket/node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/@fastify/websocket/node_modules/fastify-plugin": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-6.0.0.tgz", + "integrity": "sha512-fZOty7z3O7vOliF6d8bHE3wiEh1KcNnKEQensSgTk9C1DvN6nRLS++XVd86v33Hw/8u9Un8A1zDrQ8ujcQDHEg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, + "node_modules/@fastify/websocket/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/@gquittet/graceful-server": { "version": "6.0.10", "resolved": "https://registry.npmjs.org/@gquittet/graceful-server/-/graceful-server-6.0.10.tgz", @@ -2908,6 +2976,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript/typescript-aix-ppc64": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", @@ -5210,6 +5288,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -5375,6 +5463,27 @@ "dayjs": "^1.11.7" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/light-my-request": { "version": "6.6.0", "resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz", @@ -7434,6 +7543,27 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/ws": { + "version": "8.21.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz", + "integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/wsl-utils": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", @@ -7459,6 +7589,26 @@ "node": ">=0.4" } }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/y18n": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", @@ -7514,6 +7664,23 @@ "engines": { "node": ">=6" } + }, + "node_modules/yjs": { + "version": "13.6.31", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", + "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } } } } diff --git a/backend/package.json b/backend/package.json index 6ea343d8c..b436dddb5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -47,6 +47,7 @@ "@fastify/swagger": "9.7.0", "@fastify/swagger-ui": "6.0.0", "@fastify/view": "12.0.0", + "@fastify/websocket": "11.3.0", "@gquittet/graceful-server": "6.0.10", "@iconify/utils": "3.1.4", "@simplewebauthn/server": "13.3.2", @@ -64,6 +65,7 @@ "filesize": "11.0.17", "fs-extra": "11.3.5", "js-yaml": "4.2.0", + "lib0": "0.2.117", "mime": "4.1.0", "nanoid": "5.1.11", "node-cache": "5.1.2", @@ -74,7 +76,9 @@ "qrcode": "1.5.4", "sanitize-html": "2.17.6", "semver": "7.8.4", - "uuid": "14.0.0" + "uuid": "14.0.0", + "y-protocols": "1.0.7", + "yjs": "13.6.31" }, "optionalDependencies": { "sharp": "0.35.3" @@ -89,6 +93,7 @@ "@types/qrcode": "1.5.6", "@types/sanitize-html": "2.16.1", "@types/semver": "7.7.1", + "@types/ws": "8.18.1", "drizzle-kit": "1.0.0-beta.15-859cf75", "nodemon": "3.1.14", "npm-check-updates": "22.2.3", diff --git a/backend/types/global.d.ts b/backend/types/global.d.ts index d72b9d5ec..4cb20d7b8 100644 --- a/backend/types/global.d.ts +++ b/backend/types/global.d.ts @@ -49,6 +49,7 @@ declare global { /** Contents of `base.yml` — set by configSvc.init(), not by index.ts */ data: any + collab: typeof import('../core/collab.ts').default configSvc: typeof import('../core/config.ts').default db: import('../core/db.ts').WikiDb dbManager: typeof import('../core/db.ts').default diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 198effa1e..d808fd4fc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -52,6 +52,10 @@ "vue-router": "5.2.0", "vue3-otp-input": "0.5.40", "vuedraggable": "4.1.0", + "y-monaco": "0.1.6", + "y-protocols": "1.0.7", + "y-websocket": "3.0.0", + "yjs": "13.6.31", "zxcvbn": "4.4.2" }, "devDependencies": { @@ -3222,6 +3226,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -3310,6 +3324,27 @@ "url": "https://github.com/sindresorhus/ky?sponsor=1" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -5382,6 +5417,64 @@ "node": ">=0.4.0" } }, + "node_modules/y-monaco": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/y-monaco/-/y-monaco-0.1.6.tgz", + "integrity": "sha512-sYRywMmcylt+Nupl+11AvizD2am06ST8lkVbUXuaEmrtV6Tf+TD4rsEm6u9YGGowYue+Vfg1IJ97SUP2J+PVXg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.43" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + }, + "peerDependencies": { + "monaco-editor": ">=0.20.0", + "yjs": "^13.3.1" + } + }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, + "node_modules/y-websocket": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/y-websocket/-/y-websocket-3.0.0.tgz", + "integrity": "sha512-mUHy7AzkOZ834T/7piqtlA8Yk6AchqKqcrCXjKW8J1w2lPtRDjz8W5/CvXz9higKAHgKRKqpI3T33YkRFLkPtg==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.102", + "y-protocols": "^1.0.5" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.5.6" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -5404,6 +5497,23 @@ "url": "https://github.com/sponsors/eemeli" } }, + "node_modules/yjs": { + "version": "13.6.31", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.31.tgz", + "integrity": "sha512-Eq+5BRfbeGyqGVrTJL3bEcr8gKkxPuyuoHmAwpk52fDb8kOVMrfVSTRPd6yiGgX5Fskb96qCRjzjbRjrL4YEnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/zxcvbn": { "version": "4.4.2", "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", diff --git a/frontend/package.json b/frontend/package.json index 3b530a51b..54dd3918c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -61,6 +61,10 @@ "vue-router": "5.2.0", "vue3-otp-input": "0.5.40", "vuedraggable": "4.1.0", + "y-monaco": "0.1.6", + "y-protocols": "1.0.7", + "y-websocket": "3.0.0", + "yjs": "13.6.31", "zxcvbn": "4.4.2" }, "devDependencies": { diff --git a/frontend/src/components/CollabPresence.vue b/frontend/src/components/CollabPresence.vue new file mode 100644 index 000000000..afb0de888 --- /dev/null +++ b/frontend/src/components/CollabPresence.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/frontend/src/components/EditorMarkdown.vue b/frontend/src/components/EditorMarkdown.vue index f748c4429..77248da07 100644 --- a/frontend/src/components/EditorMarkdown.vue +++ b/frontend/src/components/EditorMarkdown.vue @@ -284,9 +284,19 @@