migrate from github
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.git/
|
||||
.vscode/
|
||||
.idea/
|
||||
src/gen/
|
||||
dist-ssr/
|
||||
|
||||
*.log
|
||||
*.local
|
||||
.DS_Store
|
||||
@@ -0,0 +1 @@
|
||||
gen/
|
||||
@@ -0,0 +1,6 @@
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
|
||||
EXPOSE 1234
|
||||
|
||||
CMD ["sh", "-c", "npm install && node server.js"]
|
||||
@@ -0,0 +1,29 @@
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
RUN npm install -g grpc-tools protoc-gen-js
|
||||
|
||||
WORKDIR /app
|
||||
COPY collab/package.json collab/package-lock.json* ./
|
||||
RUN npm install
|
||||
|
||||
COPY proto ./proto
|
||||
|
||||
RUN mkdir -p gen && \
|
||||
PROTOC="/usr/local/bin/grpc_tools_node_protoc" && \
|
||||
GRPC_PLUGIN="/usr/local/bin/grpc_tools_node_protoc_plugin" && \
|
||||
"$PROTOC" -I proto \
|
||||
--plugin=protoc-gen-grpc="$GRPC_PLUGIN" \
|
||||
--grpc_out=grpc_js:gen \
|
||||
--js_out=import_style=commonjs,binary:gen \
|
||||
proto/yoresee_doc/v1/yoresee_doc.proto
|
||||
|
||||
FROM node:20-alpine
|
||||
WORKDIR /app
|
||||
RUN mkdir -p src
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/gen ./src/gen
|
||||
COPY collab/. .
|
||||
|
||||
EXPOSE 1234
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
Generated
+1222
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "yoresee-doc-collab",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"main": "server.js",
|
||||
"type": "commonjs",
|
||||
"scripts": {
|
||||
"start": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "^1.12.0",
|
||||
"@grpc/proto-loader": "^0.7.13",
|
||||
"amqplib": "^0.10.9",
|
||||
"google-protobuf": "^4.0.2",
|
||||
"redis": "^4.6.0",
|
||||
"ws": "^8.18.0",
|
||||
"y-redis": "^1.0.3",
|
||||
"y-websocket": "^1.5.4",
|
||||
"yjs": "^13.6.16"
|
||||
},
|
||||
"devDependencies": {
|
||||
"protoc": "^34.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
const wsUtils = require('./src/ws-utils');
|
||||
const { setupWSConnection } = wsUtils;
|
||||
const config = require('./src/config');
|
||||
const redis = require('./src/redis');
|
||||
const grpc = require('./src/grpc');
|
||||
const mq = require('./src/mq');
|
||||
const { createHTTPServer } = require('./src/http/server');
|
||||
const { bindWebSocketGateway } = require('./src/ws-gateway');
|
||||
const { startServer } = require('./src/bootstrap');
|
||||
const { createGracefulShutdown, registerShutdownSignals } = require('./src/lifecycle');
|
||||
|
||||
let isDraining = false;
|
||||
|
||||
const server = createHTTPServer({
|
||||
redis,
|
||||
wsUtils,
|
||||
getIsDraining: () => isDraining
|
||||
});
|
||||
const wss = bindWebSocketGateway({
|
||||
server,
|
||||
redis,
|
||||
setupWSConnection
|
||||
});
|
||||
startServer({ server, config, redis, grpc });
|
||||
|
||||
const shutdown = createGracefulShutdown({
|
||||
server,
|
||||
wss,
|
||||
mq,
|
||||
redis,
|
||||
onDraining: () => {
|
||||
isDraining = true;
|
||||
}
|
||||
});
|
||||
|
||||
registerShutdownSignals(shutdown);
|
||||
Vendored
+17
@@ -0,0 +1,17 @@
|
||||
function startServer({ server, config, redis, grpc }) {
|
||||
server.listen(config.port, async () => {
|
||||
await redis.initRedis();
|
||||
redis.initYRedis();
|
||||
grpc.initGrpcClient();
|
||||
|
||||
await grpc.healthCheck();
|
||||
|
||||
console.log(`Collab server listening on ${config.port}`);
|
||||
console.log(`Redis config: ${config.redisHost}:${config.redisPort}/${config.redisDb}`);
|
||||
console.log('HTTP API: /api/active-rooms');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
startServer
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
const port = Number(process.env.PORT || 1234);
|
||||
|
||||
const redisHost = process.env.REDIS_HOST || 'redis';
|
||||
const redisPort = Number(process.env.REDIS_PORT || 6379);
|
||||
const redisPassword = process.env.REDIS_PASSWORD || '';
|
||||
const redisDb = Number(process.env.REDIS_DB || 0);
|
||||
|
||||
const backendAddr = process.env.BACKEND_ADDR || 'backend:9090';
|
||||
const dirtyDocTopic = process.env.DIRTY_DOC_TOPIC || 'collab.dirty_docs';
|
||||
const dirtyDocMqType = process.env.DIRTY_DOC_MQ || 'redis';
|
||||
const rabbitmqUrl = process.env.RABBITMQ_URL || '';
|
||||
const internalRpcKey = process.env.INTERNAL_RPC_KEY || '';
|
||||
const dirtyDocSetKey = process.env.DIRTY_DOC_SET_KEY || 'collab:yjs:dirty:doc';
|
||||
const dirtyDocNotifyThreshold = Number(process.env.DIRTY_DOC_NOTIFY_THRESHOLD || 200);
|
||||
const docUpdatesTTL = Number(process.env.DOC_UPDATES_TTL || 259200);
|
||||
|
||||
module.exports = {
|
||||
port,
|
||||
redisHost,
|
||||
redisPort,
|
||||
redisPassword,
|
||||
redisDb,
|
||||
backendAddr,
|
||||
dirtyDocTopic,
|
||||
dirtyDocMqType,
|
||||
rabbitmqUrl,
|
||||
internalRpcKey,
|
||||
dirtyDocSetKey,
|
||||
dirtyDocNotifyThreshold,
|
||||
docUpdatesTTL
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
const Y = require('yjs');
|
||||
const redis = require('./redis');
|
||||
const backend = require('./grpc');
|
||||
const { keyCollabDocUpdates } = require('./key');
|
||||
|
||||
const snapshotTimeoutMs = Number(process.env.BACKEND_SNAPSHOT_TIMEOUT_MS || 3000);
|
||||
|
||||
async function loadDoc(docId) {
|
||||
const start = Date.now();
|
||||
console.log(`[doc-loader] start docId=${docId}`);
|
||||
const redisKey = keyCollabDocUpdates(docId);
|
||||
const backendDocId = docId;
|
||||
|
||||
try {
|
||||
const updates = await redis.getListBuffers(redisKey);
|
||||
console.log(`[doc-loader] redis ${docId} ${updates && updates.length ? 'hit' : 'miss'}`);
|
||||
|
||||
const doc = new Y.Doc();
|
||||
if (updates && updates.length > 0) {
|
||||
for (const update of updates) {
|
||||
if (update && update.length > 0) {
|
||||
Y.applyUpdate(doc, update);
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
console.log(`[doc-loader] no redis cache, trying snapshot docId=${docId}`);
|
||||
try {
|
||||
console.log(`[doc-loader] requesting snapshot docId=${docId}`);
|
||||
const snapshot = await Promise.race([
|
||||
backend.getDocumentYjsSnapshot(backendDocId),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('snapshot timeout')), snapshotTimeoutMs))
|
||||
]);
|
||||
console.log(`[doc-loader] received snapshot docId=${docId} length=${snapshot ? snapshot.length : 0}`);
|
||||
|
||||
if (snapshot && snapshot.length > 0) {
|
||||
console.log(`[doc-loader] applying snapshot docId=${docId} bytes=${snapshot.length}`);
|
||||
Y.applyUpdate(doc, snapshot);
|
||||
console.log(`[doc-loader] saving snapshot to redis docId=${docId}`);
|
||||
await redis.replaceListBuffer(redisKey, snapshot);
|
||||
console.log(`[doc-loader] apply snapshot docId=${docId} bytes=${snapshot.length} took=${Date.now() - start}ms`);
|
||||
} else {
|
||||
console.log(`[doc-loader] snapshot empty docId=${docId} took=${Date.now() - start}ms`);
|
||||
console.log(`[doc-loader] trying to get document content docId=${docId}`);
|
||||
const content = await backend.getDocumentContent(backendDocId);
|
||||
console.log(`[doc-loader] received content docId=${docId} length=${content ? content.length : 0}`);
|
||||
|
||||
if (content) {
|
||||
console.log(`[doc-loader] seeding content docId=${docId} length=${content.length}`);
|
||||
const ytext = doc.getText('content');
|
||||
ytext.insert(0, content);
|
||||
const initialUpdate = Y.encodeStateAsUpdate(doc);
|
||||
console.log(`[doc-loader] saving initial update to redis docId=${docId}`);
|
||||
await redis.replaceListBuffer(redisKey, initialUpdate);
|
||||
console.log(`[doc-loader] seeded content docId=${docId} bytes=${initialUpdate.length} took=${Date.now() - start}ms`);
|
||||
} else {
|
||||
console.log(`[doc-loader] no content to seed docId=${docId} took=${Date.now() - start}ms`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[doc-loader] snapshot error docId=${docId} err=${err.message} stack=${err.stack} took=${Date.now() - start}ms`);
|
||||
// Fallback to getting document content when snapshot fails
|
||||
try {
|
||||
console.log(`[doc-loader] fallback: trying to get document content docId=${docId}`);
|
||||
const content = await backend.getDocumentContent(backendDocId);
|
||||
console.log(`[doc-loader] fallback: received content docId=${docId} length=${content ? content.length : 0}`);
|
||||
|
||||
if (content) {
|
||||
console.log(`[doc-loader] fallback: seeding content docId=${docId} length=${content.length}`);
|
||||
const ytext = doc.getText('content');
|
||||
ytext.insert(0, content);
|
||||
const initialUpdate = Y.encodeStateAsUpdate(doc);
|
||||
console.log(`[doc-loader] fallback: saving initial update to redis docId=${docId}`);
|
||||
await redis.replaceListBuffer(redisKey, initialUpdate);
|
||||
console.log(`[doc-loader] fallback seeded content docId=${docId} bytes=${initialUpdate.length} took=${Date.now() - start}ms`);
|
||||
} else {
|
||||
console.log(`[doc-loader] fallback no content to seed docId=${docId} took=${Date.now() - start}ms`);
|
||||
}
|
||||
} catch (fallbackErr) {
|
||||
console.error(`[doc-loader] fallback error docId=${docId} err=${fallbackErr.message} stack=${fallbackErr.stack} took=${Date.now() - start}ms`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[doc-loader] returning doc docId=${docId} took=${Date.now() - start}ms`);
|
||||
return doc;
|
||||
} catch (err) {
|
||||
console.error(`[doc-loader] fatal error docId=${docId} err=${err.message} stack=${err.stack} took=${Date.now() - start}ms`);
|
||||
// Return empty doc as last resort
|
||||
return new Y.Doc();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadDoc
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
const grpc = require('@grpc/grpc-js');
|
||||
const { DocumentServiceClient, SystemServiceClient } = require('../gen/yoresee_doc/v1/yoresee_doc_grpc_pb');
|
||||
const config = require('../config');
|
||||
|
||||
let documentServiceClient = null;
|
||||
let systemServiceClient = null;
|
||||
|
||||
const buildMetadata = () => {
|
||||
const md = new grpc.Metadata();
|
||||
if (config.internalRpcKey) {
|
||||
md.set('x-internal-key', config.internalRpcKey);
|
||||
}
|
||||
return md;
|
||||
};
|
||||
|
||||
function initGrpcClient() {
|
||||
try {
|
||||
const opts = {
|
||||
'grpc.default_service_config': JSON.stringify({
|
||||
loadBalancingConfig: [{ round_robin: {} }]
|
||||
})
|
||||
};
|
||||
const creds = grpc.credentials.createInsecure();
|
||||
documentServiceClient = new DocumentServiceClient(config.backendAddr, creds, opts);
|
||||
systemServiceClient = new SystemServiceClient(config.backendAddr, creds, opts);
|
||||
console.log(`gRPC clients initialized, connecting to ${config.backendAddr}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize gRPC client:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initGrpcClient,
|
||||
buildMetadata,
|
||||
getDocumentServiceClient: () => documentServiceClient,
|
||||
getSystemServiceClient: () => systemServiceClient,
|
||||
};
|
||||
@@ -0,0 +1,76 @@
|
||||
const pb = require('../gen/yoresee_doc/v1/yoresee_doc_pb');
|
||||
const { getDocumentServiceClient, buildMetadata } = require('./client');
|
||||
|
||||
async function getDocumentContent(documentExternalId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = getDocumentServiceClient();
|
||||
if (!client) {
|
||||
return reject(new Error('gRPC client not initialized'));
|
||||
}
|
||||
const request = new pb.GetDocumentContentRequest();
|
||||
request.setDocumentExternalId(documentExternalId);
|
||||
|
||||
client.getDocumentContent(request, buildMetadata(), (err, response) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
const base = response?.getBase?.();
|
||||
if (base && base.getCode && base.getCode() !== 0) {
|
||||
return reject(new Error(base.getMessage() || 'Failed to get document content'));
|
||||
}
|
||||
resolve(response?.getContent?.() || '');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function getDocumentYjsSnapshot(documentExternalId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = getDocumentServiceClient();
|
||||
if (!client) {
|
||||
return reject(new Error('gRPC client not initialized'));
|
||||
}
|
||||
const request = new pb.GetDocumentYjsSnapshotRequest();
|
||||
request.setDocumentExternalId(documentExternalId);
|
||||
|
||||
client.getDocumentYjsSnapshot(request, buildMetadata(), (err, response) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
const base = response?.getBase?.();
|
||||
if (base && base.getCode && base.getCode() !== 0) {
|
||||
return reject(new Error(base.getMessage() || 'Failed to get document snapshot'));
|
||||
}
|
||||
const state = response?.getState_asU8?.();
|
||||
resolve(state && state.length ? Buffer.from(state) : null);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function saveDocumentYjsSnapshot(documentExternalId, state) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = getDocumentServiceClient();
|
||||
if (!client) {
|
||||
return reject(new Error('gRPC client not initialized'));
|
||||
}
|
||||
const request = new pb.SaveDocumentYjsSnapshotRequest();
|
||||
request.setDocumentExternalId(documentExternalId);
|
||||
request.setState(state);
|
||||
|
||||
client.saveDocumentYjsSnapshot(request, buildMetadata(), (err, response) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
const base = response?.getBase?.();
|
||||
if (base && base.getCode && base.getCode() !== 0) {
|
||||
return reject(new Error(base.getMessage() || 'Failed to save document snapshot'));
|
||||
}
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getDocumentContent,
|
||||
getDocumentYjsSnapshot,
|
||||
saveDocumentYjsSnapshot,
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
const { initGrpcClient, getDocumentServiceClient, getSystemServiceClient } = require('./client');
|
||||
const { getDocumentContent, getDocumentYjsSnapshot, saveDocumentYjsSnapshot } = require('./document');
|
||||
const { healthCheck } = require('./system');
|
||||
|
||||
module.exports = {
|
||||
initGrpcClient,
|
||||
getDocumentContent,
|
||||
getDocumentYjsSnapshot,
|
||||
saveDocumentYjsSnapshot,
|
||||
healthCheck,
|
||||
get documentServiceClient() {
|
||||
return getDocumentServiceClient();
|
||||
},
|
||||
get systemServiceClient() {
|
||||
return getSystemServiceClient();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
const pb = require('../gen/yoresee_doc/v1/yoresee_doc_pb');
|
||||
const { getSystemServiceClient, buildMetadata } = require('./client');
|
||||
|
||||
async function healthCheck() {
|
||||
const client = getSystemServiceClient();
|
||||
if (!client) {
|
||||
console.log('System service client not initialized, skipping health check');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const request = new pb.HealthRequest();
|
||||
client.health(request, buildMetadata(), (err, response) => {
|
||||
if (err) {
|
||||
console.error('Health check failed:', err);
|
||||
} else {
|
||||
const base = response?.getBase?.();
|
||||
if (base && base.getCode && base.getCode() === 0) {
|
||||
console.log('Health check successful');
|
||||
} else {
|
||||
console.error('Health check failed:', base ? base.getMessage() : 'Unknown error');
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to execute health check:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
healthCheck,
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
const Y = require('yjs');
|
||||
const { keyCollabDocUpdates } = require('../key');
|
||||
|
||||
function findDocInMemory(docs, docId) {
|
||||
if (!docs) {
|
||||
return null;
|
||||
}
|
||||
return docs.get(`doc-${docId}`) || docs.get(docId) || null;
|
||||
}
|
||||
|
||||
async function buildDocFromRedis(redis, docId) {
|
||||
const redisKey = keyCollabDocUpdates(docId);
|
||||
const updates = await redis.getListBuffers(redisKey);
|
||||
if (!updates || updates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const doc = new Y.Doc();
|
||||
for (const update of updates) {
|
||||
if (update && update.length > 0) {
|
||||
Y.applyUpdate(doc, update);
|
||||
}
|
||||
}
|
||||
return doc;
|
||||
}
|
||||
|
||||
async function loadDocForRead(docs, redis, docId) {
|
||||
const inMemoryDoc = findDocInMemory(docs, docId);
|
||||
if (inMemoryDoc) {
|
||||
return inMemoryDoc;
|
||||
}
|
||||
return buildDocFromRedis(redis, docId);
|
||||
}
|
||||
|
||||
const XML_FRAGMENT_DOC_TYPES = new Set(['yoresee_rich_text']);
|
||||
|
||||
function encodeSnapshotResponse(doc, docType) {
|
||||
const update = Y.encodeStateAsUpdate(doc);
|
||||
let content = '';
|
||||
|
||||
if (XML_FRAGMENT_DOC_TYPES.has(docType)) {
|
||||
const fragment = doc.getXmlFragment('content');
|
||||
content = fragment ? fragment.toString() : '';
|
||||
} else {
|
||||
const ytext = doc.getText('content');
|
||||
content = ytext ? ytext.toString() : '';
|
||||
}
|
||||
|
||||
return {
|
||||
state: Buffer.from(update).toString('base64'),
|
||||
content
|
||||
};
|
||||
}
|
||||
|
||||
function encodeBinaryUpdate(doc) {
|
||||
return Buffer.from(Y.encodeStateAsUpdate(doc));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
loadDocForRead,
|
||||
encodeSnapshotResponse,
|
||||
encodeBinaryUpdate
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
function writeJson(res, status, payload) {
|
||||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
function writeText(res, status, body) {
|
||||
res.writeHead(status);
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function getRequestURL(req) {
|
||||
const host = req.headers && req.headers.host ? req.headers.host : 'localhost';
|
||||
return new URL(req.url || '/', `http://${host}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
writeJson,
|
||||
writeText,
|
||||
getRequestURL
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
const { writeJson } = require('./helpers');
|
||||
|
||||
function buildReadinessState(isDraining, redisClient) {
|
||||
const ready = !isDraining && !!redisClient;
|
||||
return {
|
||||
ready,
|
||||
redisState: redisClient ? 'connected' : 'disconnected',
|
||||
detail: isDraining ? 'server is draining' : undefined
|
||||
};
|
||||
}
|
||||
|
||||
function handleProbeRequest(pathname, res, { isDraining, redisClient }) {
|
||||
if (pathname === '/livez') {
|
||||
writeJson(res, 200, { status: 'ok' });
|
||||
return true;
|
||||
}
|
||||
|
||||
const state = buildReadinessState(isDraining, redisClient);
|
||||
|
||||
if (pathname === '/readyz') {
|
||||
writeJson(res, state.ready ? 200 : 503, {
|
||||
status: state.ready ? 'ok' : 'not_ready',
|
||||
redis: state.redisState,
|
||||
detail: state.detail
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (pathname === '/health') {
|
||||
writeJson(res, 200, {
|
||||
status: 'ok',
|
||||
redis: state.redisState,
|
||||
persistence: 'custom',
|
||||
ready: state.ready ? 'true' : 'false',
|
||||
detail: state.detail
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
handleProbeRequest
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
const { writeJson, writeText, getRequestURL } = require('./helpers');
|
||||
const { handleProbeRequest } = require('./probes');
|
||||
const { loadDocForRead, encodeSnapshotResponse, encodeBinaryUpdate } = require('./docs');
|
||||
|
||||
function decodeDocID(pathname, prefix) {
|
||||
try {
|
||||
return decodeURIComponent(pathname.slice(prefix.length));
|
||||
} catch (_err) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function createRequestHandler({ redis, wsUtils, getIsDraining }) {
|
||||
const docs = wsUtils.docs;
|
||||
|
||||
return async function handleRequest(req, res) {
|
||||
try {
|
||||
const url = getRequestURL(req);
|
||||
const pathname = url.pathname;
|
||||
|
||||
if (handleProbeRequest(pathname, res, { isDraining: getIsDraining(), redisClient: redis.redisClient })) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/api/active-rooms') {
|
||||
const rooms = await redis.getActiveRooms();
|
||||
writeJson(res, 200, { rooms, count: rooms.length });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/internal/yjs/doc-snapshot/')) {
|
||||
const docId = decodeDocID(pathname, '/internal/yjs/doc-snapshot/');
|
||||
if (!docId) {
|
||||
writeText(res, 400, 'doc id required');
|
||||
return;
|
||||
}
|
||||
|
||||
const docType = url.searchParams.get('type') || '';
|
||||
const doc = await loadDocForRead(docs, redis, docId);
|
||||
if (!doc) {
|
||||
writeText(res, 404, 'doc not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
writeJson(res, 200, encodeSnapshotResponse(doc, docType));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname.startsWith('/internal/yjs/doc/')) {
|
||||
const docId = decodeDocID(pathname, '/internal/yjs/doc/');
|
||||
if (!docId) {
|
||||
writeText(res, 400, 'doc id required');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc = await loadDocForRead(docs, redis, docId);
|
||||
if (!doc) {
|
||||
writeText(res, 404, 'doc not loaded');
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/octet-stream' });
|
||||
res.end(encodeBinaryUpdate(doc));
|
||||
return;
|
||||
}
|
||||
|
||||
writeText(res, 404, '');
|
||||
} catch (err) {
|
||||
console.error('Request handling failed:', err);
|
||||
writeText(res, 500, 'internal error');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createRequestHandler
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
const http = require('http');
|
||||
const { createRequestHandler } = require('./router');
|
||||
|
||||
function createHTTPServer({ redis, wsUtils, getIsDraining }) {
|
||||
const requestHandler = createRequestHandler({
|
||||
redis,
|
||||
wsUtils,
|
||||
getIsDraining
|
||||
});
|
||||
|
||||
return http.createServer((req, res) => {
|
||||
void requestHandler(req, res);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createHTTPServer
|
||||
};
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
function keyCollabDocUpdates(docId) {
|
||||
return `collab:yjs:doc:updates:${docId}`;
|
||||
}
|
||||
|
||||
function keyCollabRoom(docId) {
|
||||
return `collab:room:doc-${docId}`;
|
||||
}
|
||||
|
||||
function keyCollabRoomRaw(roomName) {
|
||||
return `collab:room:${roomName}`;
|
||||
}
|
||||
|
||||
function keyCollabRoomPattern() {
|
||||
return 'collab:room:*';
|
||||
}
|
||||
|
||||
function parseRoomName(redisKey) {
|
||||
return redisKey.slice('collab:room:'.length);
|
||||
}
|
||||
|
||||
function keyCollabYjs(roomName) {
|
||||
return `collab:yjs:${roomName}`;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
keyCollabDocUpdates,
|
||||
keyCollabRoom,
|
||||
keyCollabRoomRaw,
|
||||
keyCollabRoomPattern,
|
||||
parseRoomName,
|
||||
keyCollabYjs,
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
function closeServerGracefully(server) {
|
||||
return new Promise((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function closeWssGracefully(wss) {
|
||||
return new Promise((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function createGracefulShutdown({
|
||||
server,
|
||||
wss,
|
||||
mq,
|
||||
redis,
|
||||
onDraining,
|
||||
timeoutMs = 10000
|
||||
}) {
|
||||
let shuttingDown = false;
|
||||
|
||||
return async function shutdown(signal) {
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
}
|
||||
shuttingDown = true;
|
||||
onDraining();
|
||||
console.log(`Received ${signal}, start graceful shutdown`);
|
||||
|
||||
const forceExitTimer = setTimeout(() => {
|
||||
console.error('Graceful shutdown timeout, force exiting');
|
||||
process.exit(1);
|
||||
}, timeoutMs);
|
||||
forceExitTimer.unref();
|
||||
|
||||
await Promise.all([
|
||||
closeWssGracefully(wss),
|
||||
closeServerGracefully(server)
|
||||
]);
|
||||
|
||||
try {
|
||||
await mq.close();
|
||||
} catch (err) {
|
||||
console.error('Failed to close MQ:', err);
|
||||
}
|
||||
|
||||
try {
|
||||
await redis.closeRedis();
|
||||
} catch (err) {
|
||||
console.error('Failed to close Redis:', err);
|
||||
}
|
||||
|
||||
clearTimeout(forceExitTimer);
|
||||
process.exit(0);
|
||||
};
|
||||
}
|
||||
|
||||
function registerShutdownSignals(shutdown) {
|
||||
process.on('SIGTERM', () => {
|
||||
void shutdown('SIGTERM');
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
void shutdown('SIGINT');
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createGracefulShutdown,
|
||||
registerShutdownSignals
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
const amqp = require('amqplib');
|
||||
const config = require('./config');
|
||||
const redis = require('./redis');
|
||||
|
||||
let rabbitConn = null;
|
||||
let rabbitChannel = null;
|
||||
|
||||
const shouldPublishRedis = () => {
|
||||
const mode = (config.dirtyDocMqType || 'redis').toLowerCase();
|
||||
return mode === 'redis' || mode === 'both';
|
||||
};
|
||||
|
||||
const shouldPublishRabbit = () => {
|
||||
const mode = (config.dirtyDocMqType || 'redis').toLowerCase();
|
||||
return mode === 'rabbitmq' || mode === 'rabbit' || mode === 'both';
|
||||
};
|
||||
|
||||
const getRabbitChannel = async () => {
|
||||
if (!config.rabbitmqUrl) {
|
||||
return null;
|
||||
}
|
||||
if (rabbitChannel) {
|
||||
return rabbitChannel;
|
||||
}
|
||||
rabbitConn = await amqp.connect(config.rabbitmqUrl);
|
||||
rabbitChannel = await rabbitConn.createChannel();
|
||||
return rabbitChannel;
|
||||
};
|
||||
|
||||
const publishRabbit = async (topic, payload) => {
|
||||
const channel = await getRabbitChannel();
|
||||
if (!channel) {
|
||||
return;
|
||||
}
|
||||
await channel.assertExchange(topic, 'topic', { durable: true });
|
||||
channel.publish(topic, '', Buffer.from(payload), {
|
||||
contentType: 'application/json'
|
||||
});
|
||||
};
|
||||
|
||||
const publishRedis = async (topic, payload) => {
|
||||
if (!redis.redisClient) {
|
||||
return;
|
||||
}
|
||||
await redis.redisClient.publish(topic, payload);
|
||||
};
|
||||
|
||||
const publishDirtyDoc = async (docId) => {
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
const payload = JSON.stringify({ doc_id: docId, ts: Date.now() });
|
||||
try {
|
||||
if (shouldPublishRedis()) {
|
||||
await publishRedis(config.dirtyDocTopic, payload);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to publish dirty doc ${docId} to redis:`, err.message);
|
||||
}
|
||||
try {
|
||||
if (shouldPublishRabbit()) {
|
||||
await publishRabbit(config.dirtyDocTopic, payload);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to publish dirty doc ${docId} to rabbitmq:`, err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const close = async () => {
|
||||
if (rabbitChannel) {
|
||||
await rabbitChannel.close();
|
||||
rabbitChannel = null;
|
||||
}
|
||||
if (rabbitConn) {
|
||||
await rabbitConn.close();
|
||||
rabbitConn = null;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
publishDirtyDoc,
|
||||
close
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
const Y = require('yjs');
|
||||
const redis = require('./redis');
|
||||
const mq = require('./mq');
|
||||
const config = require('./config');
|
||||
const { keyCollabDocUpdates } = require('./key');
|
||||
|
||||
const compactThreshold = parseInt(process.env.DOC_UPDATE_COMPACT_THRESHOLD, 10) || 1000;
|
||||
const dirtyNotifyThreshold = config.dirtyDocNotifyThreshold;
|
||||
|
||||
const updateCounts = new Map();
|
||||
const notifyCounts = new Map();
|
||||
const dirtyLogged = new Set();
|
||||
|
||||
const persistUpdate = async (docId, doc, update) => {
|
||||
if (!docId) {
|
||||
return;
|
||||
}
|
||||
const redisKey = keyCollabDocUpdates(docId);
|
||||
await redis.appendListBuffer(redisKey, update);
|
||||
await redis.addDirtyDoc(docId);
|
||||
await redis.updateRoomActiveTime(`doc-${docId}`);
|
||||
if (!dirtyLogged.has(docId)) {
|
||||
dirtyLogged.add(docId);
|
||||
console.log(`[collab] dirty marked docId=${docId} redisKey=${redisKey}`);
|
||||
}
|
||||
|
||||
const nextNotify = (notifyCounts.get(docId) || 0) + 1;
|
||||
if (nextNotify >= dirtyNotifyThreshold) {
|
||||
await mq.publishDirtyDoc(docId);
|
||||
notifyCounts.set(docId, 0);
|
||||
} else {
|
||||
notifyCounts.set(docId, nextNotify);
|
||||
}
|
||||
|
||||
const nextCount = (updateCounts.get(docId) || 0) + 1;
|
||||
if (nextCount >= compactThreshold) {
|
||||
const merged = Y.encodeStateAsUpdate(doc);
|
||||
await redis.replaceListBuffer(redisKey, merged);
|
||||
updateCounts.set(docId, 0);
|
||||
} else {
|
||||
updateCounts.set(docId, nextCount);
|
||||
}
|
||||
};
|
||||
|
||||
const attachPersistence = (doc, docId) => {
|
||||
if (doc._persistenceBound) {
|
||||
return;
|
||||
}
|
||||
doc._persistenceBound = true;
|
||||
doc.on('update', (update) => {
|
||||
void persistUpdate(docId, doc, update);
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = { persistUpdate, attachPersistence };
|
||||
@@ -0,0 +1,40 @@
|
||||
const { createClient } = require('redis');
|
||||
const config = require('../config');
|
||||
|
||||
let redisClient = null;
|
||||
|
||||
async function initRedis() {
|
||||
redisClient = createClient({
|
||||
socket: {
|
||||
host: config.redisHost,
|
||||
port: config.redisPort
|
||||
},
|
||||
password: config.redisPassword || undefined,
|
||||
database: config.redisDb
|
||||
});
|
||||
|
||||
redisClient.on('error', (err) => console.error('Redis Client Error', err));
|
||||
|
||||
try {
|
||||
await redisClient.connect();
|
||||
console.log(`Connected to Redis at ${config.redisHost}:${config.redisPort}`);
|
||||
} catch (err) {
|
||||
console.error('Failed to connect to Redis:', err);
|
||||
}
|
||||
}
|
||||
|
||||
function initYRedis() {
|
||||
console.log('Yjs Redis persistence initialized (custom loader)');
|
||||
}
|
||||
|
||||
async function closeRedis() {
|
||||
if (redisClient) {
|
||||
await redisClient.quit();
|
||||
}
|
||||
}
|
||||
|
||||
function getClient() {
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
module.exports = { initRedis, initYRedis, closeRedis, getClient };
|
||||
@@ -0,0 +1,87 @@
|
||||
const { commandOptions } = require('redis');
|
||||
const { getClient } = require('./client');
|
||||
const config = require('../config');
|
||||
|
||||
async function getListBuffers(key) {
|
||||
const client = getClient();
|
||||
if (!client) return [];
|
||||
try {
|
||||
const items = await client.lRange(commandOptions({ returnBuffers: true }), key, 0, -1);
|
||||
return items || [];
|
||||
} catch (err) {
|
||||
console.error(`Failed to lrange buffer for ${key}:`, err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function appendListBuffer(key, buffer) {
|
||||
const client = getClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
const payload = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||
await client.rPush(key, payload);
|
||||
if (config.docUpdatesTTL > 0) {
|
||||
await client.expire(key, config.docUpdatesTTL);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to rpush buffer for ${key}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceListBuffer(key, buffer) {
|
||||
const client = getClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
const payload = Buffer.isBuffer(buffer) ? buffer : Buffer.from(buffer);
|
||||
const pipeline = client.multi();
|
||||
pipeline.del(key);
|
||||
pipeline.rPush(key, payload);
|
||||
if (config.docUpdatesTTL > 0) {
|
||||
pipeline.expire(key, config.docUpdatesTTL);
|
||||
}
|
||||
await pipeline.exec();
|
||||
} catch (err) {
|
||||
console.error(`Failed to replace list buffer for ${key}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function addDirtyDoc(docId) {
|
||||
const client = getClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.sAdd(config.dirtyDocSetKey, docId);
|
||||
} catch (err) {
|
||||
console.error(`Failed to add dirty doc ${docId}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function removeDirtyDoc(docId) {
|
||||
const client = getClient();
|
||||
if (!client) return;
|
||||
try {
|
||||
await client.sRem(config.dirtyDocSetKey, docId);
|
||||
} catch (err) {
|
||||
console.error(`Failed to remove dirty doc ${docId}:`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function isDirtyDoc(docId) {
|
||||
const client = getClient();
|
||||
if (!client) return false;
|
||||
try {
|
||||
const exists = await client.sIsMember(config.dirtyDocSetKey, docId);
|
||||
return !!exists;
|
||||
} catch (err) {
|
||||
console.error(`Failed to check dirty doc ${docId}:`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getListBuffers,
|
||||
appendListBuffer,
|
||||
replaceListBuffer,
|
||||
addDirtyDoc,
|
||||
removeDirtyDoc,
|
||||
isDirtyDoc,
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
const { initRedis, initYRedis, closeRedis, getClient } = require('./client');
|
||||
const { updateRoomActiveTime, getActiveRooms, checkRoomExists } = require('./room');
|
||||
const { getListBuffers, appendListBuffer, replaceListBuffer, addDirtyDoc, removeDirtyDoc, isDirtyDoc } = require('./document');
|
||||
|
||||
module.exports = {
|
||||
initRedis,
|
||||
initYRedis,
|
||||
closeRedis,
|
||||
updateRoomActiveTime,
|
||||
getActiveRooms,
|
||||
checkRoomExists,
|
||||
getListBuffers,
|
||||
appendListBuffer,
|
||||
replaceListBuffer,
|
||||
addDirtyDoc,
|
||||
removeDirtyDoc,
|
||||
isDirtyDoc,
|
||||
get redisClient() {
|
||||
return getClient();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
const { getClient } = require('./client');
|
||||
const { keyCollabRoomRaw, keyCollabRoomPattern, parseRoomName, keyCollabYjs } = require('../key');
|
||||
|
||||
async function updateRoomActiveTime(roomName) {
|
||||
const client = getClient();
|
||||
if (!client) return;
|
||||
|
||||
try {
|
||||
const key = keyCollabRoomRaw(roomName);
|
||||
await client.set(key, Date.now().toString(), { EX: 3600 * 24 });
|
||||
} catch (err) {
|
||||
console.error('Failed to update room active time:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function getActiveRooms() {
|
||||
const client = getClient();
|
||||
if (!client) return [];
|
||||
|
||||
try {
|
||||
const keys = await client.keys(keyCollabRoomPattern());
|
||||
return keys.map(k => parseRoomName(k));
|
||||
} catch (err) {
|
||||
console.error('Failed to get active rooms:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function checkRoomExists(roomName) {
|
||||
const client = getClient();
|
||||
if (!client) return false;
|
||||
|
||||
try {
|
||||
const yjsKey = keyCollabYjs(roomName);
|
||||
const exists = await client.exists(yjsKey);
|
||||
return exists;
|
||||
} catch (err) {
|
||||
console.error(`Failed to check room ${roomName}:`, err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { updateRoomActiveTime, getActiveRooms, checkRoomExists };
|
||||
@@ -0,0 +1,38 @@
|
||||
const Y = require('yjs');
|
||||
const redis = require('./redis');
|
||||
const grpc = require('./grpc');
|
||||
|
||||
async function checkAndInitRoom(roomName) {
|
||||
if (!redis.redisClient || !grpc.documentServiceClient) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const exists = await redis.checkRoomExists(roomName);
|
||||
if (exists) {
|
||||
console.log(`Room ${roomName} already has data in Redis, skipping init`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const docId = roomName.replace(/^doc-/, '');
|
||||
console.log(`Room ${roomName} is empty, fetching content from backend for doc ${docId}...`);
|
||||
|
||||
const content = await grpc.getDocumentContent(docId);
|
||||
if (content) {
|
||||
console.log(`Got content from backend, length: ${content.length}`);
|
||||
const ydoc = new Y.Doc();
|
||||
const ytext = ydoc.getText('content');
|
||||
ytext.insert(0, content);
|
||||
return ydoc;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (err) {
|
||||
console.error(`Failed to check/init room ${roomName}:`, err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkAndInitRoom
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
const WebSocket = require('ws');
|
||||
|
||||
function bindWebSocketGateway({ server, redis, setupWSConnection }) {
|
||||
const wss = new WebSocket.Server({ server });
|
||||
|
||||
wss.on('connection', async (conn, req) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const roomName = url.pathname.slice(1);
|
||||
|
||||
if (roomName) {
|
||||
redis.updateRoomActiveTime(roomName);
|
||||
}
|
||||
|
||||
try {
|
||||
await setupWSConnection(conn, req, {
|
||||
gc: true
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to setup WS connection:', err);
|
||||
conn.close();
|
||||
}
|
||||
});
|
||||
|
||||
return wss;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
bindWebSocketGateway
|
||||
};
|
||||
+222
@@ -0,0 +1,222 @@
|
||||
const Y = require('yjs');
|
||||
const syncProtocol = require('y-protocols/dist/sync.cjs');
|
||||
const awarenessProtocol = require('y-protocols/dist/awareness.cjs');
|
||||
|
||||
const encoding = require('lib0/dist/encoding.cjs');
|
||||
const decoding = require('lib0/dist/decoding.cjs');
|
||||
const { loadDoc } = require('./doc-loader');
|
||||
const { attachPersistence } = require('./persistence');
|
||||
|
||||
const wsReadyStateConnecting = 0;
|
||||
const wsReadyStateOpen = 1;
|
||||
const wsReadyStateClosed = 3;
|
||||
|
||||
const gcEnabled = process.env.GC !== 'false' && process.env.GC !== '0';
|
||||
|
||||
const messageSync = 0;
|
||||
const messageAwareness = 1;
|
||||
|
||||
const docs = new Map();
|
||||
const docPromises = new Map();
|
||||
|
||||
exports.docs = docs;
|
||||
|
||||
const extractDocId = (docName) => {
|
||||
if (!docName) {
|
||||
return '';
|
||||
}
|
||||
if (docName.startsWith('doc-')) {
|
||||
return docName.slice(4);
|
||||
}
|
||||
return docName;
|
||||
};
|
||||
|
||||
// ── WebSocket send / close ──────────────────────────────────
|
||||
|
||||
const send = (doc, conn, m) => {
|
||||
if (conn.readyState !== wsReadyStateConnecting && conn.readyState !== wsReadyStateOpen) {
|
||||
closeConn(doc, conn);
|
||||
}
|
||||
try {
|
||||
conn.send(m, err => {
|
||||
if (err != null) {
|
||||
closeConn(doc, conn);
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
closeConn(doc, conn);
|
||||
}
|
||||
};
|
||||
|
||||
const closeConn = (doc, conn) => {
|
||||
if (doc.conns.has(conn)) {
|
||||
const controlledIds = doc.conns.get(conn);
|
||||
doc.conns.delete(conn);
|
||||
awarenessProtocol.removeAwarenessStates(doc.awareness, Array.from(controlledIds), null);
|
||||
if (doc.conns.size === 0) {
|
||||
docs.delete(doc.name);
|
||||
doc.destroy();
|
||||
}
|
||||
}
|
||||
conn.close();
|
||||
};
|
||||
|
||||
// ── WSSharedDoc ─────────────────────────────────────────────
|
||||
|
||||
const updateHandler = (update, origin, doc) => {
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, messageSync);
|
||||
syncProtocol.writeUpdate(encoder, update);
|
||||
const message = encoding.toUint8Array(encoder);
|
||||
doc.conns.forEach((_, conn) => send(doc, conn, message));
|
||||
};
|
||||
|
||||
class WSSharedDoc extends Y.Doc {
|
||||
constructor(name) {
|
||||
super({ gc: gcEnabled });
|
||||
this.name = name;
|
||||
this.conns = new Map();
|
||||
this.awareness = new awarenessProtocol.Awareness(this);
|
||||
this.awareness.setLocalState(null);
|
||||
|
||||
const awarenessChangeHandler = ({ added, updated, removed }, conn) => {
|
||||
const changedClients = added.concat(updated, removed);
|
||||
if (conn !== null) {
|
||||
const connControlledIDs = this.conns.get(conn);
|
||||
if (connControlledIDs !== undefined) {
|
||||
added.forEach(clientID => { connControlledIDs.add(clientID); });
|
||||
removed.forEach(clientID => { connControlledIDs.delete(clientID); });
|
||||
}
|
||||
}
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, messageAwareness);
|
||||
encoding.writeVarUint8Array(encoder, awarenessProtocol.encodeAwarenessUpdate(this.awareness, changedClients));
|
||||
const buff = encoding.toUint8Array(encoder);
|
||||
this.conns.forEach((_, c) => { send(this, c, buff); });
|
||||
};
|
||||
this.awareness.on('update', awarenessChangeHandler);
|
||||
this.on('update', updateHandler);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Document lifecycle ──────────────────────────────────────
|
||||
|
||||
const createYDoc = async (docname, gc = true) => {
|
||||
const doc = new WSSharedDoc(docname);
|
||||
doc.gc = gc;
|
||||
|
||||
const docId = extractDocId(docname);
|
||||
try {
|
||||
const loadedDoc = await loadDoc(docId);
|
||||
const update = Y.encodeStateAsUpdate(loadedDoc);
|
||||
if (update && update.length > 0) {
|
||||
Y.applyUpdate(doc, update);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to load doc ${docId}:`, err.message);
|
||||
}
|
||||
|
||||
attachPersistence(doc, docId);
|
||||
return doc;
|
||||
};
|
||||
|
||||
const getYDoc = async (docname, gc = true) => {
|
||||
if (docPromises.has(docname)) {
|
||||
console.log(`[ws-utils] returning existing promise docname=${docname}`);
|
||||
return docPromises.get(docname);
|
||||
}
|
||||
console.log(`[ws-utils] creating new doc docname=${docname}`);
|
||||
const createPromise = createYDoc(docname, gc)
|
||||
.finally(() => { docPromises.delete(docname); });
|
||||
docPromises.set(docname, createPromise);
|
||||
return createPromise;
|
||||
};
|
||||
|
||||
// ── Message handler ─────────────────────────────────────────
|
||||
|
||||
const messageListener = (conn, doc, message) => {
|
||||
try {
|
||||
const encoder = encoding.createEncoder();
|
||||
const decoder = decoding.createDecoder(message);
|
||||
const messageType = decoding.readVarUint(decoder);
|
||||
switch (messageType) {
|
||||
case messageSync:
|
||||
encoding.writeVarUint(encoder, messageSync);
|
||||
syncProtocol.readSyncMessage(decoder, encoder, doc, conn);
|
||||
if (encoding.length(encoder) > 1) {
|
||||
send(doc, conn, encoding.toUint8Array(encoder));
|
||||
}
|
||||
break;
|
||||
case messageAwareness: {
|
||||
awarenessProtocol.applyAwarenessUpdate(doc.awareness, decoding.readVarUint8Array(decoder), conn);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
doc.emit('error', [err]);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Connection setup ────────────────────────────────────────
|
||||
|
||||
exports.setupWSConnection = async (conn, req, { docName = req.url.slice(1).split('?')[0], gc = true } = {}) => {
|
||||
const pendingMessages = [];
|
||||
let ready = false;
|
||||
let closed = false;
|
||||
|
||||
let doc = null;
|
||||
|
||||
conn.on('message', message => {
|
||||
if (!ready) {
|
||||
pendingMessages.push(message);
|
||||
return;
|
||||
}
|
||||
if (doc) {
|
||||
messageListener(conn, doc, new Uint8Array(message));
|
||||
}
|
||||
});
|
||||
conn.on('close', () => {
|
||||
closed = true;
|
||||
if (ready) {
|
||||
closeConn(doc, conn);
|
||||
}
|
||||
});
|
||||
conn.on('error', () => {
|
||||
closed = true;
|
||||
if (ready) {
|
||||
closeConn(doc, conn);
|
||||
}
|
||||
});
|
||||
|
||||
doc = await getYDoc(docName, gc);
|
||||
if (closed || conn.readyState === wsReadyStateClosed) {
|
||||
return;
|
||||
}
|
||||
|
||||
doc.conns.set(conn, new Set());
|
||||
ready = true;
|
||||
|
||||
for (const message of pendingMessages) {
|
||||
messageListener(conn, doc, new Uint8Array(message));
|
||||
}
|
||||
pendingMessages.length = 0;
|
||||
|
||||
const encoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(encoder, messageSync);
|
||||
syncProtocol.writeSyncStep1(encoder, doc);
|
||||
send(doc, conn, encoding.toUint8Array(encoder));
|
||||
|
||||
const awarenessStates = doc.awareness.getStates();
|
||||
if (awarenessStates.size > 0) {
|
||||
const awarenessEncoder = encoding.createEncoder();
|
||||
encoding.writeVarUint(awarenessEncoder, messageAwareness);
|
||||
encoding.writeVarUint8Array(
|
||||
awarenessEncoder,
|
||||
awarenessProtocol.encodeAwarenessUpdate(doc.awareness, Array.from(awarenessStates.keys()))
|
||||
);
|
||||
send(doc, conn, encoding.toUint8Array(awarenessEncoder));
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user