(y-provider) collaboration polling

We can now interact with the collaboration server
using http requests.
It will be used as a fallback when the websocket
is not working.
2 kind of requests:
 - to send messages to the server we use POST requests
 - to get messages from the server we use a GET
 request using SSE (Server Sent Events)
This commit is contained in:
Anthony LC
2025-02-20 11:20:49 +01:00
parent 6d0ccb15ea
commit 971b7e099d
12 changed files with 777 additions and 8 deletions
@@ -0,0 +1,185 @@
/* eslint-disable @typescript-eslint/no-require-imports */
import { EventEmitter } from 'events';
import { MessageType } from '@hocuspocus/server';
import { Response } from 'express';
import * as Y from 'yjs';
import { PollSync, PollSyncRequest } from '../src/libs/PollSync';
const { base64ToYDoc } = require('@/utils');
const { logger } = require('@/utils');
const mockEncodeStateAsUpdate = jest.fn();
jest.mock('yjs', () => ({
...jest.requireActual('yjs'),
encodeStateAsUpdate: () => mockEncodeStateAsUpdate(),
}));
jest.mock('@/utils', () => ({
base64ToYDoc: jest.fn((_b64: string) => new Y.Doc()),
toBase64: jest.fn((data: Uint8Array) => Buffer.from(data).toString('base64')),
logger: jest.fn(),
}));
jest.mock('@hocuspocus/server', () => {
const originalModule = jest.requireActual('@hocuspocus/server');
return {
__esModule: true,
...originalModule,
IncomingMessage: jest.fn().mockImplementation((buf: Buffer) => ({
buffer: buf,
readVarString: jest.fn(() => 'testRoom'),
readVarUint: jest.fn(() => MessageType.Sync),
writeVarUint: jest.fn(),
decoder: {},
encoder: {},
})),
};
});
describe('PollSync', () => {
let req: PollSyncRequest<any>;
let res: Response;
let dummyDoc: any;
let pollSync: PollSync<any>;
let mockHocuspocusServer: any;
beforeEach(() => {
req = Object.assign(new EventEmitter(), {
on: jest.fn((event, cb) => {
// For 'close' event, store the callback for manual trigger in tests.
if (event === 'close') {
req.destroy = cb;
}
}),
}) as unknown as PollSyncRequest<any>;
res = {
write: jest.fn(),
end: jest.fn(),
} as unknown as Response;
// Create a dummy document with required methods/properties
dummyDoc = {
name: 'testRoom',
merge: jest.fn((_other: Y.Doc) => {
// Simulate merging by returning a new Y.Doc instance
return new Y.Doc();
}),
getConnections: jest.fn(() => [{ handleMessage: jest.fn() }]),
awareness: {
on: jest.fn(),
off: jest.fn(),
},
addDirectConnection: jest.fn(),
removeDirectConnection: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
pollSync = new PollSync(req, 'testRoom', true);
// Pre-set the document for non-init tests
(pollSync as any)._hpDocument = dummyDoc;
// Create a dummy Hocuspocus server
mockHocuspocusServer = {
loadingDocuments: { get: jest.fn() },
documents: new Map<string, any>(),
createDocument: jest.fn(() => ({
name: 'newDoc',
merge: (doc: Y.Doc) => doc,
})),
};
});
afterEach(() => {
jest.clearAllMocks();
});
describe('initHocuspocusDocument', () => {
it('should return document from loadingDocuments when available', async () => {
mockHocuspocusServer.loadingDocuments.get.mockResolvedValue(dummyDoc);
pollSync = new PollSync(req, 'testRoom', true);
const doc = await pollSync.initHocuspocusDocument(mockHocuspocusServer);
expect(doc).toBe(dummyDoc);
expect(mockHocuspocusServer.loadingDocuments.get).toHaveBeenCalledWith(
'testRoom',
);
});
it('should return document from documents when available', async () => {
mockHocuspocusServer.loadingDocuments.get.mockResolvedValue(undefined);
mockHocuspocusServer.documents.set('testRoom', dummyDoc);
pollSync = new PollSync(req, 'testRoom', false);
const doc = await pollSync.initHocuspocusDocument(mockHocuspocusServer);
expect(doc).toBe(dummyDoc);
});
it('should create a new document when none exists and canEdit is true', async () => {
mockHocuspocusServer.loadingDocuments.get.mockResolvedValue(undefined);
pollSync = new PollSync(req, 'testRoom', true);
const newDoc = { name: 'newDoc', merge: (doc: any) => doc };
mockHocuspocusServer.createDocument.mockResolvedValue(newDoc);
const doc = await pollSync.initHocuspocusDocument(mockHocuspocusServer);
expect(doc).toBe(newDoc);
expect(mockHocuspocusServer.createDocument).toHaveBeenCalled();
});
});
describe('sync', () => {
it('should encode state without merging when canEdit is false', () => {
// When user cannot edit, merge should not be called.
pollSync = new PollSync(req, 'testRoom', false);
(pollSync as any)._hpDocument = dummyDoc;
mockEncodeStateAsUpdate.mockReturnValue(Uint8Array.from([1, 2, 3]));
const result = pollSync.sync('dummyLocalDoc64');
expect(dummyDoc.merge).not.toHaveBeenCalled();
expect(result).toBe(
Buffer.from(Uint8Array.from([1, 2, 3])).toString('base64'),
);
});
it('should merge local doc when canEdit is true', () => {
pollSync = new PollSync(req, 'testRoom', true);
(pollSync as any)._hpDocument = dummyDoc;
const localDoc = new Y.Doc();
// Mock base64ToYDoc to return our localDoc
base64ToYDoc.mockReturnValue(localDoc);
mockEncodeStateAsUpdate.mockReturnValue(Uint8Array.from([4, 5, 6]));
pollSync.sync('localDoc64');
expect(dummyDoc.merge).toHaveBeenCalledWith(localDoc);
});
});
describe('sendClientsMessages', () => {
it('should log an error if room names do not match', () => {
// Set doc name different from what IncomingMessage returns ('testRoom')
dummyDoc.name = 'differentRoom';
(pollSync as any)._hpDocument = dummyDoc;
const fakeMessage = Buffer.from('fakeMessage').toString('base64');
pollSync.sendClientsMessages(fakeMessage);
expect(logger).toHaveBeenCalled();
});
});
describe('pullClientsMessages', () => {
it('should register event listeners and cleanup on request close', () => {
(pollSync as any)._hpDocument = dummyDoc;
pollSync.pullClientsMessages(res);
expect(dummyDoc.addDirectConnection).toHaveBeenCalled();
expect(dummyDoc.on).toHaveBeenCalled();
expect(dummyDoc.awareness.on).toHaveBeenCalled();
// Simulate a 'close' event on the request
if (req.destroy) {
req.destroy();
}
expect(dummyDoc.removeDirectConnection).toHaveBeenCalled();
// Verify that document listeners are removed (off was called)
expect(dummyDoc.off).toHaveBeenCalled();
expect(dummyDoc.awareness.off).toHaveBeenCalled();
});
});
});
@@ -0,0 +1,193 @@
import { Response } from 'express';
import {
collaborationPollPostMessageHandler,
collaborationPollSSEMessageHandler,
collaborationPollSyncDocHandler,
} from '../src/handlers/collaborationPollHandler';
const mockInitHocuspocusDocument = jest.fn();
const mockSendClientsMessages = jest.fn();
const mockSync = jest.fn();
const mockPullClientsMessages = jest.fn();
jest.mock('@/libs/PollSync', () => {
return {
PollSync: jest.fn().mockImplementation(() => ({
initHocuspocusDocument: mockInitHocuspocusDocument,
sendClientsMessages: mockSendClientsMessages,
sync: mockSync,
pullClientsMessages: mockPullClientsMessages,
})),
};
});
jest.mock('@/servers/hocusPocusServer', () => ({
hocusPocusServer: {},
}));
// Helper function to create a mock response
function createResponse<T>() {
const res: Partial<Response<T>> = {};
res.status = jest.fn().mockReturnValue(res);
res.json = jest.fn().mockReturnValue(res);
res.setHeader = jest.fn();
res.write = jest.fn();
res.headersSent = false;
return res as Response<T>;
}
describe('collaborationPollPostMessageHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
mockInitHocuspocusDocument.mockResolvedValue({ doc: 'exists' });
});
it('should return 403 if user is not allowed to edit', async () => {
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'False' },
body: { message64: 'testMessage' },
} as any;
const res = createResponse();
await collaborationPollPostMessageHandler(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ error: 'Forbidden' });
});
it('should return 400 if room is not provided', async () => {
const req = {
query: {},
headers: { 'x-can-edit': 'True' },
body: { message64: 'testMessage' },
} as any;
const res = createResponse();
await collaborationPollPostMessageHandler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Room name not provided' });
});
it('should return 404 if document is not found', async () => {
mockInitHocuspocusDocument.mockResolvedValue(null);
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
body: { message64: 'testMessage' },
} as any;
const res = createResponse();
res.headersSent = false;
await collaborationPollPostMessageHandler(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found' });
});
it('should process message and return updated true when successful', async () => {
// Reset headerSent to false to simulate a proper response
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
body: { message64: 'testMessage' },
} as any;
const res = createResponse();
res.headersSent = false;
await collaborationPollPostMessageHandler(req, res);
expect(mockSendClientsMessages).toHaveBeenCalledWith('testMessage');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ updated: true });
});
});
describe('collaborationPollSyncDocHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
mockInitHocuspocusDocument.mockResolvedValue({ doc: 'exists' });
mockSync.mockReturnValue('syncDocEncoded');
});
it('should return 400 if room is not provided', async () => {
const req = {
query: {},
headers: { 'x-can-edit': 'True' },
body: { localDoc64: 'localDocEncoded' },
} as any;
const res = createResponse();
await collaborationPollSyncDocHandler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Room name not provided' });
});
it('should return 404 if document is not found', async () => {
mockInitHocuspocusDocument.mockResolvedValue(null);
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
body: { localDoc64: 'localDocEncoded' },
} as any;
const res = createResponse();
await collaborationPollSyncDocHandler(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found' });
});
it('should sync document and return syncDoc64 when successful', async () => {
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
body: { localDoc64: 'localDocEncoded' },
} as any;
const res = createResponse();
res.headersSent = false;
await collaborationPollSyncDocHandler(req, res);
expect(mockSync).toHaveBeenCalledWith('localDocEncoded');
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json).toHaveBeenCalledWith({ syncDoc64: 'syncDocEncoded' });
});
});
describe('collaborationPollSSEMessageHandler', () => {
beforeEach(() => {
jest.clearAllMocks();
mockInitHocuspocusDocument.mockResolvedValue({ doc: 'exists' });
});
it('should return 400 if room is not provided', async () => {
const req = {
query: {},
headers: { 'x-can-edit': 'True' },
} as any;
const res = createResponse();
await collaborationPollSSEMessageHandler(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(res.json).toHaveBeenCalledWith({ error: 'Room name not provided' });
});
it('should return 404 if document is not found', async () => {
mockInitHocuspocusDocument.mockResolvedValue(null);
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
} as any;
const res = createResponse();
await collaborationPollSSEMessageHandler(req, res);
expect(res.status).toHaveBeenCalledWith(404);
expect(res.json).toHaveBeenCalledWith({ error: 'Document not found' });
});
it('should set SSE headers and send connected message when successful', async () => {
const req = {
query: { room: 'test-room' },
headers: { 'x-can-edit': 'True' },
} as any;
const res = createResponse();
res.headersSent = false;
await collaborationPollSSEMessageHandler(req, res);
expect(res.setHeader).toHaveBeenCalledWith(
'Content-Type',
'text/event-stream',
);
expect(res.setHeader).toHaveBeenCalledWith('Cache-Control', 'no-cache');
expect(res.setHeader).toHaveBeenCalledWith('Connection', 'keep-alive');
expect(res.write).toHaveBeenCalledWith(': connected\n\n');
expect(mockPullClientsMessages).toHaveBeenCalledWith(res);
});
});
@@ -7,6 +7,7 @@ var config = {
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/../src/$1',
'^@blocknote/server-util$': '<rootDir>/../__mocks__/mock.js',
'^y-protocols/awareness.js$': '<rootDir>/../__mocks__/mock.js',
},
};
export default config;
@@ -23,6 +23,7 @@
"cors": "2.8.5",
"express": "4.21.2",
"express-ws": "5.0.2",
"uuid": "11.0.5",
"y-protocols": "1.0.6",
"yjs": "13.6.23"
},
@@ -0,0 +1,132 @@
import { Response } from 'express';
import { PollSync, PollSyncRequest } from '@/libs/PollSync';
import { hocusPocusServer } from '@/servers/hocusPocusServer';
interface CollaborationPollPostMessagePayload {
message64: string;
}
interface CollaborationPollPostMessageResponse {
updated?: boolean;
error?: string;
}
export const collaborationPollPostMessageHandler = async (
req: PollSyncRequest<CollaborationPollPostMessagePayload>,
res: Response<CollaborationPollPostMessageResponse>,
) => {
const room = req.query.room;
const canEdit = req.headers['x-can-edit'] === 'True';
// Only editors can send messages
if (!canEdit) {
res.status(403).json({ error: 'Forbidden' });
return;
}
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
const pollSynch = new PollSync<CollaborationPollPostMessagePayload>(
req,
room,
canEdit,
);
const hpDoc = await pollSynch.initHocuspocusDocument(hocusPocusServer);
if (!res.headersSent && !hpDoc) {
res.status(404).json({ error: 'Document not found' });
return;
}
pollSynch.sendClientsMessages(req.body.message64);
if (!res.headersSent) {
res.status(200).json({ updated: true });
}
};
/**
* Polling way of handling collaboration
* @param req
* @param res
*/
interface CollaborationPollSyncDocResponse {
syncDoc64?: string;
error?: string;
}
interface CollaborationPollSyncDocBody {
localDoc64: string;
}
export const collaborationPollSyncDocHandler = async (
req: PollSyncRequest<CollaborationPollSyncDocBody>,
res: Response<CollaborationPollSyncDocResponse>,
) => {
const room = req.query.room;
const canEdit = req.headers['x-can-edit'] === 'True';
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
const pollSynch = new PollSync<CollaborationPollSyncDocBody>(
req,
room,
canEdit,
);
const hpDoc = await pollSynch.initHocuspocusDocument(hocusPocusServer);
if (!hpDoc) {
res.status(404).json({ error: 'Document not found' });
return;
}
const syncDoc64 = pollSynch.sync(req.body.localDoc64);
if (!res.headersSent) {
res.status(200).json({ syncDoc64 });
}
};
/**
* SSE message handling
* @param req
* @param res
*/
interface CollaborationPollSSEMessageResponse {
updatedDoc64?: string;
stateFingerprint?: string;
awareness64?: string;
error?: string;
}
export const collaborationPollSSEMessageHandler = async (
req: PollSyncRequest<void>,
res: Response<CollaborationPollSSEMessageResponse>,
) => {
const room = req.query.room;
const canEdit = req.headers['x-can-edit'] === 'True';
if (!room) {
res.status(400).json({ error: 'Room name not provided' });
return;
}
const pollSynch = new PollSync<void>(req, room, canEdit);
const hpDoc = await pollSynch.initHocuspocusDocument(hocusPocusServer);
if (!hpDoc) {
res.status(404).json({ error: 'Document not found' });
return;
}
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
res.write(': connected\n\n');
pollSynch.pullClientsMessages(res);
};
@@ -1,3 +1,4 @@
export * from './collaborationResetConnectionsHandler';
export * from './collaborationWSHandler';
export * from './collaborationPollHandler';
export * from './convertMarkdownHandler';
export * from './collaborationWSHandler';
@@ -1,6 +1,6 @@
export const promiseDone = () => {
let done: (value: void | PromiseLike<void>) => void = () => {};
const promise = new Promise<void>((resolve) => {
export const promiseDone = <T = void>() => {
let done: (value: T | PromiseLike<T>) => void = () => {};
const promise = new Promise<T>((resolve) => {
done = resolve;
});
@@ -0,0 +1,227 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import crypto from 'crypto';
import {
AwarenessUpdate,
Document,
Hocuspocus,
IncomingMessage,
MessageType,
OutgoingMessage,
} from '@hocuspocus/server';
import { Request, Response } from 'express';
import { v4 as uuid } from 'uuid';
import { applyAwarenessUpdate } from 'y-protocols/awareness.js';
import { readSyncMessage } from 'y-protocols/sync';
import * as Y from 'yjs';
import { base64ToYDoc, logger, toBase64 } from '@/utils';
export type PollSyncRequestQuery = {
room?: string;
};
export type PollSyncRequest<T> = Request<
object,
object,
T,
PollSyncRequestQuery
>;
export class PollSync<T> {
public readonly canEdit: boolean;
public readonly req: PollSyncRequest<T>;
public readonly room: string;
private _hpDocument?: Document;
constructor(req: PollSyncRequest<T>, room: string, canEdit: boolean) {
this.room = room;
this.canEdit = canEdit;
this.req = req;
}
public get hpDocument() {
return this._hpDocument;
}
public async initHocuspocusDocument(hocusPocusServer: Hocuspocus) {
const { req, room, canEdit } = this;
this._hpDocument = await hocusPocusServer.loadingDocuments.get(room);
if (this._hpDocument) {
return this._hpDocument;
}
this._hpDocument = hocusPocusServer.documents.get(room);
if (this._hpDocument || (!this._hpDocument && !canEdit)) {
return this._hpDocument;
}
/**
* createDocument is used to create a new document if it does not exist.
* If the document exists, it will return the existing document.
*/
this._hpDocument = await hocusPocusServer.createDocument(
room,
req,
uuid(),
{
readOnly: false,
requiresAuthentication: false,
isAuthenticated: false,
},
);
return this._hpDocument;
}
/**
* Sync the document with the latest changes
*
* @param localDoc64
* @returns
*/
public sync(localDoc64: string): string | undefined {
const hpDoc = this.getHpDocument();
let syncYDoc = hpDoc;
// Merge the coming document with the latest changes (only if the user can edit)
if (this.canEdit) {
const localDoc = base64ToYDoc(localDoc64);
syncYDoc = hpDoc.merge(localDoc);
}
return toBase64(Y.encodeStateAsUpdate(syncYDoc));
}
/**
* Create a hash SHA-256 of the state vector of the document.
* Usefull to compare the state of the document.
* @param doc
* @returns
*/
protected getStateFingerprint(doc: Y.Doc): string {
const stateVector = Y.encodeStateVector(doc);
return crypto.createHash('sha256').update(stateVector).digest('base64'); // or 'hex'
}
/**
* Send messages to other clients
*/
public sendClientsMessages(message64: string) {
const hpDoc = this.getHpDocument();
const messageBuffer = Buffer.from(message64, 'base64');
const message = new IncomingMessage(messageBuffer);
const room = message.readVarString();
if (hpDoc.name !== room) {
logger('Send messages problem, room different', room, hpDoc.name);
return;
}
// We write the sync to the current doc - it will propagate to others by itself
const type = message.readVarUint() as MessageType;
if (type === MessageType.Sync) {
message.writeVarUint(MessageType.Sync);
readSyncMessage(message.decoder, message.encoder, hpDoc, null);
} else if (type === MessageType.Awareness) {
const awarenessUpdate = message.readVarUint8Array();
applyAwarenessUpdate(
hpDoc.awareness,
awarenessUpdate,
hpDoc.awareness.clientID,
);
} else {
hpDoc.getConnections().forEach((connection) => {
connection.handleMessage(messageBuffer);
});
}
}
/**
* Pull messages from other clients
*
* We listen 2 kind of messages:
* - Document updates (change in the document)
* - Awareness messages (cursor, selection, etc.)
*
* @param res
*/
public pullClientsMessages(res: Response) {
const hpDoc = this.getHpDocument();
hpDoc.addDirectConnection();
const updateMessagesFn = (
update: Uint8Array,
_origin: string,
updatedDoc: Y.Doc,
_transaction: Y.Transaction,
) => {
res.write(
`data: ${JSON.stringify({
time: new Date(),
updatedDoc64: toBase64(update),
stateFingerprint: this.getStateFingerprint(updatedDoc),
})}\n\n`,
);
};
const destroyFn = (updatedDoc: Y.Doc) => {
res.write(
`data: ${JSON.stringify({
time: new Date(),
updatedDoc64: undefined,
stateFingerprint: this.getStateFingerprint(updatedDoc),
})}\n\n`,
);
hpDoc.off('destroy', destroyFn);
hpDoc.off('update', updateMessagesFn);
// Close the connection
res.end();
};
const updateAwarenessFn = ({
added,
updated,
removed,
}: AwarenessUpdate) => {
const changedClients = added.concat(updated, removed);
const awarenessMessage = new OutgoingMessage(
this.room,
).createAwarenessUpdateMessage(hpDoc.awareness, changedClients);
res.write(
`data: ${JSON.stringify({
time: new Date(),
awareness64: toBase64(awarenessMessage.toUint8Array()),
stateFingerprint: this.getStateFingerprint(hpDoc),
})}\n\n`,
);
};
hpDoc.awareness.off('update', updateAwarenessFn);
hpDoc.awareness.on('update', updateAwarenessFn);
hpDoc.off('update', updateMessagesFn);
hpDoc.off('destroy', destroyFn);
hpDoc.on('update', updateMessagesFn);
hpDoc.on('destroy', destroyFn);
this.req.on('close', () => {
hpDoc.off('update', updateMessagesFn);
hpDoc.off('destroy', destroyFn);
hpDoc.awareness.off('update', updateAwarenessFn);
hpDoc.removeDirectConnection();
});
}
protected getHpDocument() {
if (!this.hpDocument) {
throw new Error('HocusPocus document not initialized');
}
return this.hpDocument;
}
}
@@ -1,5 +1,7 @@
export const routes = {
COLLABORATION_WS: '/collaboration/ws/',
COLLABORATION_POLL_MESSAGE: '/collaboration/ws/poll/message/',
COLLABORATION_POLL_SYNC: '/collaboration/ws/poll/sync/',
COLLABORATION_RESET_CONNECTIONS: '/collaboration/api/reset-connections/',
CONVERT_MARKDOWN: '/api/convert-markdown/',
};
@@ -6,6 +6,9 @@ import expressWebsockets from 'express-ws';
import { PORT } from '../env';
import {
collaborationPollPostMessageHandler,
collaborationPollSSEMessageHandler,
collaborationPollSyncDocHandler,
collaborationResetConnectionsHandler,
collaborationWSHandler,
convertMarkdownHandler,
@@ -27,9 +30,24 @@ export const initServer = () => {
app.use(corsMiddleware);
/**
* Route to handle WebSocket connections
* Routes to handle collaboration connections
*/
app.ws(routes.COLLABORATION_WS, wsSecurity, collaborationWSHandler);
app.get(
routes.COLLABORATION_POLL_MESSAGE,
httpSecurity,
collaborationPollSSEMessageHandler,
);
app.post(
routes.COLLABORATION_POLL_MESSAGE,
httpSecurity,
collaborationPollPostMessageHandler,
);
app.post(
routes.COLLABORATION_POLL_SYNC,
httpSecurity,
collaborationPollSyncDocHandler,
);
/**
* Route to reset connections in a room:
+11 -2
View File
@@ -1,3 +1,5 @@
import * as Y from 'yjs';
import { COLLABORATION_LOGGING } from './env';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -8,6 +10,13 @@ export function logger(...args: any[]) {
}
}
export const toBase64 = function (str: Uint8Array) {
return Buffer.from(str).toString('base64');
export const toBase64 = function (uInt8Array: Uint8Array) {
return Buffer.from(uInt8Array).toString('base64');
};
export const base64ToYDoc = (base64: string) => {
const uint8Array = Buffer.from(base64, 'base64');
const ydoc = new Y.Doc();
Y.applyUpdate(ydoc, uint8Array);
return ydoc;
};
+1 -1
View File
@@ -13934,7 +13934,7 @@ [email protected]:
resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713"
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
uuid@^11.0.3:
[email protected], uuid@^11.0.3:
version "11.0.5"
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.0.5.tgz#07b46bdfa6310c92c3fb3953a8720f170427fc62"
integrity sha512-508e6IcKLrhxKdBbcA2b4KQZlLVp2+J5UwQ6F7Drckkc5N9ZJwFa4TgWtsww9UG8fGHbm6gbV19TdM5pQ4GaIA==