texlite 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +661 -0
- package/README.md +403 -0
- package/README.zh-CN.md +391 -0
- package/dist/client/assets/GitDialog-BHHxSBn3.js +3 -0
- package/dist/client/assets/HistoryDialog-Dlv57tYf.js +5 -0
- package/dist/client/assets/LatexEditor-B-0VRUPv.js +55 -0
- package/dist/client/assets/PdfPreview-617TWFJN.js +1 -0
- package/dist/client/assets/ProjectNavigationDialogs-Cpt2LVEt.js +1 -0
- package/dist/client/assets/SystemMetricsDialog-fkFHVVzJ.js +1 -0
- package/dist/client/assets/editor-JxT6uSYX.js +14 -0
- package/dist/client/assets/index-Bc5CmFMl.js +15 -0
- package/dist/client/assets/index-CMqYAbDx.css +1 -0
- package/dist/client/assets/pdf-DPnwNQHb.js +55 -0
- package/dist/client/assets/pdf.worker.min-CHFwMXne.mjs +29 -0
- package/dist/client/assets/plus-DzbLkp6l.js +1 -0
- package/dist/client/assets/react-CsO_xvbl.js +49 -0
- package/dist/client/assets/spellCheck-R6fUM9oA.js +49783 -0
- package/dist/client/index.html +20 -0
- package/dist/client/logo.svg +4 -0
- package/dist/server/anchors.js +95 -0
- package/dist/server/app.js +1624 -0
- package/dist/server/archive.js +26 -0
- package/dist/server/auth.js +42 -0
- package/dist/server/cli.js +318 -0
- package/dist/server/collaboration.js +635 -0
- package/dist/server/compileArtifacts.js +86 -0
- package/dist/server/compileDiagnostics.js +227 -0
- package/dist/server/compiler.js +514 -0
- package/dist/server/config.js +336 -0
- package/dist/server/db.js +280 -0
- package/dist/server/environment.js +57 -0
- package/dist/server/files.js +121 -0
- package/dist/server/git.js +414 -0
- package/dist/server/history.js +379 -0
- package/dist/server/http.js +7 -0
- package/dist/server/index.js +75 -0
- package/dist/server/latexCompletion.js +296 -0
- package/dist/server/metrics.js +34 -0
- package/dist/server/pm2.js +234 -0
- package/dist/server/projectOutline.js +101 -0
- package/dist/server/projectSearch.js +112 -0
- package/dist/server/projects.js +29 -0
- package/dist/server/routes/compile.js +343 -0
- package/dist/server/runtimePaths.js +42 -0
- package/dist/server/security.js +29 -0
- package/dist/server/spellCheck.js +195 -0
- package/dist/server/synctex.js +56 -0
- package/dist/server/zip.js +185 -0
- package/package.json +110 -0
- package/preview.png +0 -0
- package/texlite.config.example.json +33 -0
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { performance } from "node:perf_hooks";
|
|
5
|
+
import { WebSocket } from "ws";
|
|
6
|
+
import * as Y from "yjs";
|
|
7
|
+
import * as encoding from "lib0/encoding";
|
|
8
|
+
import * as decoding from "lib0/decoding";
|
|
9
|
+
import * as syncProtocol from "y-protocols/sync";
|
|
10
|
+
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, modifyAwarenessUpdate, removeAwarenessStates } from "y-protocols/awareness";
|
|
11
|
+
import { listProjectFiles, outputRoot, resolveSourcePath, safeRelativePath } from "./files.js";
|
|
12
|
+
import { accessibleProject, canEdit } from "./projects.js";
|
|
13
|
+
import { reanchorFileComments } from "./anchors.js";
|
|
14
|
+
const MESSAGE_SYNC = 0;
|
|
15
|
+
const MESSAGE_AWARENESS = 1;
|
|
16
|
+
const MESSAGE_QUERY_AWARENESS = 3;
|
|
17
|
+
const MESSAGE_FLUSH = 4;
|
|
18
|
+
const MESSAGE_PROTOCOL = 5;
|
|
19
|
+
const SOURCE_PREFIX = "source:";
|
|
20
|
+
const MAX_PROJECT_SESSIONS = 10;
|
|
21
|
+
const MAX_COLLABORATIVE_FILE_BYTES = 5 * 1024 * 1024;
|
|
22
|
+
const DISK_ORIGIN = Symbol("disk");
|
|
23
|
+
const HTTP_ORIGIN = Symbol("http");
|
|
24
|
+
const META_ORIGIN = Symbol("meta");
|
|
25
|
+
const SAVE_DELAY_MS = 750;
|
|
26
|
+
const STATE_SAVE_DELAY_MS = 750;
|
|
27
|
+
const ROOM_IDLE_MS = 30_000;
|
|
28
|
+
const COLORS = [
|
|
29
|
+
["#1677c8", "#1677c833"], ["#d65745", "#d6574533"], ["#16866a", "#16866a33"],
|
|
30
|
+
["#9a58b5", "#9a58b533"], ["#d27b18", "#d27b1833"], ["#3f7d20", "#3f7d2033"],
|
|
31
|
+
["#be3e7b", "#be3e7b33"], ["#4964c6", "#4964c633"], ["#8a6a14", "#8a6a1433"],
|
|
32
|
+
["#087f8c", "#087f8c33"]
|
|
33
|
+
];
|
|
34
|
+
export class CollaborationService {
|
|
35
|
+
config;
|
|
36
|
+
db;
|
|
37
|
+
onPersist;
|
|
38
|
+
rooms = new Map();
|
|
39
|
+
constructor(config, db, onPersist) {
|
|
40
|
+
this.config = config;
|
|
41
|
+
this.db = db;
|
|
42
|
+
this.onPersist = onPersist;
|
|
43
|
+
}
|
|
44
|
+
connect(socket, projectId, user) {
|
|
45
|
+
const project = accessibleProject(this.db, projectId, user);
|
|
46
|
+
if (!project) {
|
|
47
|
+
socket.close(1008, "Project access denied");
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const room = this.getRoom(projectId);
|
|
51
|
+
if (room.connections.size >= MAX_PROJECT_SESSIONS) {
|
|
52
|
+
socket.close(1013, "Project collaboration room is full");
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (room.cleanupTimer) {
|
|
56
|
+
clearTimeout(room.cleanupTimer);
|
|
57
|
+
room.cleanupTimer = null;
|
|
58
|
+
}
|
|
59
|
+
const connection = {
|
|
60
|
+
socket, user, awarenessClientId: null, protocolVerified: false, protocolTimer: null
|
|
61
|
+
};
|
|
62
|
+
room.connections.add(connection);
|
|
63
|
+
socket.binaryType = "arraybuffer";
|
|
64
|
+
socket.on("message", (data) => {
|
|
65
|
+
try {
|
|
66
|
+
this.handleMessage(room, connection, rawData(data));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
socket.close(1003, "Invalid collaboration message");
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
socket.on("close", () => this.disconnect(room, connection));
|
|
73
|
+
socket.on("error", () => this.disconnect(room, connection));
|
|
74
|
+
connection.protocolTimer = setTimeout(() => {
|
|
75
|
+
if (!connection.protocolVerified)
|
|
76
|
+
socket.close(4001, "Reload required");
|
|
77
|
+
}, 10_000);
|
|
78
|
+
send(socket, protocolMessage(room.epoch));
|
|
79
|
+
}
|
|
80
|
+
flushProject(projectId) {
|
|
81
|
+
const room = this.rooms.get(projectId);
|
|
82
|
+
if (room)
|
|
83
|
+
this.flushRoom(room);
|
|
84
|
+
}
|
|
85
|
+
stats() {
|
|
86
|
+
let sessions = 0;
|
|
87
|
+
let dirtyFiles = 0;
|
|
88
|
+
for (const room of this.rooms.values()) {
|
|
89
|
+
sessions += room.connections.size;
|
|
90
|
+
dirtyFiles += room.dirtyPaths.size;
|
|
91
|
+
}
|
|
92
|
+
return { rooms: this.rooms.size, sessions, dirtyFiles };
|
|
93
|
+
}
|
|
94
|
+
updateFile(projectId, filePathInput, content, userId) {
|
|
95
|
+
const room = this.rooms.get(projectId);
|
|
96
|
+
if (!room || !isCollaborativeTextFile(filePathInput))
|
|
97
|
+
return;
|
|
98
|
+
const filePath = safeRelativePath(filePathInput);
|
|
99
|
+
room.allowedPaths.add(filePath);
|
|
100
|
+
room.persistedContent.set(filePath, content);
|
|
101
|
+
room.doc.transact(() => replaceText(this.trackedText(room, filePath), content), HTTP_ORIGIN);
|
|
102
|
+
room.lastModifiedUserId = userId;
|
|
103
|
+
this.bumpFiles(room, { kind: "update", path: filePath });
|
|
104
|
+
}
|
|
105
|
+
movePath(projectId, sourceInput, destinationInput, userId) {
|
|
106
|
+
const room = this.rooms.get(projectId);
|
|
107
|
+
if (!room)
|
|
108
|
+
return;
|
|
109
|
+
this.flushRoom(room);
|
|
110
|
+
const source = safeRelativePath(sourceInput);
|
|
111
|
+
const destination = safeRelativePath(destinationInput);
|
|
112
|
+
const moved = [...room.allowedPaths].filter((filePath) => filePath === source || filePath.startsWith(`${source}/`));
|
|
113
|
+
room.doc.transact(() => {
|
|
114
|
+
for (const oldPath of moved) {
|
|
115
|
+
const nextPath = oldPath === source ? destination : `${destination}${oldPath.slice(source.length)}`;
|
|
116
|
+
const content = this.trackedText(room, oldPath).toString();
|
|
117
|
+
replaceText(this.trackedText(room, nextPath), content);
|
|
118
|
+
replaceText(this.trackedText(room, oldPath), "");
|
|
119
|
+
room.allowedPaths.delete(oldPath);
|
|
120
|
+
room.allowedPaths.add(nextPath);
|
|
121
|
+
room.persistedContent.delete(oldPath);
|
|
122
|
+
room.persistedContent.set(nextPath, content);
|
|
123
|
+
}
|
|
124
|
+
}, HTTP_ORIGIN);
|
|
125
|
+
room.lastModifiedUserId = userId;
|
|
126
|
+
this.bumpFiles(room, { kind: "move", source, destination });
|
|
127
|
+
}
|
|
128
|
+
removePath(projectId, filePathInput) {
|
|
129
|
+
const room = this.rooms.get(projectId);
|
|
130
|
+
if (!room)
|
|
131
|
+
return;
|
|
132
|
+
const filePath = safeRelativePath(filePathInput);
|
|
133
|
+
const removed = [...room.allowedPaths].filter((candidate) => candidate === filePath || candidate.startsWith(`${filePath}/`));
|
|
134
|
+
room.doc.transact(() => {
|
|
135
|
+
for (const candidate of removed) {
|
|
136
|
+
replaceText(this.trackedText(room, candidate), "");
|
|
137
|
+
room.allowedPaths.delete(candidate);
|
|
138
|
+
room.persistedContent.delete(candidate);
|
|
139
|
+
room.dirtyPaths.delete(candidate);
|
|
140
|
+
}
|
|
141
|
+
}, HTTP_ORIGIN);
|
|
142
|
+
this.bumpFiles(room, { kind: "delete", path: filePath });
|
|
143
|
+
}
|
|
144
|
+
signalComments(projectId) {
|
|
145
|
+
const room = this.rooms.get(projectId);
|
|
146
|
+
if (!room)
|
|
147
|
+
return;
|
|
148
|
+
room.doc.transact(() => room.meta.set("commentsRevision", randomUUID()), META_ORIGIN);
|
|
149
|
+
}
|
|
150
|
+
signalDictionary(projectId) {
|
|
151
|
+
const room = this.rooms.get(projectId);
|
|
152
|
+
if (!room)
|
|
153
|
+
return;
|
|
154
|
+
room.doc.transact(() => room.meta.set("dictionaryRevision", randomUUID()), META_ORIGIN);
|
|
155
|
+
}
|
|
156
|
+
signalCompileState(projectId, state) {
|
|
157
|
+
const room = this.rooms.get(projectId);
|
|
158
|
+
if (!room)
|
|
159
|
+
return;
|
|
160
|
+
const current = room.meta.get("compileStates");
|
|
161
|
+
const states = current && typeof current === "object" && !Array.isArray(current)
|
|
162
|
+
? { ...current }
|
|
163
|
+
: {};
|
|
164
|
+
states[state.mainFile] = state;
|
|
165
|
+
const retained = Object.fromEntries(Object.entries(states)
|
|
166
|
+
.sort((left, right) => Date.parse(right[1].updatedAt) - Date.parse(left[1].updatedAt))
|
|
167
|
+
.slice(0, 20));
|
|
168
|
+
room.doc.transact(() => room.meta.set("compileStates", retained), META_ORIGIN);
|
|
169
|
+
}
|
|
170
|
+
closeProject(projectId) {
|
|
171
|
+
const room = this.rooms.get(projectId);
|
|
172
|
+
if (!room)
|
|
173
|
+
return;
|
|
174
|
+
for (const connection of room.connections)
|
|
175
|
+
connection.socket.close(1008, "Project closed");
|
|
176
|
+
this.destroyRoom(room);
|
|
177
|
+
}
|
|
178
|
+
resetProject(projectId) {
|
|
179
|
+
const room = this.rooms.get(projectId);
|
|
180
|
+
if (room) {
|
|
181
|
+
for (const connection of room.connections)
|
|
182
|
+
connection.socket.close(4001, "Project version changed; reload required");
|
|
183
|
+
this.destroyRoom(room, false);
|
|
184
|
+
}
|
|
185
|
+
fs.rmSync(collaborationStatePath(this.config, projectId), { force: true });
|
|
186
|
+
fs.rmSync(collaborationEpochPath(this.config, projectId), { force: true });
|
|
187
|
+
}
|
|
188
|
+
destroy() {
|
|
189
|
+
for (const room of [...this.rooms.values()])
|
|
190
|
+
this.destroyRoom(room);
|
|
191
|
+
}
|
|
192
|
+
getRoom(projectId) {
|
|
193
|
+
const existing = this.rooms.get(projectId);
|
|
194
|
+
if (existing)
|
|
195
|
+
return existing;
|
|
196
|
+
const doc = new Y.Doc();
|
|
197
|
+
const room = {
|
|
198
|
+
projectId,
|
|
199
|
+
doc,
|
|
200
|
+
awareness: new Awareness(doc),
|
|
201
|
+
meta: doc.getMap("texlite:meta"),
|
|
202
|
+
connections: new Set(),
|
|
203
|
+
awarenessOwners: new Map(),
|
|
204
|
+
allowedPaths: new Set(),
|
|
205
|
+
persistedContent: new Map(),
|
|
206
|
+
dirtyPaths: new Set(),
|
|
207
|
+
textObservers: new Map(),
|
|
208
|
+
saveTimer: null,
|
|
209
|
+
stateSaveTimer: null,
|
|
210
|
+
cleanupTimer: null,
|
|
211
|
+
lastModifiedUserId: null,
|
|
212
|
+
epoch: "",
|
|
213
|
+
persistedRevision: 0,
|
|
214
|
+
persistedAt: new Date().toISOString()
|
|
215
|
+
};
|
|
216
|
+
room.awareness.setLocalState(null);
|
|
217
|
+
const persistedState = collaborationStatePath(this.config, projectId);
|
|
218
|
+
let recoveredState = false;
|
|
219
|
+
let recoveredDirty = false;
|
|
220
|
+
if (fs.existsSync(persistedState)) {
|
|
221
|
+
try {
|
|
222
|
+
Y.applyUpdate(doc, fs.readFileSync(persistedState), DISK_ORIGIN);
|
|
223
|
+
recoveredState = true;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// A corrupt state file is ignored; the source files remain authoritative.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
room.epoch = collaborationEpoch(this.config, projectId, recoveredState);
|
|
230
|
+
const diskPaths = new Set();
|
|
231
|
+
doc.transact(() => {
|
|
232
|
+
for (const entry of listProjectFiles(this.config, projectId)) {
|
|
233
|
+
if (entry.type !== "file" || !isCollaborativeTextFile(entry.path) || (entry.size ?? 0) > MAX_COLLABORATIVE_FILE_BYTES)
|
|
234
|
+
continue;
|
|
235
|
+
const content = fs.readFileSync(resolveSourcePath(this.config, projectId, entry.path), "utf8");
|
|
236
|
+
diskPaths.add(entry.path);
|
|
237
|
+
room.allowedPaths.add(entry.path);
|
|
238
|
+
room.persistedContent.set(entry.path, content);
|
|
239
|
+
const name = typeName(entry.path);
|
|
240
|
+
// Yjs decodes top-level shared types lazily as AbstractType instances;
|
|
241
|
+
// the stable source: namespace identifies text more reliably than
|
|
242
|
+
// instanceof before getText() materializes the public type.
|
|
243
|
+
const hasRecoveredText = recoveredState && doc.share.has(name);
|
|
244
|
+
const text = this.trackedText(room, entry.path);
|
|
245
|
+
if (hasRecoveredText && text.toString() !== content) {
|
|
246
|
+
room.dirtyPaths.add(entry.path);
|
|
247
|
+
recoveredDirty = true;
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
replaceText(text, content);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
for (const name of doc.share.keys()) {
|
|
254
|
+
if (!name.startsWith(SOURCE_PREFIX))
|
|
255
|
+
continue;
|
|
256
|
+
const filePath = name.slice(SOURCE_PREFIX.length);
|
|
257
|
+
if (!diskPaths.has(filePath))
|
|
258
|
+
replaceText(doc.getText(name), "");
|
|
259
|
+
}
|
|
260
|
+
}, DISK_ORIGIN);
|
|
261
|
+
doc.on("update", (update, origin) => {
|
|
262
|
+
this.broadcast(room, syncUpdateMessage(update), origin instanceof Object && "socket" in origin ? origin : null);
|
|
263
|
+
this.scheduleStateSave(room);
|
|
264
|
+
if (origin !== DISK_ORIGIN && origin !== HTTP_ORIGIN && origin !== META_ORIGIN) {
|
|
265
|
+
if (origin && typeof origin === "object" && "user" in origin) {
|
|
266
|
+
room.lastModifiedUserId = origin.user.id;
|
|
267
|
+
}
|
|
268
|
+
this.scheduleSave(room);
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
room.awareness.on("update", ({ added, updated, removed }, origin) => {
|
|
272
|
+
const changed = [...added, ...updated, ...removed];
|
|
273
|
+
if (!changed.length)
|
|
274
|
+
return;
|
|
275
|
+
this.broadcast(room, awarenessMessage(encodeAwarenessUpdate(room.awareness, changed)), null);
|
|
276
|
+
if (origin && typeof origin === "object" && "socket" in origin) {
|
|
277
|
+
const connection = origin;
|
|
278
|
+
for (const clientId of [...added, ...updated])
|
|
279
|
+
room.awarenessOwners.set(clientId, connection);
|
|
280
|
+
for (const clientId of removed) {
|
|
281
|
+
if (room.awarenessOwners.get(clientId) === connection)
|
|
282
|
+
room.awarenessOwners.delete(clientId);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
this.rooms.set(projectId, room);
|
|
287
|
+
if (recoveredDirty)
|
|
288
|
+
this.flushRoom(room);
|
|
289
|
+
else
|
|
290
|
+
this.persistRoomState(room);
|
|
291
|
+
return room;
|
|
292
|
+
}
|
|
293
|
+
trackedText(room, filePath) {
|
|
294
|
+
const text = room.doc.getText(typeName(filePath));
|
|
295
|
+
if (room.textObservers.has(filePath))
|
|
296
|
+
return text;
|
|
297
|
+
const observer = (_event, transaction) => {
|
|
298
|
+
if (transaction.origin === DISK_ORIGIN || transaction.origin === HTTP_ORIGIN || transaction.origin === META_ORIGIN)
|
|
299
|
+
return;
|
|
300
|
+
if (!room.allowedPaths.has(filePath)) {
|
|
301
|
+
// A client can still have the old Y.Text bound briefly after another
|
|
302
|
+
// session deletes or moves a file. Correct any late update immediately;
|
|
303
|
+
// otherwise the editor would appear writable even though the content
|
|
304
|
+
// can no longer be persisted to a source path.
|
|
305
|
+
room.doc.transact(() => replaceText(text, ""), DISK_ORIGIN);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
room.dirtyPaths.add(filePath);
|
|
309
|
+
};
|
|
310
|
+
text.observe(observer);
|
|
311
|
+
room.textObservers.set(filePath, observer);
|
|
312
|
+
return text;
|
|
313
|
+
}
|
|
314
|
+
handleMessage(room, connection, bytes) {
|
|
315
|
+
const current = accessibleProject(this.db, room.projectId, connection.user);
|
|
316
|
+
if (!current) {
|
|
317
|
+
connection.socket.close(1008, "Project access revoked");
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
const decoder = decoding.createDecoder(bytes);
|
|
321
|
+
const messageType = decoding.readVarUint(decoder);
|
|
322
|
+
if (messageType === MESSAGE_PROTOCOL) {
|
|
323
|
+
const epoch = decoding.readVarString(decoder);
|
|
324
|
+
if (epoch !== room.epoch) {
|
|
325
|
+
connection.socket.close(4001, "Collaboration state changed; reload required");
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (!connection.protocolVerified) {
|
|
329
|
+
connection.protocolVerified = true;
|
|
330
|
+
if (connection.protocolTimer)
|
|
331
|
+
clearTimeout(connection.protocolTimer);
|
|
332
|
+
connection.protocolTimer = null;
|
|
333
|
+
this.sendSyncStep1(room, connection.socket);
|
|
334
|
+
this.sendAwareness(room, connection.socket);
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (!connection.protocolVerified)
|
|
339
|
+
return;
|
|
340
|
+
if (messageType === MESSAGE_SYNC) {
|
|
341
|
+
const encoder = encoding.createEncoder();
|
|
342
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
343
|
+
const syncType = decoding.readVarUint(decoder);
|
|
344
|
+
if (syncType === syncProtocol.messageYjsSyncStep1) {
|
|
345
|
+
syncProtocol.readSyncStep1(decoder, encoder, room.doc);
|
|
346
|
+
}
|
|
347
|
+
else if (canEdit(current)) {
|
|
348
|
+
if (syncType === syncProtocol.messageYjsSyncStep2)
|
|
349
|
+
syncProtocol.readSyncStep2(decoder, room.doc, connection);
|
|
350
|
+
else if (syncType === syncProtocol.messageYjsUpdate)
|
|
351
|
+
syncProtocol.readUpdate(decoder, room.doc, connection);
|
|
352
|
+
}
|
|
353
|
+
if (encoding.length(encoder) > 1)
|
|
354
|
+
send(connection.socket, encoding.toUint8Array(encoder));
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
if (messageType === MESSAGE_QUERY_AWARENESS) {
|
|
358
|
+
this.sendAwareness(room, connection.socket);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
361
|
+
if (messageType === MESSAGE_FLUSH) {
|
|
362
|
+
const requestId = decoding.readVarString(decoder).slice(0, 128);
|
|
363
|
+
if (!canEdit(current))
|
|
364
|
+
return;
|
|
365
|
+
const receipt = this.flushRoom(room);
|
|
366
|
+
const encoder = encoding.createEncoder();
|
|
367
|
+
encoding.writeVarUint(encoder, MESSAGE_FLUSH);
|
|
368
|
+
encoding.writeVarString(encoder, requestId);
|
|
369
|
+
encoding.writeVarUint(encoder, receipt.revision);
|
|
370
|
+
encoding.writeVarString(encoder, receipt.persistedAt);
|
|
371
|
+
send(connection.socket, encoding.toUint8Array(encoder));
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
if (messageType !== MESSAGE_AWARENESS)
|
|
375
|
+
return;
|
|
376
|
+
const update = decoding.readVarUint8Array(decoder);
|
|
377
|
+
const clientIds = awarenessClientIds(update);
|
|
378
|
+
if (clientIds.length !== 1)
|
|
379
|
+
return;
|
|
380
|
+
const clientId = clientIds[0];
|
|
381
|
+
const owner = room.awarenessOwners.get(clientId);
|
|
382
|
+
if ((connection.awarenessClientId !== null && connection.awarenessClientId !== clientId) || (owner && owner !== connection))
|
|
383
|
+
return;
|
|
384
|
+
connection.awarenessClientId = clientId;
|
|
385
|
+
const [color, colorLight] = COLORS[Math.abs(clientId) % COLORS.length];
|
|
386
|
+
const sanitized = modifyAwarenessUpdate(update, (state) => state === null ? null : {
|
|
387
|
+
cursor: state && typeof state === "object" ? state.cursor ?? null : null,
|
|
388
|
+
filePath: state && typeof state.filePath === "string" ? state.filePath.slice(0, 1024) : "",
|
|
389
|
+
user: {
|
|
390
|
+
id: connection.user.id,
|
|
391
|
+
username: connection.user.username,
|
|
392
|
+
name: connection.user.display_name,
|
|
393
|
+
color,
|
|
394
|
+
colorLight,
|
|
395
|
+
permission: current.permission,
|
|
396
|
+
sessionId: String(clientId)
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
applyAwarenessUpdate(room.awareness, sanitized, connection);
|
|
400
|
+
}
|
|
401
|
+
disconnect(room, connection) {
|
|
402
|
+
if (!room.connections.delete(connection))
|
|
403
|
+
return;
|
|
404
|
+
if (connection.protocolTimer)
|
|
405
|
+
clearTimeout(connection.protocolTimer);
|
|
406
|
+
connection.protocolTimer = null;
|
|
407
|
+
if (connection.awarenessClientId !== null) {
|
|
408
|
+
removeAwarenessStates(room.awareness, [connection.awarenessClientId], connection);
|
|
409
|
+
room.awarenessOwners.delete(connection.awarenessClientId);
|
|
410
|
+
}
|
|
411
|
+
if (room.connections.size === 0 && !room.cleanupTimer) {
|
|
412
|
+
room.cleanupTimer = setTimeout(() => this.destroyRoom(room), ROOM_IDLE_MS);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
sendSyncStep1(room, socket) {
|
|
416
|
+
const encoder = encoding.createEncoder();
|
|
417
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
418
|
+
syncProtocol.writeSyncStep1(encoder, room.doc);
|
|
419
|
+
send(socket, encoding.toUint8Array(encoder));
|
|
420
|
+
}
|
|
421
|
+
sendAwareness(room, socket) {
|
|
422
|
+
const clients = [...room.awareness.getStates().keys()];
|
|
423
|
+
if (clients.length)
|
|
424
|
+
send(socket, awarenessMessage(encodeAwarenessUpdate(room.awareness, clients)));
|
|
425
|
+
}
|
|
426
|
+
broadcast(room, message, except) {
|
|
427
|
+
for (const connection of room.connections)
|
|
428
|
+
if (connection !== except)
|
|
429
|
+
send(connection.socket, message);
|
|
430
|
+
}
|
|
431
|
+
scheduleSave(room) {
|
|
432
|
+
if (room.saveTimer)
|
|
433
|
+
clearTimeout(room.saveTimer);
|
|
434
|
+
room.saveTimer = setTimeout(() => {
|
|
435
|
+
try {
|
|
436
|
+
this.flushRoom(room);
|
|
437
|
+
}
|
|
438
|
+
catch {
|
|
439
|
+
room.saveTimer = null; /* The Yjs state remains durable and the next client flush retries. */
|
|
440
|
+
}
|
|
441
|
+
}, SAVE_DELAY_MS);
|
|
442
|
+
}
|
|
443
|
+
scheduleStateSave(room) {
|
|
444
|
+
if (room.stateSaveTimer)
|
|
445
|
+
clearTimeout(room.stateSaveTimer);
|
|
446
|
+
room.stateSaveTimer = setTimeout(() => {
|
|
447
|
+
try {
|
|
448
|
+
this.persistRoomState(room);
|
|
449
|
+
}
|
|
450
|
+
catch {
|
|
451
|
+
room.stateSaveTimer = null; /* A later source flush will retry state persistence. */
|
|
452
|
+
}
|
|
453
|
+
}, STATE_SAVE_DELAY_MS);
|
|
454
|
+
}
|
|
455
|
+
persistRoomState(room) {
|
|
456
|
+
if (room.stateSaveTimer)
|
|
457
|
+
clearTimeout(room.stateSaveTimer);
|
|
458
|
+
room.stateSaveTimer = null;
|
|
459
|
+
const target = collaborationStatePath(this.config, room.projectId);
|
|
460
|
+
const temporary = `${target}.tmp`;
|
|
461
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
462
|
+
fs.writeFileSync(temporary, Y.encodeStateAsUpdate(room.doc), { mode: 0o600 });
|
|
463
|
+
fs.renameSync(temporary, target);
|
|
464
|
+
}
|
|
465
|
+
flushRoom(room) {
|
|
466
|
+
const startedAt = performance.now();
|
|
467
|
+
if (room.saveTimer)
|
|
468
|
+
clearTimeout(room.saveTimer);
|
|
469
|
+
room.saveTimer = null;
|
|
470
|
+
this.persistRoomState(room);
|
|
471
|
+
let changed = false;
|
|
472
|
+
const changedPaths = [];
|
|
473
|
+
const dirtyPaths = [...room.dirtyPaths];
|
|
474
|
+
for (const filePath of dirtyPaths) {
|
|
475
|
+
if (!room.allowedPaths.has(filePath)) {
|
|
476
|
+
room.dirtyPaths.delete(filePath);
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
const next = this.trackedText(room, filePath).toString();
|
|
480
|
+
const previous = room.persistedContent.get(filePath) ?? "";
|
|
481
|
+
if (next === previous) {
|
|
482
|
+
room.dirtyPaths.delete(filePath);
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
if (Buffer.byteLength(next, "utf8") > this.config.maxUploadBytes)
|
|
486
|
+
throw new Error(`Collaborative file exceeds upload limit: ${filePath}`);
|
|
487
|
+
const absolute = resolveSourcePath(this.config, room.projectId, filePath);
|
|
488
|
+
if (!fs.existsSync(absolute))
|
|
489
|
+
throw new Error(`Collaborative source path disappeared: ${filePath}`);
|
|
490
|
+
const temporary = `${absolute}.collaboration-${process.pid}-${randomUUID()}.tmp`;
|
|
491
|
+
try {
|
|
492
|
+
fs.writeFileSync(temporary, next, { encoding: "utf8", mode: 0o600 });
|
|
493
|
+
fs.renameSync(temporary, absolute);
|
|
494
|
+
}
|
|
495
|
+
catch (error) {
|
|
496
|
+
fs.rmSync(temporary, { force: true });
|
|
497
|
+
throw error;
|
|
498
|
+
}
|
|
499
|
+
room.persistedContent.set(filePath, next);
|
|
500
|
+
room.dirtyPaths.delete(filePath);
|
|
501
|
+
try {
|
|
502
|
+
reanchorFileComments(this.db, room.projectId, filePath, previous, next);
|
|
503
|
+
}
|
|
504
|
+
catch { /* Source durability is primary; comments can still be re-anchored by a later edit. */ }
|
|
505
|
+
changed = true;
|
|
506
|
+
changedPaths.push(filePath);
|
|
507
|
+
}
|
|
508
|
+
if (changed && room.lastModifiedUserId) {
|
|
509
|
+
this.db.prepare("UPDATE projects SET updated_at = ?, last_modified_by = ? WHERE id = ?")
|
|
510
|
+
.run(new Date().toISOString(), room.lastModifiedUserId, room.projectId);
|
|
511
|
+
}
|
|
512
|
+
if (changed)
|
|
513
|
+
this.signalComments(room.projectId);
|
|
514
|
+
if (changed && this.onPersist) {
|
|
515
|
+
try {
|
|
516
|
+
this.onPersist({ projectId: room.projectId, userId: room.lastModifiedUserId, paths: changedPaths, durationMs: performance.now() - startedAt });
|
|
517
|
+
}
|
|
518
|
+
catch { /* Source durability must not depend on optional history bookkeeping. */ }
|
|
519
|
+
}
|
|
520
|
+
room.persistedRevision += 1;
|
|
521
|
+
room.persistedAt = new Date().toISOString();
|
|
522
|
+
return { revision: room.persistedRevision, persistedAt: room.persistedAt };
|
|
523
|
+
}
|
|
524
|
+
bumpFiles(room, event) {
|
|
525
|
+
room.doc.transact(() => room.meta.set("filesEvent", { ...event, revision: randomUUID() }), META_ORIGIN);
|
|
526
|
+
}
|
|
527
|
+
destroyRoom(room, persist = true) {
|
|
528
|
+
if (this.rooms.get(room.projectId) !== room)
|
|
529
|
+
return;
|
|
530
|
+
if (room.cleanupTimer)
|
|
531
|
+
clearTimeout(room.cleanupTimer);
|
|
532
|
+
for (const connection of room.connections) {
|
|
533
|
+
if (connection.protocolTimer)
|
|
534
|
+
clearTimeout(connection.protocolTimer);
|
|
535
|
+
connection.protocolTimer = null;
|
|
536
|
+
}
|
|
537
|
+
if (persist) {
|
|
538
|
+
try {
|
|
539
|
+
this.flushRoom(room);
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
try {
|
|
543
|
+
this.persistRoomState(room);
|
|
544
|
+
}
|
|
545
|
+
catch { /* Keep shutdown best-effort. */ }
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
if (room.saveTimer)
|
|
550
|
+
clearTimeout(room.saveTimer);
|
|
551
|
+
if (room.stateSaveTimer)
|
|
552
|
+
clearTimeout(room.stateSaveTimer);
|
|
553
|
+
}
|
|
554
|
+
for (const [filePath, observer] of room.textObservers) {
|
|
555
|
+
room.doc.getText(typeName(filePath)).unobserve(observer);
|
|
556
|
+
}
|
|
557
|
+
room.awareness.destroy();
|
|
558
|
+
room.doc.destroy();
|
|
559
|
+
this.rooms.delete(room.projectId);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
export function collaborationStatePath(config, projectId) {
|
|
563
|
+
return path.join(outputRoot(config, projectId), ".texlite", "collaboration.bin");
|
|
564
|
+
}
|
|
565
|
+
export function collaborationEpochPath(config, projectId) {
|
|
566
|
+
return path.join(outputRoot(config, projectId), ".texlite", "collaboration.epoch");
|
|
567
|
+
}
|
|
568
|
+
function collaborationEpoch(config, projectId, recoveredState) {
|
|
569
|
+
const target = collaborationEpochPath(config, projectId);
|
|
570
|
+
if (recoveredState && fs.existsSync(target)) {
|
|
571
|
+
const existing = fs.readFileSync(target, "utf8").trim();
|
|
572
|
+
if (/^[a-f0-9-]{36}$/i.test(existing))
|
|
573
|
+
return existing;
|
|
574
|
+
}
|
|
575
|
+
const epoch = randomUUID();
|
|
576
|
+
const temporary = `${target}.tmp`;
|
|
577
|
+
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
578
|
+
fs.writeFileSync(temporary, epoch, { encoding: "utf8", mode: 0o600 });
|
|
579
|
+
fs.renameSync(temporary, target);
|
|
580
|
+
return epoch;
|
|
581
|
+
}
|
|
582
|
+
function isCollaborativeTextFile(filePath) {
|
|
583
|
+
return /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
|
|
584
|
+
}
|
|
585
|
+
function typeName(filePath) {
|
|
586
|
+
return `${SOURCE_PREFIX}${filePath}`;
|
|
587
|
+
}
|
|
588
|
+
function replaceText(text, content) {
|
|
589
|
+
if (text.toString() === content)
|
|
590
|
+
return;
|
|
591
|
+
if (text.length)
|
|
592
|
+
text.delete(0, text.length);
|
|
593
|
+
if (content)
|
|
594
|
+
text.insert(0, content);
|
|
595
|
+
}
|
|
596
|
+
function syncUpdateMessage(update) {
|
|
597
|
+
const encoder = encoding.createEncoder();
|
|
598
|
+
encoding.writeVarUint(encoder, MESSAGE_SYNC);
|
|
599
|
+
syncProtocol.writeUpdate(encoder, update);
|
|
600
|
+
return encoding.toUint8Array(encoder);
|
|
601
|
+
}
|
|
602
|
+
function awarenessMessage(update) {
|
|
603
|
+
const encoder = encoding.createEncoder();
|
|
604
|
+
encoding.writeVarUint(encoder, MESSAGE_AWARENESS);
|
|
605
|
+
encoding.writeVarUint8Array(encoder, update);
|
|
606
|
+
return encoding.toUint8Array(encoder);
|
|
607
|
+
}
|
|
608
|
+
function protocolMessage(epoch) {
|
|
609
|
+
const encoder = encoding.createEncoder();
|
|
610
|
+
encoding.writeVarUint(encoder, MESSAGE_PROTOCOL);
|
|
611
|
+
encoding.writeVarString(encoder, epoch);
|
|
612
|
+
return encoding.toUint8Array(encoder);
|
|
613
|
+
}
|
|
614
|
+
function awarenessClientIds(update) {
|
|
615
|
+
const decoder = decoding.createDecoder(update);
|
|
616
|
+
const count = decoding.readVarUint(decoder);
|
|
617
|
+
const result = [];
|
|
618
|
+
for (let index = 0; index < count; index += 1) {
|
|
619
|
+
result.push(decoding.readVarUint(decoder));
|
|
620
|
+
decoding.readVarUint(decoder);
|
|
621
|
+
decoding.readVarString(decoder);
|
|
622
|
+
}
|
|
623
|
+
return result;
|
|
624
|
+
}
|
|
625
|
+
function rawData(data) {
|
|
626
|
+
if (data instanceof ArrayBuffer)
|
|
627
|
+
return new Uint8Array(data);
|
|
628
|
+
if (Array.isArray(data))
|
|
629
|
+
return new Uint8Array(Buffer.concat(data));
|
|
630
|
+
return new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
631
|
+
}
|
|
632
|
+
function send(socket, message) {
|
|
633
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
634
|
+
socket.send(message);
|
|
635
|
+
}
|