texlite 0.7.5 → 0.7.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.
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-W3hLOWrF.js} +2 -2
  4. package/dist/client/assets/HistoryDialog-7ADYJcGi.js +5 -0
  5. package/dist/client/assets/LatexEditor-ChHQwJXZ.js +59 -0
  6. package/dist/client/assets/PdfPreview-CuvWphzY.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-CaovMu4V.js → ProjectNavigationDialogs-CzFhIw6B.js} +1 -1
  8. package/dist/client/assets/SystemMetricsDialog-C0m9JUDy.js +1 -0
  9. package/dist/client/assets/index-DRShM2ob.js +64 -0
  10. package/dist/client/assets/index-DzcH7hBu.css +1 -0
  11. package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
  12. package/dist/client/assets/minus-BfqMIeUg.js +1 -0
  13. package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
  14. package/dist/client/assets/spellCheck-BBnCnxmQ.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 +250 -2
  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,8 @@ 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;
41
46
  const EPHEMERAL_META_KEYS = ["compileStates", "filesEvent", "commentsRevision", "dictionaryRevision"];
42
47
  const COLORS = [
43
48
  ["#1677c8", "#1677c833"], ["#d65745", "#d6574533"], ["#16866a", "#16866a33"],
@@ -262,6 +267,11 @@ export class CollaborationService {
262
267
  const room = this.rooms.get(projectId);
263
268
  if (!room)
264
269
  return;
270
+ if (permission === "read" || permission === "revoked") {
271
+ const connectionIds = new Set([...room.connections].filter((connection) => connection.user.id === userId));
272
+ for (const connection of connectionIds)
273
+ this.releaseFormatLeasesForConnection(room, connection);
274
+ }
265
275
  const encoder = encoding.createEncoder();
266
276
  encoding.writeVarUint(encoder, MESSAGE_PERMISSION);
267
277
  encoding.writeVarString(encoder, userId);
@@ -657,7 +667,9 @@ export class CollaborationService {
657
667
  snapshotFlushPending: false,
658
668
  pendingFlushes: [],
659
669
  rejectedPaths: new Set(),
660
- compileMetaValidationPending: false
670
+ compileMetaValidationPending: false,
671
+ formatLeases: new Map(),
672
+ formatLeaseWaiters: new Map()
661
673
  };
662
674
  room.awareness.setLocalState(null);
663
675
  // A recovered Yjs document may contain metadata from a process that no
@@ -859,6 +871,7 @@ export class CollaborationService {
859
871
  this.sendSyncStep1(room, connection.socket);
860
872
  this.sendCompileStates(room, connection.socket);
861
873
  this.sendAwareness(room, connection.socket);
874
+ this.sendFormatLeaseStates(room, connection);
862
875
  }
863
876
  return;
864
877
  }
@@ -916,6 +929,10 @@ export class CollaborationService {
916
929
  this.sendFlushReceipt(connection, requestId, receipt);
917
930
  return;
918
931
  }
932
+ if (messageType === MESSAGE_FORMAT_LEASE) {
933
+ this.handleFormatLeaseMessage(room, connection, decoder, current);
934
+ return;
935
+ }
919
936
  if (messageType !== MESSAGE_AWARENESS)
920
937
  return;
921
938
  const update = decoding.readVarUint8Array(decoder);
@@ -946,6 +963,7 @@ export class CollaborationService {
946
963
  disconnect(room, connection) {
947
964
  if (!room.connections.delete(connection))
948
965
  return;
966
+ this.releaseFormatLeasesForConnection(room, connection);
949
967
  room.pendingFlushes = room.pendingFlushes.filter((pending) => pending.connection !== connection);
950
968
  if (connection.protocolTimer)
951
969
  clearTimeout(connection.protocolTimer);
@@ -972,6 +990,219 @@ export class CollaborationService {
972
990
  if (connection !== except)
973
991
  send(connection.socket, message);
974
992
  }
993
+ /**
994
+ * Serialize access to one source file while a browser-side formatter is
995
+ * calculating and applying its replacement. This is intentionally an
996
+ * in-memory room primitive: the lease is advisory to the live collaboration
997
+ * room, while the Yjs update + flush ordering remains the authoritative
998
+ * durability boundary. A disconnected holder can never keep a lease alive.
999
+ */
1000
+ handleFormatLeaseMessage(room, connection, decoder, currentProject) {
1001
+ const operation = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 16) : "";
1002
+ const requestId = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 128) : "";
1003
+ const rawPath = decoding.hasContent(decoder) ? decoding.readVarString(decoder) : "";
1004
+ const token = decoding.hasContent(decoder) ? decoding.readVarString(decoder).slice(0, 128) : "";
1005
+ let filePath;
1006
+ try {
1007
+ filePath = safeRelativePath(rawPath);
1008
+ }
1009
+ catch {
1010
+ this.sendFormatLeaseResponse(connection, "denied", requestId, rawPath, "invalid path");
1011
+ return;
1012
+ }
1013
+ if (!requestId || !isCollaborativeTextFile(filePath) || !room.allowedPaths.has(filePath)) {
1014
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "file is not format-able");
1015
+ return;
1016
+ }
1017
+ if (!canEdit(currentProject)) {
1018
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "write permission is required");
1019
+ return;
1020
+ }
1021
+ if (operation === "acquire") {
1022
+ const current = this.activeFormatLease(room, filePath);
1023
+ if (!current) {
1024
+ // Expiring a lease may have immediately granted the next queued
1025
+ // request. Do not overwrite that grant with this new request.
1026
+ if (room.formatLeases.has(filePath)) {
1027
+ const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
1028
+ if (waiters.length >= MAX_FORMAT_LEASE_WAITERS) {
1029
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "too many formatters are waiting");
1030
+ return;
1031
+ }
1032
+ waiters.push({ path: filePath, requestId, connection });
1033
+ room.formatLeaseWaiters.set(filePath, waiters);
1034
+ return;
1035
+ }
1036
+ this.grantFormatLease(room, { path: filePath, requestId, connection });
1037
+ return;
1038
+ }
1039
+ if (current.connection === connection) {
1040
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "this session already holds the lease");
1041
+ return;
1042
+ }
1043
+ const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
1044
+ if (waiters.some((waiter) => waiter.connection === connection && waiter.requestId === requestId))
1045
+ return;
1046
+ if (waiters.length >= MAX_FORMAT_LEASE_WAITERS) {
1047
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "too many formatters are waiting");
1048
+ return;
1049
+ }
1050
+ waiters.push({ path: filePath, requestId, connection });
1051
+ room.formatLeaseWaiters.set(filePath, waiters);
1052
+ return;
1053
+ }
1054
+ if (operation === "renew") {
1055
+ const lease = this.activeFormatLease(room, filePath);
1056
+ if (!lease || lease.connection !== connection || lease.token !== token) {
1057
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "format lease is no longer valid");
1058
+ return;
1059
+ }
1060
+ this.extendFormatLease(room, lease);
1061
+ this.sendFormatLeaseResponse(connection, "renewed", requestId, filePath, "", lease.expiresAt, lease.token);
1062
+ this.broadcastFormatLeaseState(room, lease);
1063
+ return;
1064
+ }
1065
+ if (operation === "release") {
1066
+ const lease = this.activeFormatLease(room, filePath);
1067
+ if (!lease || lease.connection !== connection || lease.token !== token) {
1068
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "format lease is no longer valid");
1069
+ return;
1070
+ }
1071
+ this.releaseFormatLease(room, lease);
1072
+ this.sendFormatLeaseResponse(connection, "released", requestId, filePath);
1073
+ return;
1074
+ }
1075
+ if (operation === "cancel") {
1076
+ const waiters = room.formatLeaseWaiters.get(filePath) ?? [];
1077
+ const remaining = waiters.filter((waiter) => !(waiter.connection === connection && waiter.requestId === requestId));
1078
+ if (remaining.length)
1079
+ room.formatLeaseWaiters.set(filePath, remaining);
1080
+ else
1081
+ room.formatLeaseWaiters.delete(filePath);
1082
+ // A timeout may race with the server granting the request. In that
1083
+ // window the request is no longer in the waiter queue, so also revoke a
1084
+ // matching active lease instead of leaving it until the TTL expires.
1085
+ const active = this.activeFormatLease(room, filePath);
1086
+ if (active?.connection === connection && active.requestId === requestId) {
1087
+ this.releaseFormatLease(room, active);
1088
+ }
1089
+ this.sendFormatLeaseResponse(connection, "released", requestId, filePath);
1090
+ return;
1091
+ }
1092
+ this.sendFormatLeaseResponse(connection, "denied", requestId, filePath, "unknown format lease operation");
1093
+ }
1094
+ activeFormatLease(room, filePath) {
1095
+ const lease = room.formatLeases.get(filePath);
1096
+ if (!lease)
1097
+ return null;
1098
+ if (lease.expiresAt > Date.now())
1099
+ return lease;
1100
+ this.releaseFormatLease(room, lease);
1101
+ return null;
1102
+ }
1103
+ grantFormatLease(room, waiter) {
1104
+ if (!room.connections.has(waiter.connection) || waiter.connection.socket.readyState !== WebSocket.OPEN)
1105
+ return false;
1106
+ const lease = {
1107
+ path: waiter.path,
1108
+ token: randomUUID(),
1109
+ requestId: waiter.requestId,
1110
+ connection: waiter.connection,
1111
+ expiresAt: Date.now() + FORMAT_LEASE_TTL_MS,
1112
+ timer: setTimeout(() => this.expireFormatLease(room, waiter.path), FORMAT_LEASE_TTL_MS + 25)
1113
+ };
1114
+ room.formatLeases.set(waiter.path, lease);
1115
+ this.sendFormatLeaseResponse(waiter.connection, "grant", waiter.requestId, lease.path, "", lease.expiresAt, lease.token);
1116
+ this.broadcastFormatLeaseState(room, lease);
1117
+ return true;
1118
+ }
1119
+ extendFormatLease(room, lease) {
1120
+ clearTimeout(lease.timer);
1121
+ lease.expiresAt = Date.now() + FORMAT_LEASE_TTL_MS;
1122
+ lease.timer = setTimeout(() => this.expireFormatLease(room, lease.path), FORMAT_LEASE_TTL_MS + 25);
1123
+ }
1124
+ expireFormatLease(room, filePath) {
1125
+ const lease = room.formatLeases.get(filePath);
1126
+ if (!lease)
1127
+ return;
1128
+ if (lease.expiresAt > Date.now()) {
1129
+ lease.timer = setTimeout(() => this.expireFormatLease(room, filePath), lease.expiresAt - Date.now() + 25);
1130
+ return;
1131
+ }
1132
+ this.releaseFormatLease(room, lease);
1133
+ }
1134
+ releaseFormatLease(room, lease) {
1135
+ if (room.formatLeases.get(lease.path) !== lease)
1136
+ return;
1137
+ clearTimeout(lease.timer);
1138
+ room.formatLeases.delete(lease.path);
1139
+ this.broadcastFormatLeaseState(room, null, lease.path);
1140
+ this.grantNextFormatLease(room, lease.path);
1141
+ }
1142
+ grantNextFormatLease(room, filePath) {
1143
+ if (room.formatLeases.has(filePath))
1144
+ return;
1145
+ const waiters = room.formatLeaseWaiters.get(filePath);
1146
+ if (!waiters?.length) {
1147
+ room.formatLeaseWaiters.delete(filePath);
1148
+ return;
1149
+ }
1150
+ while (waiters.length) {
1151
+ const waiter = waiters.shift();
1152
+ if (!room.connections.has(waiter.connection) || waiter.connection.socket.readyState !== WebSocket.OPEN)
1153
+ continue;
1154
+ // Keep all later requests in the room queue while the first valid
1155
+ // waiter owns the lease. The previous implementation deleted the map
1156
+ // before granting and silently dropped every later waiter.
1157
+ if (waiters.length)
1158
+ room.formatLeaseWaiters.set(filePath, waiters);
1159
+ else
1160
+ room.formatLeaseWaiters.delete(filePath);
1161
+ if (this.grantFormatLease(room, waiter))
1162
+ return;
1163
+ }
1164
+ room.formatLeaseWaiters.delete(filePath);
1165
+ }
1166
+ releaseFormatLeasesForConnection(room, connection) {
1167
+ const pathsToGrant = new Set();
1168
+ for (const [filePath, lease] of room.formatLeases) {
1169
+ if (lease.connection !== connection)
1170
+ continue;
1171
+ pathsToGrant.add(filePath);
1172
+ clearTimeout(lease.timer);
1173
+ room.formatLeases.delete(filePath);
1174
+ this.broadcastFormatLeaseState(room, null, filePath);
1175
+ }
1176
+ for (const [filePath, waiters] of room.formatLeaseWaiters) {
1177
+ const remaining = waiters.filter((waiter) => waiter.connection !== connection);
1178
+ if (remaining.length)
1179
+ room.formatLeaseWaiters.set(filePath, remaining);
1180
+ else
1181
+ room.formatLeaseWaiters.delete(filePath);
1182
+ }
1183
+ for (const filePath of pathsToGrant)
1184
+ this.grantNextFormatLease(room, filePath);
1185
+ }
1186
+ sendFormatLeaseResponse(connection, status, requestId, filePath, reason = "", expiresAt = 0, token = "") {
1187
+ const encoder = encoding.createEncoder();
1188
+ encoding.writeVarUint(encoder, MESSAGE_FORMAT_LEASE);
1189
+ encoding.writeVarString(encoder, status);
1190
+ encoding.writeVarString(encoder, requestId);
1191
+ encoding.writeVarString(encoder, filePath);
1192
+ encoding.writeVarString(encoder, token);
1193
+ encoding.writeVarString(encoder, expiresAt > 0 ? String(expiresAt) : "");
1194
+ encoding.writeVarString(encoder, reason.slice(0, 256));
1195
+ send(connection.socket, encoding.toUint8Array(encoder));
1196
+ }
1197
+ broadcastFormatLeaseState(room, lease, filePath = lease?.path ?? "") {
1198
+ this.broadcast(room, formatLeaseStateMessage(lease, filePath), null);
1199
+ }
1200
+ sendFormatLeaseStates(room, connection) {
1201
+ for (const lease of room.formatLeases.values()) {
1202
+ if (lease.expiresAt > Date.now())
1203
+ send(connection.socket, formatLeaseStateMessage(lease, lease.path));
1204
+ }
1205
+ }
975
1206
  scheduleRoomCleanup(room) {
976
1207
  if (room.connections.size === 0 && !room.cleanupTimer) {
977
1208
  room.cleanupTimer = setTimeout(() => this.destroyRoom(room), ROOM_IDLE_MS);
@@ -1147,6 +1378,10 @@ export class CollaborationService {
1147
1378
  connection.protocolTimer = null;
1148
1379
  }
1149
1380
  room.connections.clear();
1381
+ for (const lease of room.formatLeases.values())
1382
+ clearTimeout(lease.timer);
1383
+ room.formatLeases.clear();
1384
+ room.formatLeaseWaiters.clear();
1150
1385
  room.pendingFlushes.splice(0);
1151
1386
  room.rejectedPaths.clear();
1152
1387
  room.awarenessOwners.clear();
@@ -1302,6 +1537,19 @@ function maintenanceMessage(reason) {
1302
1537
  encoding.writeVarString(encoder, reason);
1303
1538
  return encoding.toUint8Array(encoder);
1304
1539
  }
1540
+ function formatLeaseStateMessage(lease, filePath) {
1541
+ const encoder = encoding.createEncoder();
1542
+ encoding.writeVarUint(encoder, MESSAGE_FORMAT_LEASE);
1543
+ encoding.writeVarString(encoder, "state");
1544
+ encoding.writeVarString(encoder, "");
1545
+ encoding.writeVarString(encoder, filePath);
1546
+ encoding.writeVarString(encoder, "");
1547
+ encoding.writeVarString(encoder, lease ? String(lease.expiresAt) : "");
1548
+ encoding.writeVarString(encoder, "");
1549
+ encoding.writeVarString(encoder, lease?.connection.user.id ?? "");
1550
+ encoding.writeVarString(encoder, lease?.connection.user.display_name ?? "");
1551
+ return encoding.toUint8Array(encoder);
1552
+ }
1305
1553
  function awarenessClientIds(update) {
1306
1554
  const decoder = decoding.createDecoder(update);
1307
1555
  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 };
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { randomUUID } from "node:crypto";
4
+ import { httpError } from "./http.js";
4
5
  export function projectRoot(config, projectId) {
5
6
  return path.join(config.projectsDir, projectId);
6
7
  }
@@ -49,14 +50,14 @@ export async function duplicateProjectFiles(config, sourceProjectId, targetProje
49
50
  }
50
51
  export function safeRelativePath(input) {
51
52
  if (!input || input.includes("\0") || path.isAbsolute(input)) {
52
- throw Object.assign(new Error("无效的文件路径"), { statusCode: 400, code: "INVALID_PATH" });
53
+ throw httpError(400, "INVALID_PATH");
53
54
  }
54
55
  const normalized = path.posix.normalize(input.replaceAll("\\", "/"));
55
56
  if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
56
- throw Object.assign(new Error("无效的文件路径"), { statusCode: 400, code: "INVALID_PATH" });
57
+ throw httpError(400, "INVALID_PATH");
57
58
  }
58
59
  if (normalized.split("/").some((segment) => segment.toLocaleLowerCase() === ".git")) {
59
- throw Object.assign(new Error(".git 是系统保留目录"), { statusCode: 400, code: "RESERVED_PATH" });
60
+ throw httpError(400, "RESERVED_PATH");
60
61
  }
61
62
  return normalized;
62
63
  }
@@ -64,12 +65,12 @@ export function safeRelativePath(input) {
64
65
  export function safePathSegment(input) {
65
66
  const name = input.trim();
66
67
  if (!name || name.includes("\0") || name.includes("/") || name.includes("\\")) {
67
- throw Object.assign(new Error("无效的文件名"), { statusCode: 400, code: "INVALID_PATH" });
68
+ throw httpError(400, "INVALID_PATH");
68
69
  }
69
70
  return safeRelativePath(name);
70
71
  }
71
72
  export function symbolicLinkError(relativePath) {
72
- return Object.assign(new Error(`项目源文件包含不受支持的符号链接,已拒绝访问:${relativePath || "source"}`), { statusCode: 409, code: "SYMLINK_FORBIDDEN" });
73
+ return httpError(409, "SYMLINK_FORBIDDEN", { path: relativePath || "source" });
73
74
  }
74
75
  /**
75
76
  * Check every existing component below the project directory without
@@ -256,7 +257,7 @@ export function removeProjectDirectory(config, projectId) {
256
257
  catch {
257
258
  fs.rmSync(root, { recursive: true, force: true });
258
259
  if (fs.existsSync(root)) {
259
- throw new Error(`无法清理项目目录: ${root}`);
260
+ throw new Error(`Unable to remove project directory: ${root}`);
260
261
  }
261
262
  }
262
263
  }