@textea/y-socket.io
Version:
Socket.io Connector for Yjs
182 lines (177 loc) • 6.55 kB
JavaScript
/// <reference types="./server.d.ts" />
import { createServer } from 'http';
import { Server } from 'socket.io';
import { Awareness, encodeAwarenessUpdate, applyAwarenessUpdate, removeAwarenessStates } from 'y-protocols/awareness';
import * as Y from 'yjs';
const getClients = (awareness)=>[
...awareness.getStates().keys()
];
/**
* There are four scenarios:
* 1. Signed User share sheet to specified person with write permission (both side need authorization)
* 2. Signed User share sheet to specified person with only view permission (both side need authorization)
* 3. Signed User share sheet to everyone with write permission (only one side need authorization)
* 4. Signed User share sheet to everyone with only view permission (only one side need authorization)
*
* If sheet owner close sharing (or disable sharing), others won't see the sheet anymore,
* which means we will delete the sheet in their browser.
*
* We only consider scenario 3 for now, because It's easy to implement
*/ const createSocketIOServer = (httpServer, { getUserId , persistence , autoDeleteRoom =false , cors , allowRequest } = {})=>{
const roomMap = new Map();
const io = new Server(httpServer, {
cors,
allowRequest
});
io.use((socket, next)=>{
const { roomName , clientId } = socket.handshake.query;
if (typeof roomName !== 'string') {
return next(new Error("wrong type of query parameter 'roomName'"));
}
if (typeof clientId !== 'string' || Number.isNaN(+clientId)) {
return next(new Error("wrong type of query parameter 'clientId'"));
}
socket.yjs = {
roomMap,
roomName,
clientId: Number(clientId)
};
return next();
});
io.use((socket, next)=>{
const result = getUserId?.(socket) ?? socket.yjs.clientId;
if (result instanceof Error) {
return next(result);
}
socket.userId = result;
return next();
});
const { adapter } = io.of('/');
const isDefaultRoom = (roomName)=>{
return adapter.sids.has(roomName);
// ^^^^ Map<SocketId, Set<RoomName>>
};
adapter.on('create-room', (roomName)=>{
if (isDefaultRoom(roomName) || roomMap.has(roomName)) {
return;
}
const doc = new Y.Doc();
const awareness = new Awareness(doc);
// delete local `clientId` from `awareness.getStates()` Map
awareness.setLocalState(null);
awareness.on('update', (changes, origin)=>{
const changedClients = Object.values(changes).reduce((res, cur)=>[
...res,
...cur
]);
const update = encodeAwarenessUpdate(awareness, changedClients);
io.to(roomName).except(origin).emit('awareness:update', update);
});
const prepareDoc = async ()=>{
await persistence?.bindState(roomName, doc);
doc.on('update', (updateV1, origin)=>{
const updateV2 = Y.convertUpdateFormatV1ToV2(updateV1);
io.to(roomName).except(origin).emit('doc:update', updateV2);
});
return doc;
};
const preparingDoc = prepareDoc();
const room = {
owner: null,
awareness,
getDoc: ()=>preparingDoc,
destroy: async ()=>{
await persistence?.writeState(roomName, doc);
doc.destroy();
awareness.destroy();
}
};
roomMap.set(roomName, room);
});
if (autoDeleteRoom) {
adapter.on('delete-room', (roomName)=>{
if (isDefaultRoom(roomName) || !roomMap.has(roomName)) {
return;
}
const room = roomMap.get(roomName);
roomMap.delete(roomName);
return room.destroy();
});
}
io.on('connection', (socket)=>{
const { roomName } = socket.yjs;
socket.join(roomName);
const room = roomMap.get(roomName);
if (room.owner === null) {
room.owner = socket.userId;
}
const clients = getClients(room.awareness);
if (clients.length) {
const awarenessUpdate = encodeAwarenessUpdate(room.awareness, clients);
socket.emit('awareness:update', awarenessUpdate);
}
room.getDoc().then((doc)=>{
const docDiff = Y.encodeStateVector(doc);
socket.emit('doc:diff', docDiff);
});
socket.on('room:close', ()=>{
const room = roomMap.get(roomName);
if (!room || socket.userId !== room.owner) {
return;
}
io.to(roomName).disconnectSockets();
roomMap.delete(roomName);
return room.destroy();
});
socket.on('doc:diff', (diff)=>{
const room = roomMap.get(roomName);
if (!room) {
return;
}
room.getDoc().then((doc)=>{
const updateV2 = Y.encodeStateAsUpdateV2(doc, diff);
socket.emit('doc:update', updateV2);
});
});
socket.on('doc:update', (updateV2, callback)=>{
const room = roomMap.get(roomName);
if (!room) {
return;
}
room.getDoc().then((doc)=>{
Y.applyUpdateV2(doc, updateV2, socket.id);
callback?.();
});
});
socket.on('awareness:update', (update)=>{
const room = roomMap.get(roomName);
if (!room) {
return;
}
applyAwarenessUpdate(room.awareness, update, socket.id);
});
socket.on('disconnect', ()=>{
const room = roomMap.get(roomName);
if (!room) {
return;
}
const { clientId } = socket.yjs;
removeAwarenessStates(room.awareness, [
clientId
], socket.id);
});
});
return io;
};
const createSimpleServer = (options = {})=>{
const httpServer = createServer((request, response)=>{
response.writeHead(200, {
'Content-Type': 'application/json'
});
response.end('"okay"');
});
createSocketIOServer(httpServer, options);
return httpServer;
};
export { createSimpleServer, createSocketIOServer };
//# sourceMappingURL=server.mjs.map