texlite 0.6.0 → 0.6.6
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/README.md +1 -0
- package/dist/client/assets/{BinaryModule-Aj1vLnwf-C2TCdTPg.js → BinaryModule-Aj1vLnwf-FnkCpIDV.js} +1 -1
- package/dist/client/assets/{GitDialog-9mNxx8oX.js → GitDialog-BtWtaeZV.js} +1 -1
- package/dist/client/assets/{HistoryDialog-CmoD9Cvf.js → HistoryDialog-DOzAQAQA.js} +1 -1
- package/dist/client/assets/{LatexEditor-Dp8EoZBP.js → LatexEditor-C5S1jijC.js} +20 -20
- package/dist/client/assets/PdfPreview-B3-kvd02.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-veKVnRxc.js → ProjectNavigationDialogs-DeEMFRsp.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-D_oiVmBC.js → SystemMetricsDialog-DWh6yS0w.js} +1 -1
- package/dist/client/assets/binary-D_GeVl1T.js +1 -0
- package/dist/client/assets/index-C4KkkecE.js +17 -0
- package/dist/client/assets/index-DwzHKaKD.css +1 -0
- package/dist/client/assets/{index-CyEG4l54.js → index-nywS6bYC.js} +1 -1
- package/dist/client/assets/{plus-Bup73Wq6.js → plus-B_Bl12ZY.js} +1 -1
- package/dist/client/assets/{spellCheck-BHW5bumN.js → spellCheck-BynsoX26.js} +3 -3
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +888 -339
- package/dist/server/archive.js +20 -3
- package/dist/server/auth.js +13 -2
- package/dist/server/collaboration.js +815 -124
- package/dist/server/compileArtifacts.js +31 -13
- package/dist/server/compiler.js +69 -20
- package/dist/server/db.js +6 -0
- package/dist/server/environment.js +4 -2
- package/dist/server/files.js +172 -25
- package/dist/server/git.js +152 -124
- package/dist/server/history.js +86 -18
- package/dist/server/http.js +26 -0
- package/dist/server/index.js +39 -26
- package/dist/server/instanceLock.js +137 -0
- package/dist/server/latexCompletion.js +23 -1
- package/dist/server/latexFormatter.js +4 -2
- package/dist/server/processTree.js +31 -0
- package/dist/server/projectMutations.js +255 -0
- package/dist/server/projectOutline.js +113 -1
- package/dist/server/projectSearch.js +6 -0
- package/dist/server/routes/compile.js +505 -128
- package/dist/server/security.js +61 -1
- package/dist/server/synctex.js +11 -2
- package/dist/server/zip.js +2 -1
- package/package.json +1 -1
- package/dist/client/assets/PdfPreview-IfkmLMSU.js +0 -1
- package/dist/client/assets/binary-DTriWQzf.js +0 -1
- package/dist/client/assets/index-CI87C5Wy.js +0 -17
- package/dist/client/assets/index-DB6GC33g.css +0 -1
|
@@ -8,7 +8,7 @@ import * as encoding from "lib0/encoding";
|
|
|
8
8
|
import * as decoding from "lib0/decoding";
|
|
9
9
|
import * as syncProtocol from "y-protocols/sync";
|
|
10
10
|
import { Awareness, applyAwarenessUpdate, encodeAwarenessUpdate, modifyAwarenessUpdate, removeAwarenessStates } from "y-protocols/awareness";
|
|
11
|
-
import {
|
|
11
|
+
import { listProjectFilesAsync, outputRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
|
|
12
12
|
import { accessibleProject, canEdit } from "./projects.js";
|
|
13
13
|
import { reanchorFileComments } from "./anchors.js";
|
|
14
14
|
const MESSAGE_SYNC = 0;
|
|
@@ -16,6 +16,19 @@ const MESSAGE_AWARENESS = 1;
|
|
|
16
16
|
const MESSAGE_QUERY_AWARENESS = 3;
|
|
17
17
|
const MESSAGE_FLUSH = 4;
|
|
18
18
|
const MESSAGE_PROTOCOL = 5;
|
|
19
|
+
const MESSAGE_MAINTENANCE = 6;
|
|
20
|
+
const MESSAGE_PERMISSION = 7;
|
|
21
|
+
// A small, non-Yjs handshake message used for ephemeral compile metadata.
|
|
22
|
+
// Source text still uses the normal Yjs sync protocol; this message prevents
|
|
23
|
+
// an old IndexedDB metadata entry from being treated as an active compile.
|
|
24
|
+
const MESSAGE_COMPILE_STATES = 8;
|
|
25
|
+
/**
|
|
26
|
+
* Increment this when a wire-level collaboration change cannot be decoded by
|
|
27
|
+
* an older browser. The epoch marker is rotated at the same time, which makes
|
|
28
|
+
* already-open older pages discard their local draft and reload safely.
|
|
29
|
+
*/
|
|
30
|
+
export const COLLABORATION_PROTOCOL_VERSION = 2;
|
|
31
|
+
const VERSIONED_EPOCH_PREFIX = `${COLLABORATION_PROTOCOL_VERSION}:`;
|
|
19
32
|
const SOURCE_PREFIX = "source:";
|
|
20
33
|
const MAX_PROJECT_SESSIONS = 10;
|
|
21
34
|
const MAX_COLLABORATIVE_FILE_BYTES = 5 * 1024 * 1024;
|
|
@@ -25,6 +38,7 @@ const META_ORIGIN = Symbol("meta");
|
|
|
25
38
|
const SAVE_DELAY_MS = 750;
|
|
26
39
|
const STATE_SAVE_DELAY_MS = 750;
|
|
27
40
|
const ROOM_IDLE_MS = 30_000;
|
|
41
|
+
const EPHEMERAL_META_KEYS = ["compileStates", "filesEvent", "commentsRevision", "dictionaryRevision"];
|
|
28
42
|
const COLORS = [
|
|
29
43
|
["#1677c8", "#1677c833"], ["#d65745", "#d6574533"], ["#16866a", "#16866a33"],
|
|
30
44
|
["#9a58b5", "#9a58b533"], ["#d27b18", "#d27b1833"], ["#3f7d20", "#3f7d2033"],
|
|
@@ -36,51 +50,149 @@ export class CollaborationService {
|
|
|
36
50
|
db;
|
|
37
51
|
onPersist;
|
|
38
52
|
rooms = new Map();
|
|
53
|
+
maintenanceProjects = new Map();
|
|
54
|
+
roomInitializations = new Map();
|
|
55
|
+
projectGenerations = new Map();
|
|
56
|
+
snapshotBarriers = new Map();
|
|
57
|
+
pendingConnections = new Map();
|
|
58
|
+
closed = false;
|
|
39
59
|
constructor(config, db, onPersist) {
|
|
40
60
|
this.config = config;
|
|
41
61
|
this.db = db;
|
|
42
62
|
this.onPersist = onPersist;
|
|
43
63
|
}
|
|
44
|
-
connect(socket, projectId, user) {
|
|
64
|
+
async connect(socket, projectId, user) {
|
|
65
|
+
if (this.closed) {
|
|
66
|
+
socket.close(1012, "Collaboration service is shutting down");
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
45
69
|
const project = accessibleProject(this.db, projectId, user);
|
|
46
70
|
if (!project) {
|
|
47
71
|
socket.close(1008, "Project access denied");
|
|
48
72
|
return;
|
|
49
73
|
}
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
socket.close(1013,
|
|
74
|
+
const maintenanceReason = this.maintenanceProjects.get(projectId);
|
|
75
|
+
if (maintenanceReason) {
|
|
76
|
+
socket.close(1013, `Project is temporarily unavailable: ${maintenanceReason}`);
|
|
53
77
|
return;
|
|
54
78
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
79
|
+
const existing = this.rooms.get(projectId);
|
|
80
|
+
if (existing) {
|
|
81
|
+
this.attachConnection(existing, socket, user);
|
|
82
|
+
return;
|
|
58
83
|
}
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
const generation = this.projectGeneration(projectId);
|
|
85
|
+
const pendingKey = this.pendingConnectionKey(projectId, generation);
|
|
86
|
+
const pending = this.pendingConnections.get(pendingKey) ?? 0;
|
|
87
|
+
if (pending >= MAX_PROJECT_SESSIONS) {
|
|
88
|
+
socket.close(1013, "Project collaboration room is full");
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
this.pendingConnections.set(pendingKey, pending + 1);
|
|
92
|
+
const initialization = this.roomInitializations.get(projectId) ?? this.initializeRoom(projectId, generation);
|
|
93
|
+
try {
|
|
94
|
+
const room = await initialization;
|
|
95
|
+
const currentMaintenance = this.maintenanceProjects.get(projectId);
|
|
96
|
+
if (currentMaintenance) {
|
|
97
|
+
this.disposeRoom(room);
|
|
98
|
+
socket.close(1013, `Project is temporarily unavailable: ${currentMaintenance}`);
|
|
67
99
|
}
|
|
68
|
-
|
|
69
|
-
socket.close(
|
|
100
|
+
else if (this.projectGeneration(projectId) !== generation) {
|
|
101
|
+
socket.close(1013, "Collaboration state changed; retry required");
|
|
70
102
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
103
|
+
else if (socket.readyState === WebSocket.OPEN) {
|
|
104
|
+
this.attachConnection(room, socket, user);
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
socket.close(1000, "Collaboration connection closed during initialization");
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
socket.close(1011, "Unable to initialize collaboration room");
|
|
112
|
+
}
|
|
113
|
+
finally {
|
|
114
|
+
const count = (this.pendingConnections.get(pendingKey) ?? 1) - 1;
|
|
115
|
+
if (count > 0)
|
|
116
|
+
this.pendingConnections.set(pendingKey, count);
|
|
117
|
+
else
|
|
118
|
+
this.pendingConnections.delete(pendingKey);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/** Wait for an in-flight cold-room load before reading or replacing sources. */
|
|
122
|
+
async waitForReady(projectId) {
|
|
123
|
+
const initialization = this.roomInitializations.get(projectId);
|
|
124
|
+
if (!initialization)
|
|
125
|
+
return;
|
|
126
|
+
try {
|
|
127
|
+
await initialization;
|
|
128
|
+
}
|
|
129
|
+
catch { /* Source files remain authoritative if recovery fails. */ }
|
|
79
130
|
}
|
|
80
131
|
flushProject(projectId) {
|
|
81
132
|
const room = this.rooms.get(projectId);
|
|
82
|
-
|
|
83
|
-
|
|
133
|
+
return room ? this.flushRoom(room) : null;
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Prevent collaboration autosaves from changing the source tree while a
|
|
137
|
+
* compiler is copying an immutable snapshot. Yjs updates remain accepted in
|
|
138
|
+
* memory and are flushed when the outermost barrier is released.
|
|
139
|
+
*/
|
|
140
|
+
beginSnapshotBarrier(projectId) {
|
|
141
|
+
const depth = (this.snapshotBarriers.get(projectId) ?? 0) + 1;
|
|
142
|
+
this.snapshotBarriers.set(projectId, depth);
|
|
143
|
+
const room = this.rooms.get(projectId);
|
|
144
|
+
if (!room)
|
|
145
|
+
return;
|
|
146
|
+
room.snapshotBarrierDepth = depth;
|
|
147
|
+
if (depth === 1 && room.cleanupTimer) {
|
|
148
|
+
clearTimeout(room.cleanupTimer);
|
|
149
|
+
room.cleanupTimer = null;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Release a snapshot barrier and persist edits that arrived while the
|
|
154
|
+
* source tree was protected. The returned receipt describes the post-barrier
|
|
155
|
+
* disk revision, allowing callers to label a snapshot that predates edits
|
|
156
|
+
* without treating that consistent snapshot as invalid.
|
|
157
|
+
*/
|
|
158
|
+
endSnapshotBarrier(projectId) {
|
|
159
|
+
const depth = this.snapshotBarriers.get(projectId) ?? 0;
|
|
160
|
+
if (depth <= 0)
|
|
161
|
+
return this.rooms.get(projectId) ? this.currentReceipt(this.rooms.get(projectId)) : null;
|
|
162
|
+
const nextDepth = depth - 1;
|
|
163
|
+
if (nextDepth > 0) {
|
|
164
|
+
this.snapshotBarriers.set(projectId, nextDepth);
|
|
165
|
+
const nestedRoom = this.rooms.get(projectId);
|
|
166
|
+
if (nestedRoom)
|
|
167
|
+
nestedRoom.snapshotBarrierDepth = nextDepth;
|
|
168
|
+
return nestedRoom ? this.currentReceipt(nestedRoom) : null;
|
|
169
|
+
}
|
|
170
|
+
this.snapshotBarriers.delete(projectId);
|
|
171
|
+
const room = this.rooms.get(projectId);
|
|
172
|
+
if (!room)
|
|
173
|
+
return null;
|
|
174
|
+
room.snapshotBarrierDepth = 0;
|
|
175
|
+
const shouldFlush = room.snapshotFlushPending || room.dirtyPaths.size > 0;
|
|
176
|
+
room.snapshotFlushPending = false;
|
|
177
|
+
let receipt;
|
|
178
|
+
try {
|
|
179
|
+
receipt = shouldFlush ? this.flushRoom(room) : this.currentReceipt(room);
|
|
180
|
+
}
|
|
181
|
+
catch (error) {
|
|
182
|
+
// A state-file or source-file I/O failure must still complete any
|
|
183
|
+
// browser flush requests waiting behind the barrier. The caller should
|
|
184
|
+
// receive the original server error, while editors receive a durable
|
|
185
|
+
// negative receipt instead of timing out.
|
|
186
|
+
receipt = this.currentReceipt(room, false, [...room.dirtyPaths]);
|
|
187
|
+
if (room.dirtyPaths.size > 0)
|
|
188
|
+
this.scheduleSave(room);
|
|
189
|
+
this.resolvePendingFlushes(room, receipt);
|
|
190
|
+
this.scheduleRoomCleanup(room);
|
|
191
|
+
throw error;
|
|
192
|
+
}
|
|
193
|
+
this.resolvePendingFlushes(room, receipt);
|
|
194
|
+
this.scheduleRoomCleanup(room);
|
|
195
|
+
return receipt;
|
|
84
196
|
}
|
|
85
197
|
stats() {
|
|
86
198
|
let sessions = 0;
|
|
@@ -89,23 +201,132 @@ export class CollaborationService {
|
|
|
89
201
|
sessions += room.connections.size;
|
|
90
202
|
dirtyFiles += room.dirtyPaths.size;
|
|
91
203
|
}
|
|
92
|
-
return { rooms: this.rooms.size, sessions, dirtyFiles };
|
|
204
|
+
return { rooms: this.rooms.size, sessions, dirtyFiles, initializing: this.roomInitializations.size };
|
|
93
205
|
}
|
|
94
206
|
updateFile(projectId, filePathInput, content, userId) {
|
|
95
207
|
const room = this.rooms.get(projectId);
|
|
96
|
-
if (!room
|
|
208
|
+
if (!room) {
|
|
209
|
+
this.invalidateProject(projectId);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!isCollaborativeTextFile(filePathInput)) {
|
|
213
|
+
this.invalidateProject(projectId);
|
|
97
214
|
return;
|
|
215
|
+
}
|
|
98
216
|
const filePath = safeRelativePath(filePathInput);
|
|
99
217
|
room.allowedPaths.add(filePath);
|
|
100
218
|
room.persistedContent.set(filePath, content);
|
|
101
219
|
room.doc.transact(() => replaceText(this.trackedText(room, filePath), content), HTTP_ORIGIN);
|
|
102
220
|
room.lastModifiedUserId = userId;
|
|
221
|
+
room.persistedRevision += 1;
|
|
222
|
+
room.persistedAt = new Date().toISOString();
|
|
103
223
|
this.bumpFiles(room, { kind: "update", path: filePath });
|
|
104
224
|
}
|
|
105
|
-
|
|
225
|
+
/**
|
|
226
|
+
* Mark a project as undergoing an exclusive source-tree operation. Existing
|
|
227
|
+
* clients are told to stop editing and are disconnected when the operation
|
|
228
|
+
* completes by endMaintenance(), which forces a fresh Yjs epoch.
|
|
229
|
+
*/
|
|
230
|
+
beginMaintenance(projectId, reason) {
|
|
231
|
+
const normalizedReason = reason.slice(0, 200);
|
|
232
|
+
const existingMaintenance = this.maintenanceProjects.get(projectId);
|
|
233
|
+
if (existingMaintenance)
|
|
234
|
+
throw new Error("Project is already undergoing a maintenance operation");
|
|
235
|
+
const room = this.rooms.get(projectId);
|
|
236
|
+
if (room?.maintenanceReason)
|
|
237
|
+
throw new Error("Project is already undergoing a maintenance operation");
|
|
238
|
+
this.maintenanceProjects.set(projectId, normalizedReason);
|
|
239
|
+
if (!room)
|
|
240
|
+
return;
|
|
241
|
+
room.maintenanceReason = normalizedReason;
|
|
242
|
+
const message = maintenanceMessage(room.maintenanceReason);
|
|
243
|
+
for (const connection of room.connections) {
|
|
244
|
+
send(connection.socket, message);
|
|
245
|
+
// Close immediately after the notice. This prevents local edits from
|
|
246
|
+
// accumulating while a source-tree replacement is in flight; the epoch
|
|
247
|
+
// reset in endMaintenance will make the client reload authoritative data.
|
|
248
|
+
connection.socket.close(4002, `Project maintenance: ${normalizedReason}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
endMaintenance(projectId) {
|
|
252
|
+
this.maintenanceProjects.delete(projectId);
|
|
253
|
+
const room = this.rooms.get(projectId);
|
|
254
|
+
if (room)
|
|
255
|
+
room.maintenanceReason = null;
|
|
256
|
+
// A project-level replacement cannot safely merge edits made while the
|
|
257
|
+
// operation was in flight. Resetting the epoch makes every client reload
|
|
258
|
+
// the authoritative source tree instead of replaying stale local drafts.
|
|
259
|
+
this.resetProject(projectId);
|
|
260
|
+
}
|
|
261
|
+
notifyPermissionChanged(projectId, userId, permission) {
|
|
262
|
+
const room = this.rooms.get(projectId);
|
|
263
|
+
if (!room)
|
|
264
|
+
return;
|
|
265
|
+
const encoder = encoding.createEncoder();
|
|
266
|
+
encoding.writeVarUint(encoder, MESSAGE_PERMISSION);
|
|
267
|
+
encoding.writeVarString(encoder, userId);
|
|
268
|
+
encoding.writeVarString(encoder, permission);
|
|
269
|
+
const message = encoding.toUint8Array(encoder);
|
|
270
|
+
for (const connection of room.connections) {
|
|
271
|
+
if (connection.user.id === userId)
|
|
272
|
+
send(connection.socket, message);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
disconnectUser(userId, reason = "User access revoked") {
|
|
276
|
+
const encoder = encoding.createEncoder();
|
|
277
|
+
encoding.writeVarUint(encoder, MESSAGE_PERMISSION);
|
|
278
|
+
encoding.writeVarString(encoder, userId);
|
|
279
|
+
encoding.writeVarString(encoder, "revoked");
|
|
280
|
+
const message = encoding.toUint8Array(encoder);
|
|
281
|
+
for (const room of this.rooms.values()) {
|
|
282
|
+
for (const connection of [...room.connections]) {
|
|
283
|
+
if (connection.user.id === userId) {
|
|
284
|
+
send(connection.socket, message);
|
|
285
|
+
connection.socket.close(1008, reason);
|
|
286
|
+
this.disconnect(room, connection);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
currentRevision(projectId) {
|
|
292
|
+
return this.rooms.get(projectId)?.persistedRevision ?? null;
|
|
293
|
+
}
|
|
294
|
+
/** Return the durable collaborative text currently held by a live room. */
|
|
295
|
+
fileContent(projectId, filePathInput) {
|
|
106
296
|
const room = this.rooms.get(projectId);
|
|
107
297
|
if (!room)
|
|
298
|
+
return null;
|
|
299
|
+
const filePath = safeRelativePath(filePathInput);
|
|
300
|
+
if (!room.allowedPaths.has(filePath))
|
|
301
|
+
return null;
|
|
302
|
+
return this.trackedText(room, filePath).toString();
|
|
303
|
+
}
|
|
304
|
+
/**
|
|
305
|
+
* Returns the current source-tree epoch. Exclusive filesystem operations
|
|
306
|
+
* increment this value, allowing callers to cheaply distinguish a request
|
|
307
|
+
* admitted before and after a project replacement without reading files.
|
|
308
|
+
*/
|
|
309
|
+
currentGeneration(projectId) {
|
|
310
|
+
return this.projectGeneration(projectId);
|
|
311
|
+
}
|
|
312
|
+
hasPendingChanges(projectId) {
|
|
313
|
+
return Boolean(this.rooms.get(projectId)?.dirtyPaths.size);
|
|
314
|
+
}
|
|
315
|
+
isMaintaining(projectId) {
|
|
316
|
+
return this.maintenanceProjects.has(projectId);
|
|
317
|
+
}
|
|
318
|
+
isStable(projectId, revision) {
|
|
319
|
+
const room = this.rooms.get(projectId);
|
|
320
|
+
return room
|
|
321
|
+
? room.maintenanceReason === null && room.persistedRevision === revision && room.dirtyPaths.size === 0
|
|
322
|
+
: revision === null;
|
|
323
|
+
}
|
|
324
|
+
movePath(projectId, sourceInput, destinationInput, userId) {
|
|
325
|
+
const room = this.rooms.get(projectId);
|
|
326
|
+
if (!room) {
|
|
327
|
+
this.invalidateProject(projectId);
|
|
108
328
|
return;
|
|
329
|
+
}
|
|
109
330
|
this.flushRoom(room);
|
|
110
331
|
const source = safeRelativePath(sourceInput);
|
|
111
332
|
const destination = safeRelativePath(destinationInput);
|
|
@@ -123,12 +344,16 @@ export class CollaborationService {
|
|
|
123
344
|
}
|
|
124
345
|
}, HTTP_ORIGIN);
|
|
125
346
|
room.lastModifiedUserId = userId;
|
|
347
|
+
room.persistedRevision += 1;
|
|
348
|
+
room.persistedAt = new Date().toISOString();
|
|
126
349
|
this.bumpFiles(room, { kind: "move", source, destination });
|
|
127
350
|
}
|
|
128
351
|
removePath(projectId, filePathInput) {
|
|
129
352
|
const room = this.rooms.get(projectId);
|
|
130
|
-
if (!room)
|
|
353
|
+
if (!room) {
|
|
354
|
+
this.invalidateProject(projectId);
|
|
131
355
|
return;
|
|
356
|
+
}
|
|
132
357
|
const filePath = safeRelativePath(filePathInput);
|
|
133
358
|
const removed = [...room.allowedPaths].filter((candidate) => candidate === filePath || candidate.startsWith(`${filePath}/`));
|
|
134
359
|
room.doc.transact(() => {
|
|
@@ -139,8 +364,18 @@ export class CollaborationService {
|
|
|
139
364
|
room.dirtyPaths.delete(candidate);
|
|
140
365
|
}
|
|
141
366
|
}, HTTP_ORIGIN);
|
|
367
|
+
room.persistedRevision += 1;
|
|
368
|
+
room.persistedAt = new Date().toISOString();
|
|
142
369
|
this.bumpFiles(room, { kind: "delete", path: filePath });
|
|
143
370
|
}
|
|
371
|
+
invalidateSourceTree(projectId, filePathInput) {
|
|
372
|
+
this.invalidateProject(projectId);
|
|
373
|
+
const room = this.rooms.get(projectId);
|
|
374
|
+
if (!room)
|
|
375
|
+
return;
|
|
376
|
+
const filePath = filePathInput ? safeRelativePath(filePathInput) : "";
|
|
377
|
+
this.bumpFiles(room, { kind: "update", path: filePath });
|
|
378
|
+
}
|
|
144
379
|
signalComments(projectId) {
|
|
145
380
|
const room = this.rooms.get(projectId);
|
|
146
381
|
if (!room)
|
|
@@ -167,7 +402,102 @@ export class CollaborationService {
|
|
|
167
402
|
.slice(0, 20));
|
|
168
403
|
room.doc.transact(() => room.meta.set("compileStates", retained), META_ORIGIN);
|
|
169
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* Drop compile states that no longer describe an active database run.
|
|
407
|
+
*
|
|
408
|
+
* `compileStates` is intentionally only a live UI signal. A browser can
|
|
409
|
+
* nevertheless replay an old IndexedDB update while reconnecting, so the
|
|
410
|
+
* server must not let an old `queued`/`running` value become authoritative
|
|
411
|
+
* again after a restart. Completed states are retained for the current room
|
|
412
|
+
* because they are useful to the UI and cannot disable a new compile.
|
|
413
|
+
*/
|
|
414
|
+
sanitizeCompileStates(room) {
|
|
415
|
+
const current = room.meta.get("compileStates");
|
|
416
|
+
if (!current || typeof current !== "object" || Array.isArray(current)) {
|
|
417
|
+
if (current !== undefined)
|
|
418
|
+
room.doc.transact(() => room.meta.delete("compileStates"), META_ORIGIN);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const retained = {};
|
|
422
|
+
let changed = false;
|
|
423
|
+
for (const [mainFile, value] of Object.entries(current)) {
|
|
424
|
+
if (!isSharedCompileState(mainFile, value)) {
|
|
425
|
+
changed = true;
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (value.status === "queued" || value.status === "running") {
|
|
429
|
+
const run = this.db.prepare(`SELECT status, main_file FROM compile_runs
|
|
430
|
+
WHERE id = ? AND project_id = ?`).get(value.runId, room.projectId);
|
|
431
|
+
if (!run || run.main_file !== mainFile || (run.status !== "queued" && run.status !== "running")) {
|
|
432
|
+
changed = true;
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
retained[mainFile] = value;
|
|
437
|
+
}
|
|
438
|
+
if (!changed && Object.keys(retained).length === Object.keys(current).length)
|
|
439
|
+
return;
|
|
440
|
+
room.doc.transact(() => {
|
|
441
|
+
if (Object.keys(retained).length)
|
|
442
|
+
room.meta.set("compileStates", retained);
|
|
443
|
+
else
|
|
444
|
+
room.meta.delete("compileStates");
|
|
445
|
+
}, META_ORIGIN);
|
|
446
|
+
}
|
|
447
|
+
sendCompileStates(room, socket) {
|
|
448
|
+
const payload = this.compileStatesForClient(room);
|
|
449
|
+
const encoder = encoding.createEncoder();
|
|
450
|
+
encoding.writeVarUint(encoder, MESSAGE_COMPILE_STATES);
|
|
451
|
+
encoding.writeVarString(encoder, JSON.stringify(payload));
|
|
452
|
+
send(socket, encoding.toUint8Array(encoder));
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Include active database runs even when no live room existed when the run
|
|
456
|
+
* was queued. This keeps the handshake authoritative without making every
|
|
457
|
+
* compile request persist a duplicate Yjs metadata update.
|
|
458
|
+
*/
|
|
459
|
+
compileStatesForClient(room) {
|
|
460
|
+
const current = room.meta.get("compileStates");
|
|
461
|
+
const states = isCompileStateMap(current) ? { ...current } : {};
|
|
462
|
+
const activeRuns = this.db.prepare(`SELECT run.id, run.main_file, run.status, run.requested_by,
|
|
463
|
+
run.created_at, user.username AS requested_by_username, user.display_name AS requested_by_name
|
|
464
|
+
FROM compile_runs run LEFT JOIN users user ON user.id = run.requested_by
|
|
465
|
+
WHERE run.project_id = ? AND run.status IN ('queued', 'running')
|
|
466
|
+
ORDER BY CASE run.status WHEN 'running' THEN 0 ELSE 1 END, run.created_at DESC`).all(room.projectId);
|
|
467
|
+
for (const run of activeRuns) {
|
|
468
|
+
if (states[run.main_file]?.status === "running" && run.status === "queued")
|
|
469
|
+
continue;
|
|
470
|
+
states[run.main_file] = {
|
|
471
|
+
mainFile: run.main_file,
|
|
472
|
+
runId: run.id,
|
|
473
|
+
status: run.status,
|
|
474
|
+
requestedBy: {
|
|
475
|
+
id: run.requested_by ?? "deleted-user",
|
|
476
|
+
username: run.requested_by_username ?? "deleted-user",
|
|
477
|
+
name: run.requested_by_name ?? "Deleted User"
|
|
478
|
+
},
|
|
479
|
+
updatedAt: run.created_at
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
return Object.fromEntries(Object.entries(states)
|
|
483
|
+
.sort((left, right) => Date.parse(right[1].updatedAt) - Date.parse(left[1].updatedAt))
|
|
484
|
+
.slice(0, 20));
|
|
485
|
+
}
|
|
486
|
+
clearRecoveredMetadata(room) {
|
|
487
|
+
let changed = false;
|
|
488
|
+
room.doc.transact(() => {
|
|
489
|
+
for (const key of EPHEMERAL_META_KEYS) {
|
|
490
|
+
if (!room.meta.has(key))
|
|
491
|
+
continue;
|
|
492
|
+
room.meta.delete(key);
|
|
493
|
+
changed = true;
|
|
494
|
+
}
|
|
495
|
+
}, META_ORIGIN);
|
|
496
|
+
return changed;
|
|
497
|
+
}
|
|
170
498
|
closeProject(projectId) {
|
|
499
|
+
this.invalidateProject(projectId);
|
|
500
|
+
this.snapshotBarriers.delete(projectId);
|
|
171
501
|
const room = this.rooms.get(projectId);
|
|
172
502
|
if (!room)
|
|
173
503
|
return;
|
|
@@ -176,6 +506,8 @@ export class CollaborationService {
|
|
|
176
506
|
this.destroyRoom(room);
|
|
177
507
|
}
|
|
178
508
|
resetProject(projectId) {
|
|
509
|
+
this.invalidateProject(projectId);
|
|
510
|
+
this.snapshotBarriers.delete(projectId);
|
|
179
511
|
const room = this.rooms.get(projectId);
|
|
180
512
|
if (room) {
|
|
181
513
|
for (const connection of room.connections)
|
|
@@ -186,19 +518,127 @@ export class CollaborationService {
|
|
|
186
518
|
fs.rmSync(collaborationEpochPath(this.config, projectId), { force: true });
|
|
187
519
|
}
|
|
188
520
|
destroy() {
|
|
521
|
+
this.closed = true;
|
|
189
522
|
for (const room of [...this.rooms.values()])
|
|
190
523
|
this.destroyRoom(room);
|
|
524
|
+
this.roomInitializations.clear();
|
|
525
|
+
this.snapshotBarriers.clear();
|
|
526
|
+
this.pendingConnections.clear();
|
|
191
527
|
}
|
|
192
|
-
|
|
528
|
+
initializeRoom(projectId, generation) {
|
|
193
529
|
const existing = this.rooms.get(projectId);
|
|
194
530
|
if (existing)
|
|
531
|
+
return Promise.resolve(existing);
|
|
532
|
+
const pending = this.roomInitializations.get(projectId);
|
|
533
|
+
if (pending)
|
|
534
|
+
return pending;
|
|
535
|
+
const request = this.loadRoomBootstrap(projectId)
|
|
536
|
+
.then((bootstrap) => {
|
|
537
|
+
if (this.closed || this.projectGeneration(projectId) !== generation) {
|
|
538
|
+
bootstrap.doc.destroy();
|
|
539
|
+
throw new Error("Collaboration room initialization was invalidated");
|
|
540
|
+
}
|
|
541
|
+
return this.createRoom(projectId, bootstrap);
|
|
542
|
+
})
|
|
543
|
+
.finally(() => {
|
|
544
|
+
if (this.roomInitializations.get(projectId) === request)
|
|
545
|
+
this.roomInitializations.delete(projectId);
|
|
546
|
+
});
|
|
547
|
+
this.roomInitializations.set(projectId, request);
|
|
548
|
+
return request;
|
|
549
|
+
}
|
|
550
|
+
/** Read the potentially large source tree without blocking the event loop. */
|
|
551
|
+
async loadRoomBootstrap(projectId) {
|
|
552
|
+
let doc = new Y.Doc();
|
|
553
|
+
try {
|
|
554
|
+
let recoveredState = false;
|
|
555
|
+
let persistedState = null;
|
|
556
|
+
try {
|
|
557
|
+
persistedState = await fs.promises.readFile(collaborationStatePath(this.config, projectId));
|
|
558
|
+
}
|
|
559
|
+
catch (error) {
|
|
560
|
+
if (error.code !== "ENOENT")
|
|
561
|
+
throw error;
|
|
562
|
+
}
|
|
563
|
+
if (persistedState) {
|
|
564
|
+
try {
|
|
565
|
+
Y.applyUpdate(doc, persistedState, DISK_ORIGIN);
|
|
566
|
+
recoveredState = true;
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
// Malformed Yjs data is recoverable because source files remain the
|
|
570
|
+
// authority. Start with a pristine document in case applyUpdate()
|
|
571
|
+
// partially mutated the original before rejecting the payload.
|
|
572
|
+
doc.destroy();
|
|
573
|
+
doc = new Y.Doc();
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
const epoch = await collaborationEpochAsync(this.config, projectId, recoveredState);
|
|
577
|
+
const files = await this.readCollaborativeFiles(projectId);
|
|
578
|
+
return { doc, recoveredState, epoch, files };
|
|
579
|
+
}
|
|
580
|
+
catch (error) {
|
|
581
|
+
doc.destroy();
|
|
582
|
+
throw error;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* Recheck file metadata after reading. This closes the small race where an
|
|
587
|
+
* upload or autosave changes a file while the cold room is being hydrated.
|
|
588
|
+
*/
|
|
589
|
+
async readCollaborativeFiles(projectId) {
|
|
590
|
+
const assertSourceRoot = async () => {
|
|
591
|
+
const stats = await fs.promises.lstat(sourceRoot(this.config, projectId));
|
|
592
|
+
if (stats.isSymbolicLink())
|
|
593
|
+
throw new Error("Collaborative source root cannot be a symbolic link");
|
|
594
|
+
if (!stats.isDirectory())
|
|
595
|
+
throw new Error("Collaborative source root is not a directory");
|
|
596
|
+
};
|
|
597
|
+
let lastFailure = "Unable to obtain a stable collaborative source snapshot";
|
|
598
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
599
|
+
await assertSourceRoot();
|
|
600
|
+
const entries = (await listProjectFilesAsync(this.config, projectId))
|
|
601
|
+
.filter((entry) => entry.type === "file" && isCollaborativeTextFile(entry.path)
|
|
602
|
+
&& (entry.size ?? 0) <= maxCollaborativeFileBytes(this.config));
|
|
603
|
+
const files = [];
|
|
604
|
+
const failedPaths = [];
|
|
605
|
+
for (let offset = 0; offset < entries.length; offset += 8) {
|
|
606
|
+
const batch = entries.slice(offset, offset + 8);
|
|
607
|
+
const loaded = await Promise.all(batch.map(async (entry) => {
|
|
608
|
+
try {
|
|
609
|
+
return { path: entry.path, content: await fs.promises.readFile(resolveSourcePath(this.config, projectId, entry.path), "utf8") };
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
failedPaths.push(entry.path);
|
|
613
|
+
return null;
|
|
614
|
+
}
|
|
615
|
+
}));
|
|
616
|
+
files.push(...loaded.filter((file) => file !== null));
|
|
617
|
+
}
|
|
618
|
+
const currentEntries = (await listProjectFilesAsync(this.config, projectId))
|
|
619
|
+
.filter((entry) => entry.type === "file" && isCollaborativeTextFile(entry.path)
|
|
620
|
+
&& (entry.size ?? 0) <= maxCollaborativeFileBytes(this.config));
|
|
621
|
+
await assertSourceRoot();
|
|
622
|
+
const before = entries.map(collaborativeEntrySignature).sort().join("\n");
|
|
623
|
+
const after = currentEntries.map(collaborativeEntrySignature).sort().join("\n");
|
|
624
|
+
if (before === after && failedPaths.length === 0)
|
|
625
|
+
return files;
|
|
626
|
+
if (failedPaths.length)
|
|
627
|
+
lastFailure = `Unable to read collaborative source files: ${failedPaths.join(", ")}`;
|
|
628
|
+
}
|
|
629
|
+
throw new Error(lastFailure);
|
|
630
|
+
}
|
|
631
|
+
createRoom(projectId, bootstrap) {
|
|
632
|
+
const existing = this.rooms.get(projectId);
|
|
633
|
+
if (existing) {
|
|
634
|
+
bootstrap.doc.destroy();
|
|
195
635
|
return existing;
|
|
196
|
-
|
|
636
|
+
}
|
|
197
637
|
const room = {
|
|
198
638
|
projectId,
|
|
199
|
-
doc,
|
|
200
|
-
awareness: new Awareness(doc),
|
|
201
|
-
meta: doc.getMap("texlite:meta"),
|
|
639
|
+
doc: bootstrap.doc,
|
|
640
|
+
awareness: new Awareness(bootstrap.doc),
|
|
641
|
+
meta: bootstrap.doc.getMap("texlite:meta"),
|
|
202
642
|
connections: new Set(),
|
|
203
643
|
awarenessOwners: new Map(),
|
|
204
644
|
allowedPaths: new Set(),
|
|
@@ -209,58 +649,68 @@ export class CollaborationService {
|
|
|
209
649
|
stateSaveTimer: null,
|
|
210
650
|
cleanupTimer: null,
|
|
211
651
|
lastModifiedUserId: null,
|
|
212
|
-
epoch:
|
|
652
|
+
epoch: bootstrap.epoch,
|
|
213
653
|
persistedRevision: 0,
|
|
214
|
-
persistedAt: new Date().toISOString()
|
|
654
|
+
persistedAt: new Date().toISOString(),
|
|
655
|
+
maintenanceReason: this.maintenanceProjects.get(projectId) ?? null,
|
|
656
|
+
snapshotBarrierDepth: this.snapshotBarriers.get(projectId) ?? 0,
|
|
657
|
+
snapshotFlushPending: false,
|
|
658
|
+
pendingFlushes: [],
|
|
659
|
+
rejectedPaths: new Set(),
|
|
660
|
+
compileMetaValidationPending: false
|
|
215
661
|
};
|
|
216
662
|
room.awareness.setLocalState(null);
|
|
217
|
-
|
|
218
|
-
|
|
663
|
+
// A recovered Yjs document may contain metadata from a process that no
|
|
664
|
+
// longer exists. The database/source tree are authoritative after a
|
|
665
|
+
// restart; clear the markers before any browser can sync them back.
|
|
666
|
+
const recoveredMetadataChanged = bootstrap.recoveredState && this.clearRecoveredMetadata(room);
|
|
667
|
+
this.sanitizeCompileStates(room);
|
|
219
668
|
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
669
|
const diskPaths = new Set();
|
|
231
|
-
doc.transact(() => {
|
|
232
|
-
for (const
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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);
|
|
670
|
+
room.doc.transact(() => {
|
|
671
|
+
for (const file of bootstrap.files) {
|
|
672
|
+
diskPaths.add(file.path);
|
|
673
|
+
room.allowedPaths.add(file.path);
|
|
674
|
+
room.persistedContent.set(file.path, file.content);
|
|
675
|
+
const name = typeName(file.path);
|
|
676
|
+
// Yjs decodes top-level shared types lazily; the source: namespace is
|
|
677
|
+
// the stable identifier before getText() materializes the public type.
|
|
678
|
+
const hasRecoveredText = bootstrap.recoveredState && room.doc.share.has(name);
|
|
679
|
+
const text = this.trackedText(room, file.path);
|
|
680
|
+
if (hasRecoveredText && text.toString() !== file.content) {
|
|
681
|
+
room.dirtyPaths.add(file.path);
|
|
247
682
|
recoveredDirty = true;
|
|
248
683
|
}
|
|
249
684
|
else {
|
|
250
|
-
replaceText(text, content);
|
|
685
|
+
replaceText(text, file.content);
|
|
251
686
|
}
|
|
252
687
|
}
|
|
253
|
-
for (const name of doc.share.keys()) {
|
|
688
|
+
for (const name of room.doc.share.keys()) {
|
|
254
689
|
if (!name.startsWith(SOURCE_PREFIX))
|
|
255
690
|
continue;
|
|
256
691
|
const filePath = name.slice(SOURCE_PREFIX.length);
|
|
257
692
|
if (!diskPaths.has(filePath))
|
|
258
|
-
replaceText(doc.getText(name), "");
|
|
693
|
+
replaceText(room.doc.getText(name), "");
|
|
259
694
|
}
|
|
260
695
|
}, DISK_ORIGIN);
|
|
261
|
-
|
|
696
|
+
const rejectedRecoveredPaths = this.rejectOversizedTexts(room);
|
|
697
|
+
recoveredDirty = room.dirtyPaths.size > 0;
|
|
698
|
+
room.meta.observe((_event, transaction) => {
|
|
699
|
+
if (isConnectionOrigin(transaction.origin))
|
|
700
|
+
room.compileMetaValidationPending = true;
|
|
701
|
+
});
|
|
702
|
+
room.doc.on("update", (update, origin) => {
|
|
262
703
|
this.broadcast(room, syncUpdateMessage(update), origin instanceof Object && "socket" in origin ? origin : null);
|
|
263
|
-
|
|
704
|
+
// Compile state, file-list revisions, and comment/dictionary revision
|
|
705
|
+
// markers are ephemeral metadata. They are reconstructed from SQLite
|
|
706
|
+
// and the source tree after a restart, so do not encode and synchronously
|
|
707
|
+
// rewrite the complete Yjs document for every metadata-only event.
|
|
708
|
+
if (origin !== META_ORIGIN)
|
|
709
|
+
this.scheduleStateSave(room);
|
|
710
|
+
if (room.compileMetaValidationPending) {
|
|
711
|
+
room.compileMetaValidationPending = false;
|
|
712
|
+
this.sanitizeCompileStates(room);
|
|
713
|
+
}
|
|
264
714
|
if (origin !== DISK_ORIGIN && origin !== HTTP_ORIGIN && origin !== META_ORIGIN) {
|
|
265
715
|
if (origin && typeof origin === "object" && "user" in origin) {
|
|
266
716
|
room.lastModifiedUserId = origin.user.id;
|
|
@@ -283,13 +733,65 @@ export class CollaborationService {
|
|
|
283
733
|
}
|
|
284
734
|
}
|
|
285
735
|
});
|
|
736
|
+
try {
|
|
737
|
+
// Persist a corrected state even when an oversized recovered document was
|
|
738
|
+
// reverted to the source file and no source write remains dirty.
|
|
739
|
+
if (recoveredDirty || rejectedRecoveredPaths.length > 0)
|
|
740
|
+
this.flushRoom(room);
|
|
741
|
+
else if (recoveredMetadataChanged)
|
|
742
|
+
this.scheduleStateSave(room);
|
|
743
|
+
}
|
|
744
|
+
catch (error) {
|
|
745
|
+
this.disposeRoom(room);
|
|
746
|
+
throw error;
|
|
747
|
+
}
|
|
286
748
|
this.rooms.set(projectId, room);
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
this.persistRoomState(room);
|
|
749
|
+
// A fresh room has no unsaved Yjs state yet. Delaying state persistence
|
|
750
|
+
// avoids a large synchronous write on the first connection; subsequent
|
|
751
|
+
// updates schedule the normal atomic save.
|
|
291
752
|
return room;
|
|
292
753
|
}
|
|
754
|
+
attachConnection(room, socket, user) {
|
|
755
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
756
|
+
return;
|
|
757
|
+
if (!accessibleProject(this.db, room.projectId, user)) {
|
|
758
|
+
socket.close(1008, "Project access denied");
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
const maintenanceReason = this.maintenanceProjects.get(room.projectId);
|
|
762
|
+
if (maintenanceReason) {
|
|
763
|
+
socket.close(1013, `Project is temporarily unavailable: ${maintenanceReason}`);
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
if (room.connections.size >= MAX_PROJECT_SESSIONS) {
|
|
767
|
+
socket.close(1013, "Project collaboration room is full");
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
if (room.cleanupTimer) {
|
|
771
|
+
clearTimeout(room.cleanupTimer);
|
|
772
|
+
room.cleanupTimer = null;
|
|
773
|
+
}
|
|
774
|
+
const connection = {
|
|
775
|
+
socket, user, awarenessClientId: null, protocolVerified: false, protocolTimer: null
|
|
776
|
+
};
|
|
777
|
+
room.connections.add(connection);
|
|
778
|
+
socket.binaryType = "arraybuffer";
|
|
779
|
+
socket.on("message", (data) => {
|
|
780
|
+
try {
|
|
781
|
+
this.handleMessage(room, connection, rawData(data));
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
socket.close(1003, "Invalid collaboration message");
|
|
785
|
+
}
|
|
786
|
+
});
|
|
787
|
+
socket.on("close", () => this.disconnect(room, connection));
|
|
788
|
+
socket.on("error", () => this.disconnect(room, connection));
|
|
789
|
+
connection.protocolTimer = setTimeout(() => {
|
|
790
|
+
if (!connection.protocolVerified)
|
|
791
|
+
socket.close(4001, "Reload required");
|
|
792
|
+
}, 10_000);
|
|
793
|
+
send(socket, protocolMessage(room.epoch));
|
|
794
|
+
}
|
|
293
795
|
trackedText(room, filePath) {
|
|
294
796
|
const text = room.doc.getText(typeName(filePath));
|
|
295
797
|
if (room.textObservers.has(filePath))
|
|
@@ -312,25 +814,50 @@ export class CollaborationService {
|
|
|
312
814
|
return text;
|
|
313
815
|
}
|
|
314
816
|
handleMessage(room, connection, bytes) {
|
|
315
|
-
const
|
|
817
|
+
const refreshedUser = this.db.prepare("SELECT * FROM users WHERE id = ?").get(connection.user.id);
|
|
818
|
+
if (!refreshedUser || refreshedUser.disabled) {
|
|
819
|
+
connection.socket.close(1008, "Project access revoked");
|
|
820
|
+
this.disconnect(room, connection);
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
connection.user = refreshedUser;
|
|
824
|
+
const current = accessibleProject(this.db, room.projectId, refreshedUser);
|
|
316
825
|
if (!current) {
|
|
317
826
|
connection.socket.close(1008, "Project access revoked");
|
|
318
827
|
return;
|
|
319
828
|
}
|
|
320
829
|
const decoder = decoding.createDecoder(bytes);
|
|
321
830
|
const messageType = decoding.readVarUint(decoder);
|
|
831
|
+
if (room.maintenanceReason && messageType !== MESSAGE_PROTOCOL && messageType !== MESSAGE_QUERY_AWARENESS) {
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
322
834
|
if (messageType === MESSAGE_PROTOCOL) {
|
|
323
835
|
const epoch = decoding.readVarString(decoder);
|
|
836
|
+
const clientProtocolVersion = decoding.hasContent(decoder)
|
|
837
|
+
? decoding.readVarUint(decoder)
|
|
838
|
+
: null;
|
|
324
839
|
if (epoch !== room.epoch) {
|
|
325
840
|
connection.socket.close(4001, "Collaboration state changed; reload required");
|
|
326
841
|
return;
|
|
327
842
|
}
|
|
843
|
+
// A pre-versioning client cannot safely decode the durable FLUSH
|
|
844
|
+
// response introduced in the previous release. Send a protocol marker
|
|
845
|
+
// that deliberately differs from the room epoch; the existing client
|
|
846
|
+
// handler treats it as a state change and reloads before it can edit.
|
|
847
|
+
// New clients append their version to the otherwise-compatible handshake.
|
|
848
|
+
if (clientProtocolVersion !== COLLABORATION_PROTOCOL_VERSION) {
|
|
849
|
+
send(connection.socket, protocolMessage(`${room.epoch}:reload`));
|
|
850
|
+
connection.socket.close(4001, "Collaboration protocol upgrade required");
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
328
853
|
if (!connection.protocolVerified) {
|
|
329
854
|
connection.protocolVerified = true;
|
|
330
855
|
if (connection.protocolTimer)
|
|
331
856
|
clearTimeout(connection.protocolTimer);
|
|
332
857
|
connection.protocolTimer = null;
|
|
858
|
+
this.sanitizeCompileStates(room);
|
|
333
859
|
this.sendSyncStep1(room, connection.socket);
|
|
860
|
+
this.sendCompileStates(room, connection.socket);
|
|
334
861
|
this.sendAwareness(room, connection.socket);
|
|
335
862
|
}
|
|
336
863
|
return;
|
|
@@ -344,10 +871,17 @@ export class CollaborationService {
|
|
|
344
871
|
if (syncType === syncProtocol.messageYjsSyncStep1) {
|
|
345
872
|
syncProtocol.readSyncStep1(decoder, encoder, room.doc);
|
|
346
873
|
}
|
|
347
|
-
else if (
|
|
348
|
-
|
|
874
|
+
else if (syncType === syncProtocol.messageYjsSyncStep2) {
|
|
875
|
+
// Read-only browsers may still carry a stale IndexedDB document. Do
|
|
876
|
+
// not apply their source updates, but always send the authoritative
|
|
877
|
+
// ephemeral compile state back to them.
|
|
878
|
+
if (canEdit(current))
|
|
349
879
|
syncProtocol.readSyncStep2(decoder, room.doc, connection);
|
|
350
|
-
|
|
880
|
+
this.sanitizeCompileStates(room);
|
|
881
|
+
this.sendCompileStates(room, connection.socket);
|
|
882
|
+
}
|
|
883
|
+
else if (canEdit(current)) {
|
|
884
|
+
if (syncType === syncProtocol.messageYjsUpdate)
|
|
351
885
|
syncProtocol.readUpdate(decoder, room.doc, connection);
|
|
352
886
|
}
|
|
353
887
|
if (encoding.length(encoder) > 1)
|
|
@@ -362,13 +896,24 @@ export class CollaborationService {
|
|
|
362
896
|
const requestId = decoding.readVarString(decoder).slice(0, 128);
|
|
363
897
|
if (!canEdit(current))
|
|
364
898
|
return;
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
899
|
+
if (room.snapshotBarrierDepth > 0 || (this.snapshotBarriers.get(room.projectId) ?? 0) > 0) {
|
|
900
|
+
// Do not report the intentionally deferred flush as a failure. The
|
|
901
|
+
// request is completed by endSnapshotBarrier() after the source tree
|
|
902
|
+
// is released and the pending edits have been persisted.
|
|
903
|
+
room.pendingFlushes.push({ connection, requestId });
|
|
904
|
+
room.snapshotFlushPending = room.dirtyPaths.size > 0 || room.snapshotFlushPending;
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
let receipt;
|
|
908
|
+
try {
|
|
909
|
+
receipt = this.flushRoom(room);
|
|
910
|
+
}
|
|
911
|
+
catch {
|
|
912
|
+
receipt = this.currentReceipt(room, false, [...room.dirtyPaths]);
|
|
913
|
+
if (room.dirtyPaths.size > 0)
|
|
914
|
+
this.scheduleSave(room);
|
|
915
|
+
}
|
|
916
|
+
this.sendFlushReceipt(connection, requestId, receipt);
|
|
372
917
|
return;
|
|
373
918
|
}
|
|
374
919
|
if (messageType !== MESSAGE_AWARENESS)
|
|
@@ -401,6 +946,7 @@ export class CollaborationService {
|
|
|
401
946
|
disconnect(room, connection) {
|
|
402
947
|
if (!room.connections.delete(connection))
|
|
403
948
|
return;
|
|
949
|
+
room.pendingFlushes = room.pendingFlushes.filter((pending) => pending.connection !== connection);
|
|
404
950
|
if (connection.protocolTimer)
|
|
405
951
|
clearTimeout(connection.protocolTimer);
|
|
406
952
|
connection.protocolTimer = null;
|
|
@@ -408,9 +954,7 @@ export class CollaborationService {
|
|
|
408
954
|
removeAwarenessStates(room.awareness, [connection.awarenessClientId], connection);
|
|
409
955
|
room.awarenessOwners.delete(connection.awarenessClientId);
|
|
410
956
|
}
|
|
411
|
-
|
|
412
|
-
room.cleanupTimer = setTimeout(() => this.destroyRoom(room), ROOM_IDLE_MS);
|
|
413
|
-
}
|
|
957
|
+
this.scheduleRoomCleanup(room);
|
|
414
958
|
}
|
|
415
959
|
sendSyncStep1(room, socket) {
|
|
416
960
|
const encoder = encoding.createEncoder();
|
|
@@ -428,6 +972,11 @@ export class CollaborationService {
|
|
|
428
972
|
if (connection !== except)
|
|
429
973
|
send(connection.socket, message);
|
|
430
974
|
}
|
|
975
|
+
scheduleRoomCleanup(room) {
|
|
976
|
+
if (room.connections.size === 0 && !room.cleanupTimer) {
|
|
977
|
+
room.cleanupTimer = setTimeout(() => this.destroyRoom(room), ROOM_IDLE_MS);
|
|
978
|
+
}
|
|
979
|
+
}
|
|
431
980
|
scheduleSave(room) {
|
|
432
981
|
if (room.saveTimer)
|
|
433
982
|
clearTimeout(room.saveTimer);
|
|
@@ -456,6 +1005,7 @@ export class CollaborationService {
|
|
|
456
1005
|
if (room.stateSaveTimer)
|
|
457
1006
|
clearTimeout(room.stateSaveTimer);
|
|
458
1007
|
room.stateSaveTimer = null;
|
|
1008
|
+
this.rejectOversizedTexts(room);
|
|
459
1009
|
const target = collaborationStatePath(this.config, room.projectId);
|
|
460
1010
|
const temporary = `${target}.tmp`;
|
|
461
1011
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
@@ -463,13 +1013,23 @@ export class CollaborationService {
|
|
|
463
1013
|
fs.renameSync(temporary, target);
|
|
464
1014
|
}
|
|
465
1015
|
flushRoom(room) {
|
|
1016
|
+
if (room.snapshotBarrierDepth > 0 || (this.snapshotBarriers.get(room.projectId) ?? 0) > 0) {
|
|
1017
|
+
if (room.saveTimer)
|
|
1018
|
+
clearTimeout(room.saveTimer);
|
|
1019
|
+
room.saveTimer = null;
|
|
1020
|
+
if (room.dirtyPaths.size > 0)
|
|
1021
|
+
room.snapshotFlushPending = true;
|
|
1022
|
+
return this.currentReceipt(room, room.dirtyPaths.size === 0, []);
|
|
1023
|
+
}
|
|
466
1024
|
const startedAt = performance.now();
|
|
467
1025
|
if (room.saveTimer)
|
|
468
1026
|
clearTimeout(room.saveTimer);
|
|
469
1027
|
room.saveTimer = null;
|
|
1028
|
+
this.rejectOversizedTexts(room);
|
|
470
1029
|
this.persistRoomState(room);
|
|
471
1030
|
let changed = false;
|
|
472
1031
|
const changedPaths = [];
|
|
1032
|
+
const failedPaths = [...room.rejectedPaths];
|
|
473
1033
|
const dirtyPaths = [...room.dirtyPaths];
|
|
474
1034
|
for (const filePath of dirtyPaths) {
|
|
475
1035
|
if (!room.allowedPaths.has(filePath)) {
|
|
@@ -482,11 +1042,18 @@ export class CollaborationService {
|
|
|
482
1042
|
room.dirtyPaths.delete(filePath);
|
|
483
1043
|
continue;
|
|
484
1044
|
}
|
|
485
|
-
if (Buffer.byteLength(next, "utf8") > this.config
|
|
486
|
-
|
|
1045
|
+
if (Buffer.byteLength(next, "utf8") > maxCollaborativeFileBytes(this.config)) {
|
|
1046
|
+
failedPaths.push(filePath);
|
|
1047
|
+
continue;
|
|
1048
|
+
}
|
|
487
1049
|
const absolute = resolveSourcePath(this.config, room.projectId, filePath);
|
|
488
|
-
|
|
489
|
-
|
|
1050
|
+
try {
|
|
1051
|
+
fs.mkdirSync(path.dirname(absolute), { recursive: true, mode: 0o700 });
|
|
1052
|
+
}
|
|
1053
|
+
catch {
|
|
1054
|
+
failedPaths.push(filePath);
|
|
1055
|
+
continue;
|
|
1056
|
+
}
|
|
490
1057
|
const temporary = `${absolute}.collaboration-${process.pid}-${randomUUID()}.tmp`;
|
|
491
1058
|
try {
|
|
492
1059
|
fs.writeFileSync(temporary, next, { encoding: "utf8", mode: 0o600 });
|
|
@@ -494,7 +1061,8 @@ export class CollaborationService {
|
|
|
494
1061
|
}
|
|
495
1062
|
catch (error) {
|
|
496
1063
|
fs.rmSync(temporary, { force: true });
|
|
497
|
-
|
|
1064
|
+
failedPaths.push(filePath);
|
|
1065
|
+
continue;
|
|
498
1066
|
}
|
|
499
1067
|
room.persistedContent.set(filePath, next);
|
|
500
1068
|
room.dirtyPaths.delete(filePath);
|
|
@@ -517,9 +1085,34 @@ export class CollaborationService {
|
|
|
517
1085
|
}
|
|
518
1086
|
catch { /* Source durability must not depend on optional history bookkeeping. */ }
|
|
519
1087
|
}
|
|
520
|
-
room.
|
|
521
|
-
|
|
522
|
-
|
|
1088
|
+
const ok = failedPaths.length === 0 && room.dirtyPaths.size === 0;
|
|
1089
|
+
if (changed)
|
|
1090
|
+
room.persistedRevision += 1;
|
|
1091
|
+
if (ok) {
|
|
1092
|
+
room.persistedAt = new Date().toISOString();
|
|
1093
|
+
}
|
|
1094
|
+
else if (!room.saveTimer && room.dirtyPaths.size > 0) {
|
|
1095
|
+
// Retry with backoff if dirty files remain unpersisted
|
|
1096
|
+
room.saveTimer = setTimeout(() => {
|
|
1097
|
+
try {
|
|
1098
|
+
this.flushRoom(room);
|
|
1099
|
+
}
|
|
1100
|
+
catch {
|
|
1101
|
+
room.saveTimer = null;
|
|
1102
|
+
}
|
|
1103
|
+
}, 2000);
|
|
1104
|
+
}
|
|
1105
|
+
const receipt = this.currentReceipt(room, ok, [...new Set(failedPaths)]);
|
|
1106
|
+
room.rejectedPaths.clear();
|
|
1107
|
+
return receipt;
|
|
1108
|
+
}
|
|
1109
|
+
currentReceipt(room, ok = true, failedPaths = []) {
|
|
1110
|
+
return {
|
|
1111
|
+
revision: room.persistedRevision,
|
|
1112
|
+
persistedAt: room.persistedAt,
|
|
1113
|
+
ok: ok && room.dirtyPaths.size === 0,
|
|
1114
|
+
failedPaths
|
|
1115
|
+
};
|
|
523
1116
|
}
|
|
524
1117
|
bumpFiles(room, event) {
|
|
525
1118
|
room.doc.transact(() => room.meta.set("filesEvent", { ...event, revision: randomUUID() }), META_ORIGIN);
|
|
@@ -527,13 +1120,6 @@ export class CollaborationService {
|
|
|
527
1120
|
destroyRoom(room, persist = true) {
|
|
528
1121
|
if (this.rooms.get(room.projectId) !== room)
|
|
529
1122
|
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
1123
|
if (persist) {
|
|
538
1124
|
try {
|
|
539
1125
|
this.flushRoom(room);
|
|
@@ -545,18 +1131,74 @@ export class CollaborationService {
|
|
|
545
1131
|
catch { /* Keep shutdown best-effort. */ }
|
|
546
1132
|
}
|
|
547
1133
|
}
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
1134
|
+
this.disposeRoom(room);
|
|
1135
|
+
this.rooms.delete(room.projectId);
|
|
1136
|
+
}
|
|
1137
|
+
disposeRoom(room) {
|
|
1138
|
+
if (room.cleanupTimer)
|
|
1139
|
+
clearTimeout(room.cleanupTimer);
|
|
1140
|
+
if (room.saveTimer)
|
|
1141
|
+
clearTimeout(room.saveTimer);
|
|
1142
|
+
if (room.stateSaveTimer)
|
|
1143
|
+
clearTimeout(room.stateSaveTimer);
|
|
1144
|
+
for (const connection of room.connections) {
|
|
1145
|
+
if (connection.protocolTimer)
|
|
1146
|
+
clearTimeout(connection.protocolTimer);
|
|
1147
|
+
connection.protocolTimer = null;
|
|
553
1148
|
}
|
|
1149
|
+
room.connections.clear();
|
|
1150
|
+
room.pendingFlushes.splice(0);
|
|
1151
|
+
room.rejectedPaths.clear();
|
|
1152
|
+
room.awarenessOwners.clear();
|
|
554
1153
|
for (const [filePath, observer] of room.textObservers) {
|
|
555
1154
|
room.doc.getText(typeName(filePath)).unobserve(observer);
|
|
556
1155
|
}
|
|
557
1156
|
room.awareness.destroy();
|
|
558
1157
|
room.doc.destroy();
|
|
559
|
-
|
|
1158
|
+
}
|
|
1159
|
+
rejectOversizedTexts(room) {
|
|
1160
|
+
const rejected = [];
|
|
1161
|
+
const limit = maxCollaborativeFileBytes(this.config);
|
|
1162
|
+
for (const filePath of [...room.dirtyPaths]) {
|
|
1163
|
+
const text = this.trackedText(room, filePath);
|
|
1164
|
+
if (Buffer.byteLength(text.toString(), "utf8") <= limit)
|
|
1165
|
+
continue;
|
|
1166
|
+
const previous = room.persistedContent.get(filePath) ?? "";
|
|
1167
|
+
room.doc.transact(() => replaceText(text, previous), DISK_ORIGIN);
|
|
1168
|
+
room.dirtyPaths.delete(filePath);
|
|
1169
|
+
room.rejectedPaths.add(filePath);
|
|
1170
|
+
rejected.push(filePath);
|
|
1171
|
+
}
|
|
1172
|
+
return rejected;
|
|
1173
|
+
}
|
|
1174
|
+
sendFlushReceipt(connection, requestId, receipt) {
|
|
1175
|
+
const encoder = encoding.createEncoder();
|
|
1176
|
+
encoding.writeVarUint(encoder, MESSAGE_FLUSH);
|
|
1177
|
+
encoding.writeVarString(encoder, requestId);
|
|
1178
|
+
encoding.writeVarUint(encoder, receipt.ok ? 1 : 0);
|
|
1179
|
+
encoding.writeVarUint(encoder, receipt.revision);
|
|
1180
|
+
encoding.writeVarString(encoder, receipt.persistedAt);
|
|
1181
|
+
encoding.writeVarUint(encoder, receipt.failedPaths?.length ?? 0);
|
|
1182
|
+
for (const failed of receipt.failedPaths ?? [])
|
|
1183
|
+
encoding.writeVarString(encoder, failed);
|
|
1184
|
+
send(connection.socket, encoding.toUint8Array(encoder));
|
|
1185
|
+
}
|
|
1186
|
+
resolvePendingFlushes(room, receipt) {
|
|
1187
|
+
const pending = room.pendingFlushes.splice(0);
|
|
1188
|
+
for (const request of pending) {
|
|
1189
|
+
if (room.connections.has(request.connection))
|
|
1190
|
+
this.sendFlushReceipt(request.connection, request.requestId, receipt);
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1193
|
+
projectGeneration(projectId) {
|
|
1194
|
+
return this.projectGenerations.get(projectId) ?? 0;
|
|
1195
|
+
}
|
|
1196
|
+
pendingConnectionKey(projectId, generation) {
|
|
1197
|
+
return `${projectId}\0${generation}`;
|
|
1198
|
+
}
|
|
1199
|
+
invalidateProject(projectId) {
|
|
1200
|
+
this.projectGenerations.set(projectId, this.projectGeneration(projectId) + 1);
|
|
1201
|
+
this.roomInitializations.delete(projectId);
|
|
560
1202
|
}
|
|
561
1203
|
}
|
|
562
1204
|
export function collaborationStatePath(config, projectId) {
|
|
@@ -565,23 +1207,42 @@ export function collaborationStatePath(config, projectId) {
|
|
|
565
1207
|
export function collaborationEpochPath(config, projectId) {
|
|
566
1208
|
return path.join(outputRoot(config, projectId), ".texlite", "collaboration.epoch");
|
|
567
1209
|
}
|
|
568
|
-
function
|
|
1210
|
+
export function maxCollaborativeFileBytes(config) {
|
|
1211
|
+
return Math.min(config.maxUploadBytes, MAX_COLLABORATIVE_FILE_BYTES);
|
|
1212
|
+
}
|
|
1213
|
+
export function isCollaborativeTextFile(filePath) {
|
|
1214
|
+
return /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
|
|
1215
|
+
}
|
|
1216
|
+
function collaborativeEntrySignature(entry) {
|
|
1217
|
+
return `${entry.path}\0${entry.size ?? 0}\0${entry.mtimeMs ?? 0}`;
|
|
1218
|
+
}
|
|
1219
|
+
async function collaborationEpochAsync(config, projectId, recoveredState) {
|
|
569
1220
|
const target = collaborationEpochPath(config, projectId);
|
|
570
|
-
if (recoveredState
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
1221
|
+
if (recoveredState) {
|
|
1222
|
+
try {
|
|
1223
|
+
const existing = (await fs.promises.readFile(target, "utf8")).trim();
|
|
1224
|
+
if (new RegExp(`^${COLLABORATION_PROTOCOL_VERSION}:[a-f0-9-]{36}$`, "i").test(existing))
|
|
1225
|
+
return existing;
|
|
1226
|
+
}
|
|
1227
|
+
catch (error) {
|
|
1228
|
+
if (error.code !== "ENOENT")
|
|
1229
|
+
throw error;
|
|
1230
|
+
// Generate a fresh epoch when the previous marker is missing.
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
const epoch = `${VERSIONED_EPOCH_PREFIX}${randomUUID()}`;
|
|
1234
|
+
const temporary = `${target}.${process.pid}-${randomUUID()}.tmp`;
|
|
1235
|
+
await fs.promises.mkdir(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
1236
|
+
try {
|
|
1237
|
+
await fs.promises.writeFile(temporary, epoch, { encoding: "utf8", mode: 0o600 });
|
|
1238
|
+
await fs.promises.rename(temporary, target);
|
|
1239
|
+
}
|
|
1240
|
+
catch (error) {
|
|
1241
|
+
await fs.promises.rm(temporary, { force: true });
|
|
1242
|
+
throw error;
|
|
574
1243
|
}
|
|
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
1244
|
return epoch;
|
|
581
1245
|
}
|
|
582
|
-
function isCollaborativeTextFile(filePath) {
|
|
583
|
-
return /(?:\.tex|\.bib|\.sty|\.cls|\.txt|\.md|latexmkrc)$/i.test(filePath);
|
|
584
|
-
}
|
|
585
1246
|
function typeName(filePath) {
|
|
586
1247
|
return `${SOURCE_PREFIX}${filePath}`;
|
|
587
1248
|
}
|
|
@@ -611,6 +1272,36 @@ function protocolMessage(epoch) {
|
|
|
611
1272
|
encoding.writeVarString(encoder, epoch);
|
|
612
1273
|
return encoding.toUint8Array(encoder);
|
|
613
1274
|
}
|
|
1275
|
+
function isConnectionOrigin(origin) {
|
|
1276
|
+
return Boolean(origin && typeof origin === "object" && "socket" in origin);
|
|
1277
|
+
}
|
|
1278
|
+
function isSharedCompileState(mainFile, value) {
|
|
1279
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1280
|
+
return false;
|
|
1281
|
+
const state = value;
|
|
1282
|
+
const requestedBy = state.requestedBy;
|
|
1283
|
+
return state.mainFile === mainFile
|
|
1284
|
+
&& typeof state.runId === "string" && state.runId.length > 0
|
|
1285
|
+
&& (state.status === "queued" || state.status === "running" || state.status === "succeeded"
|
|
1286
|
+
|| state.status === "failed" || state.status === "cleaned")
|
|
1287
|
+
&& (state.status !== "cleaned" || state.cleanMode === "cache" || state.cleanMode === "artifacts")
|
|
1288
|
+
&& (state.stale === undefined || typeof state.stale === "boolean")
|
|
1289
|
+
&& typeof state.updatedAt === "string"
|
|
1290
|
+
&& Boolean(requestedBy && typeof requestedBy === "object"
|
|
1291
|
+
&& typeof requestedBy.id === "string" && typeof requestedBy.username === "string"
|
|
1292
|
+
&& typeof requestedBy.name === "string");
|
|
1293
|
+
}
|
|
1294
|
+
function isCompileStateMap(value) {
|
|
1295
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1296
|
+
return false;
|
|
1297
|
+
return Object.entries(value).every(([mainFile, state]) => isSharedCompileState(mainFile, state));
|
|
1298
|
+
}
|
|
1299
|
+
function maintenanceMessage(reason) {
|
|
1300
|
+
const encoder = encoding.createEncoder();
|
|
1301
|
+
encoding.writeVarUint(encoder, MESSAGE_MAINTENANCE);
|
|
1302
|
+
encoding.writeVarString(encoder, reason);
|
|
1303
|
+
return encoding.toUint8Array(encoder);
|
|
1304
|
+
}
|
|
614
1305
|
function awarenessClientIds(update) {
|
|
615
1306
|
const decoder = decoding.createDecoder(update);
|
|
616
1307
|
const count = decoding.readVarUint(decoder);
|