texlite 0.7.5 → 0.7.7

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.
Files changed (68) hide show
  1. package/README.md +19 -3
  2. package/README.zh-CN.md +8 -3
  3. package/dist/client/assets/{GitDialog-pn8n5Afm.js → GitDialog-Bm5yMpQK.js} +2 -2
  4. package/dist/client/assets/HistoryDialog-Po4KHT_2.js +5 -0
  5. package/dist/client/assets/LatexEditor-CnKCXaHr.js +59 -0
  6. package/dist/client/assets/PdfPreview-Cd20VIjT.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-CaovMu4V.js → ProjectNavigationDialogs-BDOtIoTy.js} +1 -1
  8. package/dist/client/assets/SystemMetricsDialog-DdOlcqqK.js +1 -0
  9. package/dist/client/assets/index-MDwr9W9R.css +1 -0
  10. package/dist/client/assets/index-zIjCeZFK.js +64 -0
  11. package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
  12. package/dist/client/assets/minus-O6pK-6mA.js +1 -0
  13. package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
  14. package/dist/client/assets/spellCheck-Ce3o_xO-.js +4 -0
  15. package/dist/client/index.html +3 -3
  16. package/dist/server/anchors.js +22 -1
  17. package/dist/server/app.js +103 -2335
  18. package/dist/server/auth.js +3 -2
  19. package/dist/server/cli.js +8 -2
  20. package/dist/server/collaboration.js +283 -11
  21. package/dist/server/compileArtifacts.js +10 -7
  22. package/dist/server/compiler.js +9 -4
  23. package/dist/server/config.js +25 -1
  24. package/dist/server/environment.js +5 -1
  25. package/dist/server/files.js +7 -6
  26. package/dist/server/git.js +28 -39
  27. package/dist/server/harper.js +144 -0
  28. package/dist/server/harperRuntime.js +38 -0
  29. package/dist/server/harperWorker.js +15 -0
  30. package/dist/server/history.js +11 -9
  31. package/dist/server/http.js +33 -13
  32. package/dist/server/i18n.js +276 -0
  33. package/dist/server/latexRoot.js +78 -0
  34. package/dist/server/limits.js +9 -0
  35. package/dist/server/projectSearch.js +3 -4
  36. package/dist/server/routes/auth.js +77 -0
  37. package/dist/server/routes/citations.js +331 -0
  38. package/dist/server/routes/collaboration.js +17 -0
  39. package/dist/server/routes/comments.js +176 -0
  40. package/dist/server/routes/compile.js +71 -50
  41. package/dist/server/routes/projectFiles.js +572 -0
  42. package/dist/server/routes/projectGit.js +145 -0
  43. package/dist/server/routes/projectHistory.js +150 -0
  44. package/dist/server/routes/projectMembers.js +112 -0
  45. package/dist/server/routes/projectShared.js +238 -0
  46. package/dist/server/routes/projects.js +473 -0
  47. package/dist/server/routes/system.js +37 -0
  48. package/dist/server/routes/users.js +163 -0
  49. package/dist/server/security.js +2 -4
  50. package/dist/server/synctex.js +7 -4
  51. package/dist/server/zip.js +29 -13
  52. package/package.json +3 -3
  53. package/texlite.config.example.json +5 -1
  54. package/dist/client/assets/BinaryModule-Aj1vLnwf-CKY6Y_Pt.js +0 -4
  55. package/dist/client/assets/HistoryDialog-BpWEZrYf.js +0 -1
  56. package/dist/client/assets/LatexEditor-DNAmNvHu.js +0 -59
  57. package/dist/client/assets/PdfPreview-BVaG7TtG.js +0 -1
  58. package/dist/client/assets/SystemMetricsDialog-D8jj7epG.js +0 -1
  59. package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  60. package/dist/client/assets/binary-BoldOm0z.js +0 -1
  61. package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
  62. package/dist/client/assets/index-BQCB3z5H.js +0 -64
  63. package/dist/client/assets/index-BnvpaWsa.css +0 -1
  64. package/dist/client/assets/index-Byn2Miy9.js +0 -3456
  65. package/dist/client/assets/index-rBWMBTI-.js +0 -5
  66. package/dist/client/assets/minus-K-5IK4nL.js +0 -1
  67. package/dist/client/assets/spellCheck-C32VQVUE.js +0 -5
  68. package/dist/client/assets/texFmtRuntime-EYyQNUnJ.js +0 -2
@@ -1,3 +1,4 @@
1
+ import { apiError } from "./http.js";
1
2
  import { digestToken } from "./security.js";
2
3
  export function publicUser(user) {
3
4
  return {
@@ -36,7 +37,7 @@ export function currentUser(request, db) {
36
37
  export function requireUser(request, reply, db) {
37
38
  const user = currentUser(request, db);
38
39
  if (!user) {
39
- void reply.code(401).send({ code: "AUTH_REQUIRED", error: "请先登录" });
40
+ void apiError(reply, 401, "AUTH_REQUIRED");
40
41
  return null;
41
42
  }
42
43
  return user;
@@ -46,7 +47,7 @@ export function requireAdmin(request, reply, db) {
46
47
  if (!user)
47
48
  return null;
48
49
  if (user.role !== "admin") {
49
- void reply.code(403).send({ code: "ADMIN_REQUIRED", error: "需要管理员权限" });
50
+ void apiError(reply, 403, "ADMIN_REQUIRED");
50
51
  return null;
51
52
  }
52
53
  return user;
@@ -7,7 +7,7 @@ import { stdin, stdout } from "node:process";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { CONFIG_DEFAULTS, loadConfig } from "./config.js";
9
9
  import { activeAdminCount, openDatabase } from "./db.js";
10
- import { hashPassword } from "./security.js";
10
+ import { hashPassword, MIN_PASSWORD_LENGTH } from "./security.js";
11
11
  import { assertEnvironment, assertGitAvailable } from "./environment.js";
12
12
  import { serve } from "./index.js";
13
13
  import { processStatus, restartManaged, startManaged, stopManaged, streamLogs, waitForOnline } from "./pm2.js";
@@ -110,6 +110,10 @@ function writeInitialConfig(configPath, siteName, adminEmail) {
110
110
  server: { host: CONFIG_DEFAULTS.host, port: CONFIG_DEFAULTS.port },
111
111
  storage: { dataDir: dataDirectory },
112
112
  uploads: { maxFileSizeMB: CONFIG_DEFAULTS.maxFileSizeMB },
113
+ pdf: {
114
+ loadingStrategy: CONFIG_DEFAULTS.pdfLoadingStrategy,
115
+ rangeThresholdMB: CONFIG_DEFAULTS.pdfRangeThresholdMB
116
+ },
113
117
  history: {
114
118
  maxVersions: CONFIG_DEFAULTS.historyMaxVersions,
115
119
  maxStorageMB: CONFIG_DEFAULTS.historyMaxStorageMB
@@ -172,7 +176,7 @@ async function initialize(options) {
172
176
  const displayName = process.env.TEXLITE_INIT_DISPLAY_NAME
173
177
  ?? (rl ? (await rl.question("Administrator display name [Administrator]: ")).trim() || "Administrator" : "Administrator");
174
178
  const password = process.env.TEXLITE_INIT_PASSWORD
175
- ?? (rl ? await rl.question("Administrator password (at least 8 characters; input is visible): ") : "");
179
+ ?? (rl ? await rl.question(`Administrator password (at least ${MIN_PASSWORD_LENGTH} characters; input is visible): `) : "");
176
180
  if (!password)
177
181
  throw new Error("Non-interactive initialization requires TEXLITE_INIT_PASSWORD to be set.");
178
182
  const timestamp = new Date().toISOString();
@@ -234,6 +238,8 @@ async function printConfig(options) {
234
238
  defaultEngine: config.defaultEngine,
235
239
  allowedEngines: config.allowedEngines,
236
240
  maxUploadBytes: config.maxUploadBytes,
241
+ pdfLoadingStrategy: config.pdfLoadingStrategy,
242
+ pdfRangeThresholdMB: config.pdfRangeThresholdBytes / (1024 * 1024),
237
243
  historyMaxVersions: config.historyMaxVersions,
238
244
  historyMaxStorageBytes: config.historyMaxStorageBytes,
239
245
  git: config.git
@@ -22,12 +22,15 @@ const MESSAGE_PERMISSION = 7;
22
22
  // Source text still uses the normal Yjs sync protocol; this message prevents
23
23
  // an old IndexedDB metadata entry from being treated as an active compile.
24
24
  const MESSAGE_COMPILE_STATES = 8;
25
+ // Ephemeral, per-file formatter leases. These are deliberately kept out of
26
+ // the Yjs document and source persistence state.
27
+ const MESSAGE_FORMAT_LEASE = 9;
25
28
  /**
26
29
  * Increment this when a wire-level collaboration change cannot be decoded by
27
30
  * an older browser. The epoch marker is rotated at the same time, which makes
28
31
  * already-open older pages discard their local draft and reload safely.
29
32
  */
30
- export const COLLABORATION_PROTOCOL_VERSION = 2;
33
+ export const COLLABORATION_PROTOCOL_VERSION = 3;
31
34
  const VERSIONED_EPOCH_PREFIX = `${COLLABORATION_PROTOCOL_VERSION}:`;
32
35
  const SOURCE_PREFIX = "source:";
33
36
  const MAX_PROJECT_SESSIONS = 10;
@@ -38,6 +41,12 @@ const META_ORIGIN = Symbol("meta");
38
41
  const SAVE_DELAY_MS = 750;
39
42
  const STATE_SAVE_DELAY_MS = 750;
40
43
  const ROOM_IDLE_MS = 30_000;
44
+ const FORMAT_LEASE_TTL_MS = 45_000;
45
+ const MAX_FORMAT_LEASE_WAITERS = MAX_PROJECT_SESSIONS * 2;
46
+ // Completion notifications are useful only to browsers that were already
47
+ // present when the operation finished. The database and retained manifests
48
+ // are the authority for a later workspace open.
49
+ const COMPLETED_COMPILE_STATE_TTL_MS = 60_000;
41
50
  const EPHEMERAL_META_KEYS = ["compileStates", "filesEvent", "commentsRevision", "dictionaryRevision"];
42
51
  const COLORS = [
43
52
  ["#1677c8", "#1677c833"], ["#d65745", "#d6574533"], ["#16866a", "#16866a33"],
@@ -262,6 +271,11 @@ export class CollaborationService {
262
271
  const room = this.rooms.get(projectId);
263
272
  if (!room)
264
273
  return;
274
+ if (permission === "read" || permission === "revoked") {
275
+ const connectionIds = new Set([...room.connections].filter((connection) => connection.user.id === userId));
276
+ for (const connection of connectionIds)
277
+ this.releaseFormatLeasesForConnection(room, connection);
278
+ }
265
279
  const encoder = encoding.createEncoder();
266
280
  encoding.writeVarUint(encoder, MESSAGE_PERMISSION);
267
281
  encoding.writeVarString(encoder, userId);
@@ -403,13 +417,10 @@ export class CollaborationService {
403
417
  room.doc.transact(() => room.meta.set("compileStates", retained), META_ORIGIN);
404
418
  }
405
419
  /**
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.
420
+ * Keep collaboration compile metadata strictly ephemeral and database
421
+ * backed. A browser can replay old IndexedDB updates after reconnecting;
422
+ * completed states must therefore not override the retained-PDF lookup for
423
+ * a newly opened workspace.
413
424
  */
414
425
  sanitizeCompileStates(room) {
415
426
  const current = room.meta.get("compileStates");
@@ -420,19 +431,42 @@ export class CollaborationService {
420
431
  }
421
432
  const retained = {};
422
433
  let changed = false;
434
+ const checkedAt = Date.now();
435
+ const findRun = this.db.prepare(`SELECT id, status, main_file FROM compile_runs
436
+ WHERE id = ? AND project_id = ?`);
437
+ const findLatestRun = this.db.prepare(`SELECT id, status FROM compile_runs
438
+ WHERE project_id = ? AND main_file = ? ORDER BY created_at DESC LIMIT 1`);
423
439
  for (const [mainFile, value] of Object.entries(current)) {
424
440
  if (!isSharedCompileState(mainFile, value)) {
425
441
  changed = true;
426
442
  continue;
427
443
  }
428
444
  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);
445
+ const run = findRun.get(value.runId, room.projectId);
431
446
  if (!run || run.main_file !== mainFile || (run.status !== "queued" && run.status !== "running")) {
432
447
  changed = true;
433
448
  continue;
434
449
  }
435
450
  }
451
+ else {
452
+ const updatedAt = Date.parse(value.updatedAt);
453
+ const fresh = Number.isFinite(updatedAt)
454
+ && updatedAt <= checkedAt + COMPLETED_COMPILE_STATE_TTL_MS
455
+ && checkedAt - updatedAt <= COMPLETED_COMPILE_STATE_TTL_MS;
456
+ if (!fresh) {
457
+ changed = true;
458
+ continue;
459
+ }
460
+ if (value.status !== "cleaned") {
461
+ const run = findRun.get(value.runId, room.projectId);
462
+ const latest = findLatestRun.get(room.projectId, mainFile);
463
+ if (!run || run.main_file !== mainFile || run.status !== value.status
464
+ || !latest || latest.id !== value.runId || latest.status !== value.status) {
465
+ changed = true;
466
+ continue;
467
+ }
468
+ }
469
+ }
436
470
  retained[mainFile] = value;
437
471
  }
438
472
  if (!changed && Object.keys(retained).length === Object.keys(current).length)
@@ -657,7 +691,9 @@ export class CollaborationService {
657
691
  snapshotFlushPending: false,
658
692
  pendingFlushes: [],
659
693
  rejectedPaths: new Set(),
660
- compileMetaValidationPending: false
694
+ compileMetaValidationPending: false,
695
+ formatLeases: new Map(),
696
+ formatLeaseWaiters: new Map()
661
697
  };
662
698
  room.awareness.setLocalState(null);
663
699
  // A recovered Yjs document may contain metadata from a process that no
@@ -859,6 +895,7 @@ export class CollaborationService {
859
895
  this.sendSyncStep1(room, connection.socket);
860
896
  this.sendCompileStates(room, connection.socket);
861
897
  this.sendAwareness(room, connection.socket);
898
+ this.sendFormatLeaseStates(room, connection);
862
899
  }
863
900
  return;
864
901
  }
@@ -916,6 +953,10 @@ export class CollaborationService {
916
953
  this.sendFlushReceipt(connection, requestId, receipt);
917
954
  return;
918
955
  }
956
+ if (messageType === MESSAGE_FORMAT_LEASE) {
957
+ this.handleFormatLeaseMessage(room, connection, decoder, current);
958
+ return;
959
+ }
919
960
  if (messageType !== MESSAGE_AWARENESS)
920
961
  return;
921
962
  const update = decoding.readVarUint8Array(decoder);
@@ -946,6 +987,7 @@ export class CollaborationService {
946
987
  disconnect(room, connection) {
947
988
  if (!room.connections.delete(connection))
948
989
  return;
990
+ this.releaseFormatLeasesForConnection(room, connection);
949
991
  room.pendingFlushes = room.pendingFlushes.filter((pending) => pending.connection !== connection);
950
992
  if (connection.protocolTimer)
951
993
  clearTimeout(connection.protocolTimer);
@@ -972,6 +1014,219 @@ export class CollaborationService {
972
1014
  if (connection !== except)
973
1015
  send(connection.socket, message);
974
1016
  }
1017
+ /**
1018
+ * Serialize access to one source file while a browser-side formatter is
1019
+ * calculating and applying its replacement. This is intentionally an
1020
+ * in-memory room primitive: the lease is advisory to the live collaboration
1021
+ * room, while the Yjs update + flush ordering remains the authoritative
1022
+ * durability boundary. A disconnected holder can never keep a lease alive.
1023
+ */
1024
+ handleFormatLeaseMessage(room, connection, decoder, currentProject) {
1025
+ const operation = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 16) : "";
1026
+ const requestId = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 128) : "";
1027
+ const rawPath = decoding.hasContent(decoder) ? decoding.readVarString(decoder) : "";
1028
+ const token = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 128) : "";
1029
+ let filePath;
1030
+ try {
1031
+ filePath = safeRelativePath(rawPath);
1032
+ }
1033
+ catch {
1034
+ this.sendFormatLeaseResponse(connection, "denied", requestId, rawPath, "invalid path");
1035
+ return;
1036
+ }
1037
+ if (!requestId || !isCollaborativeTextFile(filePath) || !room.allowedPaths.has(filePath)) {
1038
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "file is not format-able");
1039
+ return;
1040
+ }
1041
+ if (!canEdit(currentProject)) {
1042
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "write permission is required");
1043
+ return;
1044
+ }
1045
+ if (operation === "acquire") {
1046
+ const current = this.activeFormatLease(room, filePath);
1047
+ if (!current) {
1048
+ // Expiring a lease may have immediately granted the next queued
1049
+ // request. Do not overwrite that grant with this new request.
1050
+ if (room.formatLeases.has(filePath)) {
1051
+ const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
1052
+ if (waiters.length >= MAX_FORMAT_LEASE_WAITERS) {
1053
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "too many formatters are waiting");
1054
+ return;
1055
+ }
1056
+ waiters.push({ path: filePath, requestId, connection });
1057
+ room.formatLeaseWaiters.set(filePath, waiters);
1058
+ return;
1059
+ }
1060
+ this.grantFormatLease(room, { path: filePath, requestId, connection });
1061
+ return;
1062
+ }
1063
+ if (current.connection === connection) {
1064
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "this session already holds the lease");
1065
+ return;
1066
+ }
1067
+ const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
1068
+ if (waiters.some((waiter) => waiter.connection === connection && waiter.requestId === requestId))
1069
+ return;
1070
+ if (waiters.length >= MAX_FORMAT_LEASE_WAITERS) {
1071
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "too many formatters are waiting");
1072
+ return;
1073
+ }
1074
+ waiters.push({ path: filePath, requestId, connection });
1075
+ room.formatLeaseWaiters.set(filePath, waiters);
1076
+ return;
1077
+ }
1078
+ if (operation === "renew") {
1079
+ const lease = this.activeFormatLease(room, filePath);
1080
+ if (!lease || lease.connection !== connection || lease.token !== token) {
1081
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "format lease is no longer valid");
1082
+ return;
1083
+ }
1084
+ this.extendFormatLease(room, lease);
1085
+ this.sendFormatLeaseResponse(connection, "renewed", requestId, filePath, "", lease.expiresAt, lease.token);
1086
+ this.broadcastFormatLeaseState(room, lease);
1087
+ return;
1088
+ }
1089
+ if (operation === "release") {
1090
+ const lease = this.activeFormatLease(room, filePath);
1091
+ if (!lease || lease.connection !== connection || lease.token !== token) {
1092
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "format lease is no longer valid");
1093
+ return;
1094
+ }
1095
+ this.releaseFormatLease(room, lease);
1096
+ this.sendFormatLeaseResponse(connection, "released", requestId, filePath);
1097
+ return;
1098
+ }
1099
+ if (operation === "cancel") {
1100
+ const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
1101
+ const remaining = waiters.filter((waiter) => !(waiter.connection === connection && waiter.requestId === requestId));
1102
+ if (remaining.length)
1103
+ room.formatLeaseWaiters.set(filePath, remaining);
1104
+ else
1105
+ room.formatLeaseWaiters.delete(filePath);
1106
+ // A timeout may race with the server granting the request. In that
1107
+ // window the request is no longer in the waiter queue, so also revoke a
1108
+ // matching active lease instead of leaving it until the TTL expires.
1109
+ const active = this.activeFormatLease(room, filePath);
1110
+ if (active?.connection === connection && active.requestId === requestId) {
1111
+ this.releaseFormatLease(room, active);
1112
+ }
1113
+ this.sendFormatLeaseResponse(connection, "released", requestId, filePath);
1114
+ return;
1115
+ }
1116
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "unknown format lease operation");
1117
+ }
1118
+ activeFormatLease(room, filePath) {
1119
+ const lease = room.formatLeases.get(filePath);
1120
+ if (!lease)
1121
+ return null;
1122
+ if (lease.expiresAt > Date.now())
1123
+ return lease;
1124
+ this.releaseFormatLease(room, lease);
1125
+ return null;
1126
+ }
1127
+ grantFormatLease(room, waiter) {
1128
+ if (!room.connections.has(waiter.connection) || waiter.connection.socket.readyState !== WebSocket.OPEN)
1129
+ return false;
1130
+ const lease = {
1131
+ path: waiter.path,
1132
+ token: randomUUID(),
1133
+ requestId: waiter.requestId,
1134
+ connection: waiter.connection,
1135
+ expiresAt: Date.now() + FORMAT_LEASE_TTL_MS,
1136
+ timer: setTimeout(() => this.expireFormatLease(room, waiter.path), FORMAT_LEASE_TTL_MS + 25)
1137
+ };
1138
+ room.formatLeases.set(waiter.path, lease);
1139
+ this.sendFormatLeaseResponse(waiter.connection, "grant", waiter.requestId, lease.path, "", lease.expiresAt, lease.token);
1140
+ this.broadcastFormatLeaseState(room, lease);
1141
+ return true;
1142
+ }
1143
+ extendFormatLease(room, lease) {
1144
+ clearTimeout(lease.timer);
1145
+ lease.expiresAt = Date.now() + FORMAT_LEASE_TTL_MS;
1146
+ lease.timer = setTimeout(() => this.expireFormatLease(room, lease.path), FORMAT_LEASE_TTL_MS + 25);
1147
+ }
1148
+ expireFormatLease(room, filePath) {
1149
+ const lease = room.formatLeases.get(filePath);
1150
+ if (!lease)
1151
+ return;
1152
+ if (lease.expiresAt > Date.now()) {
1153
+ lease.timer = setTimeout(() => this.expireFormatLease(room, filePath), lease.expiresAt - Date.now() + 25);
1154
+ return;
1155
+ }
1156
+ this.releaseFormatLease(room, lease);
1157
+ }
1158
+ releaseFormatLease(room, lease) {
1159
+ if (room.formatLeases.get(lease.path) !== lease)
1160
+ return;
1161
+ clearTimeout(lease.timer);
1162
+ room.formatLeases.delete(lease.path);
1163
+ this.broadcastFormatLeaseState(room, null, lease.path);
1164
+ this.grantNextFormatLease(room, lease.path);
1165
+ }
1166
+ grantNextFormatLease(room, filePath) {
1167
+ if (room.formatLeases.has(filePath))
1168
+ return;
1169
+ const waiters = room.formatLeaseWaiters.get(filePath);
1170
+ if (!waiters?.length) {
1171
+ room.formatLeaseWaiters.delete(filePath);
1172
+ return;
1173
+ }
1174
+ while (waiters.length) {
1175
+ const waiter = waiters.shift();
1176
+ if (!room.connections.has(waiter.connection) || waiter.connection.socket.readyState !== WebSocket.OPEN)
1177
+ continue;
1178
+ // Keep all later requests in the room queue while the first valid
1179
+ // waiter owns the lease. The previous implementation deleted the map
1180
+ // before granting and silently dropped every later waiter.
1181
+ if (waiters.length)
1182
+ room.formatLeaseWaiters.set(filePath, waiters);
1183
+ else
1184
+ room.formatLeaseWaiters.delete(filePath);
1185
+ if (this.grantFormatLease(room, waiter))
1186
+ return;
1187
+ }
1188
+ room.formatLeaseWaiters.delete(filePath);
1189
+ }
1190
+ releaseFormatLeasesForConnection(room, connection) {
1191
+ const pathsToGrant = new Set();
1192
+ for (const [filePath, lease] of room.formatLeases) {
1193
+ if (lease.connection !== connection)
1194
+ continue;
1195
+ pathsToGrant.add(filePath);
1196
+ clearTimeout(lease.timer);
1197
+ room.formatLeases.delete(filePath);
1198
+ this.broadcastFormatLeaseState(room, null, filePath);
1199
+ }
1200
+ for (const [filePath, waiters] of room.formatLeaseWaiters) {
1201
+ const remaining = waiters.filter((waiter) => waiter.connection !== connection);
1202
+ if (remaining.length)
1203
+ room.formatLeaseWaiters.set(filePath, remaining);
1204
+ else
1205
+ room.formatLeaseWaiters.delete(filePath);
1206
+ }
1207
+ for (const filePath of pathsToGrant)
1208
+ this.grantNextFormatLease(room, filePath);
1209
+ }
1210
+ sendFormatLeaseResponse(connection, status, requestId, filePath, reason = "", expiresAt = 0, token = "") {
1211
+ const encoder = encoding.createEncoder();
1212
+ encoding.writeVarUint(encoder, MESSAGE_FORMAT_LEASE);
1213
+ encoding.writeVarString(encoder, status);
1214
+ encoding.writeVarString(encoder, requestId);
1215
+ encoding.writeVarString(encoder, filePath);
1216
+ encoding.writeVarString(encoder, token);
1217
+ encoding.writeVarString(encoder, expiresAt > 0 ? String(expiresAt) : "");
1218
+ encoding.writeVarString(encoder, reason.slice(0, 256));
1219
+ send(connection.socket, encoding.toUint8Array(encoder));
1220
+ }
1221
+ broadcastFormatLeaseState(room, lease, filePath = lease?.path ?? "") {
1222
+ this.broadcast(room, formatLeaseStateMessage(lease, filePath), null);
1223
+ }
1224
+ sendFormatLeaseStates(room, connection) {
1225
+ for (const lease of room.formatLeases.values()) {
1226
+ if (lease.expiresAt > Date.now())
1227
+ send(connection.socket, formatLeaseStateMessage(lease, lease.path));
1228
+ }
1229
+ }
975
1230
  scheduleRoomCleanup(room) {
976
1231
  if (room.connections.size === 0 && !room.cleanupTimer) {
977
1232
  room.cleanupTimer = setTimeout(() => this.destroyRoom(room), ROOM_IDLE_MS);
@@ -1147,6 +1402,10 @@ export class CollaborationService {
1147
1402
  connection.protocolTimer = null;
1148
1403
  }
1149
1404
  room.connections.clear();
1405
+ for (const lease of room.formatLeases.values())
1406
+ clearTimeout(lease.timer);
1407
+ room.formatLeases.clear();
1408
+ room.formatLeaseWaiters.clear();
1150
1409
  room.pendingFlushes.splice(0);
1151
1410
  room.rejectedPaths.clear();
1152
1411
  room.awarenessOwners.clear();
@@ -1302,6 +1561,19 @@ function maintenanceMessage(reason) {
1302
1561
  encoding.writeVarString(encoder, reason);
1303
1562
  return encoding.toUint8Array(encoder);
1304
1563
  }
1564
+ function formatLeaseStateMessage(lease, filePath) {
1565
+ const encoder = encoding.createEncoder();
1566
+ encoding.writeVarUint(encoder, MESSAGE_FORMAT_LEASE);
1567
+ encoding.writeVarString(encoder, "state");
1568
+ encoding.writeVarString(encoder, "");
1569
+ encoding.writeVarString(encoder, filePath);
1570
+ encoding.writeVarString(encoder, "");
1571
+ encoding.writeVarString(encoder, lease ? String(lease.expiresAt) : "");
1572
+ encoding.writeVarString(encoder, "");
1573
+ encoding.writeVarString(encoder, lease?.connection.user.id ?? "");
1574
+ encoding.writeVarString(encoder, lease?.connection.user.display_name ?? "");
1575
+ return encoding.toUint8Array(encoder);
1576
+ }
1305
1577
  function awarenessClientIds(update) {
1306
1578
  const decoder = decoding.createDecoder(update);
1307
1579
  const count = decoding.readVarUint(decoder);
@@ -2,7 +2,14 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { publishedCompileArtifacts } from "./compiler.js";
4
4
  import { outputRoot, resolveSourcePath, safeRelativePath, sourceRoot, texFileStem } from "./files.js";
5
- import { hasDocumentClass } from "./zip.js";
5
+ import { isMainDocumentCandidateSync } from "./latexRoot.js";
6
+ import { MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
7
+ export function pdfLoadingMode(config, pdfSizeBytes) {
8
+ if (config.pdfLoadingStrategy === "full" || config.pdfLoadingStrategy === "range") {
9
+ return config.pdfLoadingStrategy;
10
+ }
11
+ return pdfSizeBytes > config.pdfRangeThresholdBytes ? "range" : "full";
12
+ }
6
13
  export function availablePdf(config, projectId, mainFile, defaultMainFile) {
7
14
  const isDefault = mainFile === defaultMainFile;
8
15
  const published = publishedCompileArtifacts(config, projectId, mainFile, isDefault);
@@ -62,11 +69,7 @@ export function compileMainFile(config, projectId, defaultMainFile, value) {
62
69
  try {
63
70
  if (!fs.statSync(absolute).isFile())
64
71
  return null;
65
- // A project may intentionally contain only one fragmentary .tex file; the
66
- // upload flow keeps it as the configured root and latexmk reports the
67
- // resulting compile error. Session-selected alternate roots are stricter:
68
- // they must identify an actual document rather than an included fragment.
69
- if (mainFile !== defaultMainFile && !hasDocumentClass(fs.readFileSync(absolute, "utf8")))
72
+ if (!isMainDocumentCandidateSync(config, projectId, mainFile))
70
73
  return null;
71
74
  }
72
75
  catch {
@@ -87,7 +90,7 @@ export function listCompileArtifacts(directory) {
87
90
  visit(absolute, relative);
88
91
  else if (entry.isFile()) {
89
92
  const size = fs.statSync(absolute).size;
90
- artifacts.push({ path: relative, size, viewable: size <= 2 * 1024 * 1024 && isTextCompileArtifact(relative) });
93
+ artifacts.push({ path: relative, size, viewable: size <= MAX_TEXT_PREVIEW_BYTES && isTextCompileArtifact(relative) });
91
94
  }
92
95
  }
93
96
  };
@@ -242,7 +242,7 @@ export function cleanCompileArtifacts(config, projectId, mainFile, defaultMainFi
242
242
  }
243
243
  export function publishCompileArtifacts(config, projectId, snapshot, result) {
244
244
  if (!result.ok || !result.pdfPath)
245
- throw new Error("无法发布失败的编译结果");
245
+ throw new Error("Cannot publish a failed compilation result");
246
246
  const manifestDirectory = compileTargetRoot(config, projectId, snapshot.mainFile);
247
247
  const previous = publishedCompileArtifacts(config, projectId, snapshot.mainFile);
248
248
  const manifest = {
@@ -535,14 +535,14 @@ function rewriteSynctexSource(synctexPath, cacheSource, snapshotSource) {
535
535
  export async function compileProject(config, snapshot, mainFileInput, engine, latexmkrcInput) {
536
536
  const mainFile = safeRelativePath(mainFileInput);
537
537
  if (!/\.tex$/i.test(mainFile))
538
- throw new Error("主文件必须是 .tex 文件");
538
+ throw new Error("The main file must have a .tex extension");
539
539
  const startedAt = performance.now();
540
540
  let latexmkrc = null;
541
541
  if (config.allowProjectLatexmkrc && latexmkrcInput) {
542
542
  latexmkrc = safeRelativePath(latexmkrcInput);
543
543
  const absoluteRc = path.join(snapshot.sourceDir, latexmkrc);
544
544
  if (!fs.existsSync(absoluteRc) || !fs.statSync(absoluteRc).isFile()) {
545
- throw new Error(`项目 latexmkrc 不存在:${latexmkrc}`);
545
+ throw new Error(`The configured project latexmkrc does not exist: ${latexmkrc}`);
546
546
  }
547
547
  }
548
548
  const cacheStartedAt = performance.now();
@@ -553,6 +553,11 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
553
553
  const engineFlag = engine === "xelatex" ? "-xelatex" : engine === "lualatex" ? "-lualatex" : "-pdf";
554
554
  const args = [
555
555
  engineFlag,
556
+ // latexmk otherwise reads a .latexmkrc from the compile working
557
+ // directory automatically. Project sources may contain one after a ZIP
558
+ // import or a Git checkout, so only an rc file explicitly selected in the
559
+ // project settings may be loaded below with -r.
560
+ "-norc",
556
561
  "-interaction=nonstopmode",
557
562
  "-file-line-error",
558
563
  "-halt-on-error",
@@ -582,7 +587,7 @@ export async function compileProject(config, snapshot, mainFileInput, engine, la
582
587
  child.stderr.on("data", append);
583
588
  child.on("error", reject);
584
589
  const timeout = setTimeout(() => {
585
- log += `\n编译超过 ${config.compileTimeoutMs / 1000} 秒,已终止。\n`;
590
+ log += `\nCompilation exceeded ${config.compileTimeoutMs / 1000} seconds and was terminated.\n`;
586
591
  killProcessGroup(child);
587
592
  }, config.compileTimeoutMs);
588
593
  child.on("close", (code) => {
@@ -2,6 +2,7 @@ import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { defaultDataDirectory, packageClientDirectory, resolveConfigPath } from "./runtimePaths.js";
4
4
  export const LATEX_ENGINES = ["pdflatex", "xelatex", "lualatex"];
5
+ export const PDF_LOADING_STRATEGIES = ["auto", "full", "range"];
5
6
  /** Effective values used when the corresponding file/env setting is omitted. */
6
7
  export const CONFIG_DEFAULTS = {
7
8
  siteName: "TexLite",
@@ -19,8 +20,10 @@ export const CONFIG_DEFAULTS = {
19
20
  extraArgs: [],
20
21
  allowProjectLatexmkrc: true,
21
22
  maxFileSizeMB: 50,
23
+ pdfLoadingStrategy: "auto",
24
+ pdfRangeThresholdMB: 5,
22
25
  historyMaxVersions: 200,
23
- historyMaxStorageMB: 512,
26
+ historyMaxStorageMB: 128,
24
27
  git: "git",
25
28
  gitOperationTimeoutSeconds: 120,
26
29
  githubApiBaseUrl: "https://api.github.com"
@@ -31,6 +34,7 @@ const CONFIG_LIMITS = {
31
34
  compileTimeoutSeconds: [1, 3_600],
32
35
  maxCompileJobs: [1, 32],
33
36
  maxFileSizeMB: [1, 2_048],
37
+ pdfRangeThresholdMB: [1, 2_048],
34
38
  historyMaxVersions: [10, 5_000],
35
39
  historyMaxStorageMB: [16, 102_400],
36
40
  gitOperationTimeoutSeconds: [1, 3_600]
@@ -59,6 +63,11 @@ export function loadConfig(configPathOverride) {
59
63
  const compileTimeoutSeconds = integerSetting("latex.compileTimeoutSeconds", process.env.TEXLITE_COMPILE_TIMEOUT, fileConfig.latex?.compileTimeoutSeconds, CONFIG_DEFAULTS.compileTimeoutSeconds, CONFIG_LIMITS.compileTimeoutSeconds);
60
64
  const maxCompileJobs = integerSetting("latex.maxCompileJobs", process.env.TEXLITE_MAX_COMPILE_JOBS, fileConfig.latex?.maxCompileJobs, CONFIG_DEFAULTS.maxCompileJobs, CONFIG_LIMITS.maxCompileJobs);
61
65
  const maxFileSizeMB = integerSetting("uploads.maxFileSizeMB", process.env.TEXLITE_MAX_UPLOAD_SIZE_MB, fileConfig.uploads?.maxFileSizeMB, CONFIG_DEFAULTS.maxFileSizeMB, CONFIG_LIMITS.maxFileSizeMB);
66
+ const configuredPdfLoadingStrategy = setting("pdf.loadingStrategy", process.env.TEXLITE_PDF_LOADING_STRATEGY, fileConfig.pdf?.loadingStrategy, CONFIG_DEFAULTS.pdfLoadingStrategy);
67
+ if (!isPdfLoadingStrategy(configuredPdfLoadingStrategy)) {
68
+ throw configurationError("pdf.loadingStrategy", `must be one of ${PDF_LOADING_STRATEGIES.join(", ")}; received ${displayValue(configuredPdfLoadingStrategy)}`);
69
+ }
70
+ const pdfRangeThresholdMB = integerSetting("pdf.rangeThresholdMB", process.env.TEXLITE_PDF_RANGE_THRESHOLD_MB, fileConfig.pdf?.rangeThresholdMB, CONFIG_DEFAULTS.pdfRangeThresholdMB, CONFIG_LIMITS.pdfRangeThresholdMB);
62
71
  const historyMaxVersions = integerSetting("history.maxVersions", process.env.TEXLITE_HISTORY_MAX_VERSIONS, fileConfig.history?.maxVersions, CONFIG_DEFAULTS.historyMaxVersions, CONFIG_LIMITS.historyMaxVersions);
63
72
  const historyMaxStorageMB = integerSetting("history.maxStorageMB", process.env.TEXLITE_HISTORY_MAX_STORAGE_MB, fileConfig.history?.maxStorageMB, CONFIG_DEFAULTS.historyMaxStorageMB, CONFIG_LIMITS.historyMaxStorageMB);
64
73
  const gitOperationTimeoutSeconds = integerSetting("git.operationTimeoutSeconds", process.env.TEXLITE_GIT_TIMEOUT, fileConfig.git?.operationTimeoutSeconds, CONFIG_DEFAULTS.gitOperationTimeoutSeconds, CONFIG_LIMITS.gitOperationTimeoutSeconds);
@@ -81,6 +90,8 @@ export function loadConfig(configPathOverride) {
81
90
  extraArgs: fileConfig.latex?.extraArgs === undefined ? [] : [...fileConfig.latex.extraArgs],
82
91
  allowProjectLatexmkrc: fileConfig.latex?.allowProjectLatexmkrc ?? CONFIG_DEFAULTS.allowProjectLatexmkrc,
83
92
  maxUploadBytes: maxFileSizeMB * 1024 * 1024,
93
+ pdfLoadingStrategy: configuredPdfLoadingStrategy,
94
+ pdfRangeThresholdBytes: pdfRangeThresholdMB * 1024 * 1024,
84
95
  historyMaxVersions,
85
96
  historyMaxStorageBytes: historyMaxStorageMB * 1024 * 1024,
86
97
  git: stringSetting("git.binary", process.env.TEXLITE_GIT, fileConfig.git?.binary, CONFIG_DEFAULTS.git, { min: 1, max: 256 }),
@@ -115,6 +126,10 @@ export function validateConfig(config) {
115
126
  validateInteger("latex.compileTimeoutSeconds", config.compileTimeoutMs / 1000, CONFIG_LIMITS.compileTimeoutSeconds);
116
127
  validateInteger("latex.maxCompileJobs", config.maxCompileJobs, CONFIG_LIMITS.maxCompileJobs);
117
128
  validateInteger("uploads.maxFileSizeMB", config.maxUploadBytes / (1024 * 1024), CONFIG_LIMITS.maxFileSizeMB);
129
+ if (!isPdfLoadingStrategy(config.pdfLoadingStrategy)) {
130
+ throw configurationError("pdf.loadingStrategy", `must be one of ${PDF_LOADING_STRATEGIES.join(", ")}`);
131
+ }
132
+ validateInteger("pdf.rangeThresholdMB", config.pdfRangeThresholdBytes / (1024 * 1024), CONFIG_LIMITS.pdfRangeThresholdMB);
118
133
  validateInteger("history.maxVersions", config.historyMaxVersions, CONFIG_LIMITS.historyMaxVersions);
119
134
  validateInteger("history.maxStorageMB", config.historyMaxStorageBytes / (1024 * 1024), CONFIG_LIMITS.historyMaxStorageMB);
120
135
  validateInteger("git.operationTimeoutSeconds", config.gitOperationTimeoutMs / 1000, CONFIG_LIMITS.gitOperationTimeoutSeconds);
@@ -122,6 +137,9 @@ export function validateConfig(config) {
122
137
  function isEngine(value) {
123
138
  return typeof value === "string" && LATEX_ENGINES.includes(value);
124
139
  }
140
+ function isPdfLoadingStrategy(value) {
141
+ return typeof value === "string" && PDF_LOADING_STRATEGIES.includes(value);
142
+ }
125
143
  function readConfigFile(configPath) {
126
144
  if (!fs.existsSync(configPath))
127
145
  return {};
@@ -149,6 +167,12 @@ function validateFileConfig(config) {
149
167
  optionalString(storage?.dataDir, "storage.dataDir", { min: 1, max: 4_096 });
150
168
  const uploads = optionalSection(config.uploads, "uploads");
151
169
  optionalInteger(uploads?.maxFileSizeMB, "uploads.maxFileSizeMB", CONFIG_LIMITS.maxFileSizeMB);
170
+ const pdf = optionalSection(config.pdf, "pdf");
171
+ if (pdf && Object.prototype.hasOwnProperty.call(pdf, "loadingStrategy")
172
+ && !isPdfLoadingStrategy(pdf.loadingStrategy)) {
173
+ throw configurationError("pdf.loadingStrategy", `must be one of ${PDF_LOADING_STRATEGIES.join(", ")}; received ${displayValue(pdf.loadingStrategy)}`);
174
+ }
175
+ optionalInteger(pdf?.rangeThresholdMB, "pdf.rangeThresholdMB", CONFIG_LIMITS.pdfRangeThresholdMB);
152
176
  const history = optionalSection(config.history, "history");
153
177
  optionalInteger(history?.maxVersions, "history.maxVersions", CONFIG_LIMITS.historyMaxVersions);
154
178
  optionalInteger(history?.maxStorageMB, "history.maxStorageMB", CONFIG_LIMITS.historyMaxStorageMB);
@@ -1,4 +1,5 @@
1
1
  import { spawn } from "node:child_process";
2
+ import { httpError } from "./http.js";
2
3
  import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
3
4
  export async function assertEnvironment(config) {
4
5
  const commands = [
@@ -26,7 +27,10 @@ export async function assertGitAvailable(config) {
26
27
  const result = await commandVersion(item.command, Math.min(config.gitOperationTimeoutMs, 10_000));
27
28
  if (result.error || result.status !== 0) {
28
29
  const detail = result.error || result.stderr.trim() || `exit ${result.status ?? "unknown"}`;
29
- throw Object.assign(new Error(`Git integration is unavailable: could not run the configured command "${item.command}" (${detail}). Install Git or correct git.binary and try again.`), { statusCode: 503, code: "GIT_UNAVAILABLE" });
30
+ // Command output can reveal host paths; it remains a startup diagnostic,
31
+ // while API clients receive the localized, stable error code.
32
+ void detail;
33
+ throw httpError(503, "GIT_UNAVAILABLE");
30
34
  }
31
35
  const version = `${result.stdout || result.stderr}`.trim().split("\n")[0]?.slice(0, 160) || "available";
32
36
  return { ...item, version };