texlite 0.5.5 → 0.6.5

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 (44) hide show
  1. package/README.md +1 -0
  2. package/dist/client/assets/{BinaryModule-Aj1vLnwf-BsIXahIW.js → BinaryModule-Aj1vLnwf-hZ2K_uyt.js} +1 -1
  3. package/dist/client/assets/{GitDialog-CFzsF7ys.js → GitDialog-s54eAFX6.js} +1 -1
  4. package/dist/client/assets/{HistoryDialog-YPiAOGU_.js → HistoryDialog-DrUnIy0a.js} +1 -1
  5. package/dist/client/assets/{LatexEditor-BX4WLdhS.js → LatexEditor-Y5KfVqQI.js} +20 -20
  6. package/dist/client/assets/PdfPreview-BPKX-qpr.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-BZanedGt.js → ProjectNavigationDialogs-CjZX0g7O.js} +1 -1
  8. package/dist/client/assets/{SystemMetricsDialog-DnH4c7Se.js → SystemMetricsDialog-B97Og0La.js} +1 -1
  9. package/dist/client/assets/binary-DCBFVo4B.js +1 -0
  10. package/dist/client/assets/index-5ajAy27z.css +1 -0
  11. package/dist/client/assets/index-CCitTPyj.js +17 -0
  12. package/dist/client/assets/{index-B5iMrEn5.js → index-CQG_gpuU.js} +1 -1
  13. package/dist/client/assets/{plus-B-DG5h4y.js → plus-Bp4_lUor.js} +1 -1
  14. package/dist/client/assets/{spellCheck-4xuNPF_M.js → spellCheck-0f5ZG5IV.js} +3 -3
  15. package/dist/client/index.html +2 -2
  16. package/dist/server/app.js +889 -337
  17. package/dist/server/archive.js +20 -3
  18. package/dist/server/auth.js +13 -2
  19. package/dist/server/collaboration.js +815 -124
  20. package/dist/server/compileArtifacts.js +37 -9
  21. package/dist/server/compiler.js +69 -20
  22. package/dist/server/db.js +6 -0
  23. package/dist/server/environment.js +4 -2
  24. package/dist/server/files.js +172 -25
  25. package/dist/server/git.js +152 -124
  26. package/dist/server/history.js +86 -18
  27. package/dist/server/http.js +26 -0
  28. package/dist/server/index.js +39 -26
  29. package/dist/server/instanceLock.js +137 -0
  30. package/dist/server/latexCompletion.js +23 -1
  31. package/dist/server/latexFormatter.js +4 -2
  32. package/dist/server/processTree.js +31 -0
  33. package/dist/server/projectMutations.js +255 -0
  34. package/dist/server/projectOutline.js +113 -1
  35. package/dist/server/projectSearch.js +6 -0
  36. package/dist/server/routes/compile.js +535 -132
  37. package/dist/server/security.js +61 -1
  38. package/dist/server/synctex.js +11 -2
  39. package/dist/server/zip.js +2 -1
  40. package/package.json +1 -1
  41. package/dist/client/assets/PdfPreview-FQ8oxyUx.js +0 -1
  42. package/dist/client/assets/binary-MtJxbY5b.js +0 -1
  43. package/dist/client/assets/index-CUa7L47p.css +0 -1
  44. package/dist/client/assets/index-DdFlmXqA.js +0 -17
@@ -4,16 +4,28 @@ import { randomUUID } from "node:crypto";
4
4
  import { performance } from "node:perf_hooks";
5
5
  import { requireUser } from "../auth.js";
6
6
  import { accessibleProject, canEdit } from "../projects.js";
7
- import { resolveSourcePath, safeRelativePath } from "../files.js";
7
+ import { resolveSourcePath, safeRelativePath, texFileStem } from "../files.js";
8
8
  import { captureCompileSnapshot, cleanCompileArtifacts, cleanCompileCache, compileProject, discardCompileSnapshot, hasCompileCache, publishCompileArtifacts, publishedCompileArtifacts } from "../compiler.js";
9
9
  import { parseCompileDiagnostics } from "../compileDiagnostics.js";
10
10
  import { pdfToSource, sourceToPdf } from "../synctex.js";
11
- import { availablePdf, compileMainFile, compilePdfUrl, isTextCompileArtifact, listCompileArtifacts, syncArtifacts } from "../compileArtifacts.js";
11
+ import { availablePdf, compileRunPdf, compileMainFile, compilePdfUrl, isTextCompileArtifact, listCompileArtifacts, syncArtifacts } from "../compileArtifacts.js";
12
12
  import { apiError, contentDisposition } from "../http.js";
13
13
  const now = () => new Date().toISOString();
14
14
  const timingDuration = (milliseconds) => Math.round(milliseconds * 10) / 10;
15
+ const MAX_SNAPSHOT_ATTEMPTS = 3;
16
+ const SNAPSHOT_RETRY_AFTER_SECONDS = 1;
17
+ function mainDocumentChangedError() {
18
+ return Object.assign(new Error("项目主文档在编译排队期间发生变化,请重新发起编译"), { statusCode: 409, code: "MAIN_DOCUMENT_CHANGED" });
19
+ }
20
+ class CompileSnapshotBusyError extends Error {
21
+ code = "COMPILE_SNAPSHOT_BUSY";
22
+ constructor() {
23
+ super("项目源码尚未完成稳定保存,暂时无法编译,请稍后重试");
24
+ this.name = "CompileSnapshotBusyError";
25
+ }
26
+ }
15
27
  export function registerCompileRoutes(app, context) {
16
- const { config, db, collaboration, compileCoordinator, metrics, pruneCompileRuns } = context;
28
+ const { config, db, collaboration, projectMutations, compileCoordinator, metrics, pruneCompileRuns } = context;
17
29
  app.get("/api/projects/:id/compile/latest", async (request, reply) => {
18
30
  const user = requireUser(request, reply, db);
19
31
  if (!user)
@@ -37,7 +49,9 @@ export function registerCompileRoutes(app, context) {
37
49
  const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
38
50
  const publishedRun = published ? db.prepare(`SELECT id, finished_at FROM compile_runs
39
51
  WHERE id = ? AND project_id = ? AND status = 'succeeded'`).get(published.runId, id) : undefined;
40
- const pdfVersion = published?.runId ?? latestSuccess?.id ?? pdf?.version;
52
+ // A retained legacy PDF has no run bundle, so its file version (mtime) is
53
+ // the only stable token that can be resolved back to those bytes.
54
+ const pdfVersion = published?.runId ?? pdf?.version;
41
55
  return {
42
56
  mainFile,
43
57
  latestRun: latest ? {
@@ -68,29 +82,74 @@ export function registerCompileRoutes(app, context) {
68
82
  if (!mainFile)
69
83
  return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
70
84
  const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
71
- if (!query.path)
72
- return { mainFile, runId: published?.runId ?? null, artifacts: published ? listCompileArtifacts(published.output) : [] };
85
+ if (!query.path) {
86
+ if (!published)
87
+ return { mainFile, runId: null, artifacts: [] };
88
+ try {
89
+ return { mainFile, runId: published.runId, artifacts: listCompileArtifacts(published.output) };
90
+ }
91
+ catch (error) {
92
+ // Cleanup can remove a retained run after the manifest has been read.
93
+ // Treat that narrow race as an empty artifact list instead of leaking
94
+ // an internal ENOENT/ENOTDIR as a 500 response.
95
+ if (isMissingFileError(error))
96
+ return { mainFile, runId: null, artifacts: [] };
97
+ throw error;
98
+ }
99
+ }
73
100
  if (!published)
74
101
  return apiError(reply, 404, "COMPILE_ARTIFACTS_NOT_FOUND", "尚无可用的编译产物");
75
102
  const relative = safeRelativePath(query.path);
76
103
  const absolute = path.join(published.output, relative);
77
104
  const outputDirectory = path.resolve(published.output);
78
105
  const resolved = path.resolve(absolute);
79
- if (!resolved.startsWith(`${outputDirectory}${path.sep}`)
80
- || !fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
106
+ if (!resolved.startsWith(`${outputDirectory}${path.sep}`)) {
81
107
  return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
82
108
  }
83
- const stat = fs.statSync(resolved);
109
+ let stat;
110
+ try {
111
+ stat = fs.statSync(resolved);
112
+ if (!stat.isFile())
113
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
114
+ }
115
+ catch (error) {
116
+ if (isMissingFileError(error))
117
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
118
+ throw error;
119
+ }
84
120
  if (query.download === "1") {
121
+ const temporaryDirectory = path.join(config.dataDir, "tmp");
122
+ const temporaryFile = path.join(temporaryDirectory, `artifact-${id}-${randomUUID()}`);
123
+ await fs.promises.mkdir(temporaryDirectory, { recursive: true, mode: 0o700 });
124
+ try {
125
+ await fs.promises.copyFile(resolved, temporaryFile);
126
+ }
127
+ catch (error) {
128
+ await fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined);
129
+ if (isMissingFileError(error))
130
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
131
+ throw error;
132
+ }
85
133
  reply.header("Content-Type", "application/octet-stream");
86
134
  reply.header("Content-Disposition", contentDisposition(path.basename(relative), "attachment"));
87
135
  reply.header("Content-Length", stat.size);
88
- return reply.send(fs.createReadStream(resolved));
136
+ const stream = fs.createReadStream(temporaryFile);
137
+ const cleanup = () => { void fs.promises.rm(temporaryFile, { force: true }).catch(() => undefined); };
138
+ stream.once("close", cleanup);
139
+ stream.once("error", cleanup);
140
+ return reply.send(stream);
89
141
  }
90
142
  if (stat.size > 2 * 1024 * 1024 || !isTextCompileArtifact(relative)) {
91
143
  return apiError(reply, 415, "ARTIFACT_PREVIEW_UNSUPPORTED", "该产物不能作为文本预览,请下载后查看");
92
144
  }
93
- return { path: relative, content: fs.readFileSync(resolved, "utf8") };
145
+ try {
146
+ return { path: relative, content: fs.readFileSync(resolved, "utf8") };
147
+ }
148
+ catch (error) {
149
+ if (isMissingFileError(error))
150
+ return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
151
+ throw error;
152
+ }
94
153
  });
95
154
  app.post("/api/projects/:id/compile/clean", async (request, reply) => {
96
155
  const user = requireUser(request, reply, db);
@@ -107,37 +166,49 @@ export function registerCompileRoutes(app, context) {
107
166
  if (body.mode !== "cache" && body.mode !== "artifacts") {
108
167
  return apiError(reply, 400, "REQUEST_INVALID", "清理模式无效");
109
168
  }
110
- const activeRun = db.prepare(`SELECT 1 AS active FROM compile_runs
111
- WHERE project_id = ? AND main_file = ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile);
112
- if (activeRun)
113
- return apiError(reply, 409, "COMPILE_CLEAN_BUSY", "当前主文档正在编译,请稍后再清理");
114
- const requestedBy = { id: user.id, username: user.username, name: user.display_name };
115
- if (body.mode === "cache") {
116
- cleanCompileCache(config, id, mainFile);
169
+ return await projectMutations.runCompileExclusive(id, () => {
170
+ const activeRun = db.prepare(`SELECT 1 AS active FROM compile_runs
171
+ WHERE project_id = ? AND main_file = ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile);
172
+ if (activeRun)
173
+ return apiError(reply, 409, "COMPILE_CLEAN_BUSY", "当前主文档正在编译,请稍后再清理");
174
+ const requestedBy = { id: user.id, username: user.username, name: user.display_name };
175
+ if (body.mode === "cache") {
176
+ cleanCompileCache(config, id, mainFile);
177
+ collaboration.signalCompileState(id, {
178
+ mainFile,
179
+ runId: `clean-${randomUUID()}`,
180
+ status: "cleaned",
181
+ cleanMode: "cache",
182
+ requestedBy,
183
+ updatedAt: now()
184
+ });
185
+ return { ok: true, mode: "cache", mainFile, retainedPdf: true };
186
+ }
187
+ const currentProject = accessibleProject(db, id, user);
188
+ if (!currentProject || !canEdit(currentProject))
189
+ return apiError(reply, 403, "COMPILE_FORBIDDEN", "没有清理编译产物的权限");
190
+ const runs = db.prepare(`SELECT id FROM compile_runs
191
+ WHERE project_id = ? AND main_file = ? AND status NOT IN ('queued', 'running')`).all(id, mainFile);
192
+ cleanCompileArtifacts(config, id, mainFile, currentProject.main_file, runs.map((run) => run.id));
193
+ db.prepare("DELETE FROM compile_runs WHERE project_id = ? AND main_file = ? AND status NOT IN ('queued', 'running')").run(id, mainFile);
194
+ pruneCompileRuns(id);
117
195
  collaboration.signalCompileState(id, {
118
196
  mainFile,
119
197
  runId: `clean-${randomUUID()}`,
120
198
  status: "cleaned",
121
- cleanMode: "cache",
199
+ cleanMode: "artifacts",
122
200
  requestedBy,
123
201
  updatedAt: now()
124
202
  });
125
- return { ok: true, mode: "cache", mainFile, retainedPdf: true };
126
- }
127
- const runs = db.prepare(`SELECT id FROM compile_runs
128
- WHERE project_id = ? AND main_file = ? AND status NOT IN ('queued', 'running')`).all(id, mainFile);
129
- cleanCompileArtifacts(config, id, mainFile, project.main_file, runs.map((run) => run.id));
130
- db.prepare("DELETE FROM compile_runs WHERE project_id = ? AND main_file = ? AND status NOT IN ('queued', 'running')").run(id, mainFile);
131
- pruneCompileRuns(id);
132
- collaboration.signalCompileState(id, {
133
- mainFile,
134
- runId: `clean-${randomUUID()}`,
135
- status: "cleaned",
136
- cleanMode: "artifacts",
137
- requestedBy,
138
- updatedAt: now()
139
- });
140
- return { ok: true, mode: "artifacts", mainFile, retainedPdf: false };
203
+ return { ok: true, mode: "artifacts", mainFile, retainedPdf: false };
204
+ }, { preflight: () => {
205
+ const current = accessibleProject(db, id, user);
206
+ if (!current || !canEdit(current))
207
+ throw Object.assign(new Error("没有清理编译产物的权限"), { statusCode: 403, code: "COMPILE_FORBIDDEN" });
208
+ const selected = compileMainFile(config, id, current.main_file, body.mainFile);
209
+ if (!selected || selected !== mainFile)
210
+ throw Object.assign(new Error("所选文件不是有效的 LaTeX 主文档"), { statusCode: 400, code: "MAIN_DOCUMENT_INVALID" });
211
+ } });
141
212
  });
142
213
  app.get("/api/projects/:id/sync/pdf", async (request, reply) => {
143
214
  const user = requireUser(request, reply, db);
@@ -157,13 +228,18 @@ export function registerCompileRoutes(app, context) {
157
228
  if (!Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
158
229
  return apiError(reply, 400, "SYNC_SOURCE_INVALID", "源码位置无效");
159
230
  }
160
- if (!fs.existsSync(resolveSourcePath(config, id, sourcePath))) {
161
- return apiError(reply, 404, "SOURCE_FILE_NOT_FOUND", "源码文件不存在");
162
- }
163
- const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
164
- if (!artifacts)
165
- return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
166
- return await sourceToPdf(artifacts.source, artifacts.pdf, sourcePath, line, column);
231
+ return await projectMutations.runConsistentRead(id, async () => {
232
+ if (!fs.existsSync(resolveSourcePath(config, id, sourcePath))) {
233
+ return apiError(reply, 404, "SOURCE_FILE_NOT_FOUND", "源码文件不存在");
234
+ }
235
+ const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
236
+ if (!artifacts)
237
+ return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
238
+ return await sourceToPdf(artifacts.source, artifacts.pdf, sourcePath, line, column);
239
+ }, { preflight: () => {
240
+ if (!accessibleProject(db, id, user))
241
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
242
+ } });
167
243
  });
168
244
  app.get("/api/projects/:id/sync/source", async (request, reply) => {
169
245
  const user = requireUser(request, reply, db);
@@ -183,16 +259,21 @@ export function registerCompileRoutes(app, context) {
183
259
  if (!Number.isInteger(page) || page < 1 || !Number.isFinite(x) || x < 0 || !Number.isFinite(y) || y < 0) {
184
260
  return apiError(reply, 400, "SYNC_PDF_INVALID", "PDF 位置无效");
185
261
  }
186
- const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
187
- if (!artifacts)
188
- return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
189
- const location = await pdfToSource(artifacts.source, artifacts.pdf, page, x, y);
190
- const sourceDirectory = path.resolve(artifacts.source);
191
- const relative = path.relative(sourceDirectory, path.resolve(location.input));
192
- if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
193
- return apiError(reply, 400, "SYNCTEX_EXTERNAL_PATH", "SyncTeX 返回了项目外部的源码路径");
194
- }
195
- return { path: safeRelativePath(relative.replaceAll(path.sep, "/")), line: location.line, column: location.column };
262
+ return await projectMutations.runConsistentRead(id, async () => {
263
+ const artifacts = syncArtifacts(config, id, mainFile, project.main_file);
264
+ if (!artifacts)
265
+ return apiError(reply, 409, "SYNCTEX_NOT_AVAILABLE", "项目尚无可用的 SyncTeX 数据,请重新编译");
266
+ const location = await pdfToSource(artifacts.source, artifacts.pdf, page, x, y);
267
+ const sourceDirectory = path.resolve(artifacts.source);
268
+ const relative = path.relative(sourceDirectory, path.resolve(location.input));
269
+ if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) {
270
+ return apiError(reply, 400, "SYNCTEX_EXTERNAL_PATH", "SyncTeX 返回了项目外部的源码路径");
271
+ }
272
+ return { path: safeRelativePath(relative.replaceAll(path.sep, "/")), line: location.line, column: location.column };
273
+ }, { preflight: () => {
274
+ if (!accessibleProject(db, id, user))
275
+ throw Object.assign(new Error("项目不存在"), { statusCode: 404, code: "PROJECT_NOT_FOUND" });
276
+ } });
196
277
  });
197
278
  app.post("/api/projects/:id/compile", async (request, reply) => {
198
279
  const requestStartedAt = performance.now();
@@ -200,116 +281,358 @@ export function registerCompileRoutes(app, context) {
200
281
  if (!user)
201
282
  return;
202
283
  const { id } = request.params;
203
- const project = accessibleProject(db, id, user);
204
- if (!project || !canEdit(project))
284
+ const initialProject = accessibleProject(db, id, user);
285
+ if (!initialProject || !canEdit(initialProject))
205
286
  return apiError(reply, 403, "COMPILE_FORBIDDEN", "没有编译权限");
206
287
  const body = (request.body ?? {});
207
- const mainFile = compileMainFile(config, id, project.main_file, body.mainFile);
208
- if (!mainFile)
288
+ const requestedMainFile = typeof body.mainFile === "string" && body.mainFile ? body.mainFile : null;
289
+ const initialMainFile = compileMainFile(config, id, initialProject.main_file, body.mainFile);
290
+ if (!initialMainFile)
209
291
  return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
210
- collaboration.flushProject(id);
211
292
  const runId = randomUUID();
212
- const snapshotStartedAt = performance.now();
213
- const snapshot = await captureCompileSnapshot(config, id, runId, {
214
- mainFile,
215
- engine: project.engine,
216
- latexmkrc: project.latexmkrc,
217
- extraArgs: config.extraArgs
218
- });
219
- const snapshotMs = performance.now() - snapshotStartedAt;
220
- metrics.record("compile.snapshot", snapshotMs);
221
- const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
222
- const publishedRun = published ? db.prepare(`SELECT id, status, log, finished_at
223
- FROM compile_runs WHERE id = ? AND project_id = ?`).get(published.runId, id) : undefined;
224
- const latestRun = db.prepare(`SELECT id, status FROM compile_runs
225
- WHERE project_id = ? AND main_file = ? ORDER BY created_at DESC LIMIT 1`).get(id, mainFile);
226
- const activeRun = db.prepare(`SELECT 1 AS active FROM compile_runs
227
- WHERE project_id = ? AND main_file = ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile);
228
- if (!activeRun && published && publishedRun?.status === "succeeded"
229
- && latestRun?.id === publishedRun.id && snapshot.revision === published.revision
230
- && hasCompileCache(config, id, mainFile)) {
231
- discardCompileSnapshot(snapshot);
232
- const requestMs = performance.now() - requestStartedAt;
233
- metrics.record("compile.request", requestMs);
234
- reply.header("Server-Timing", `snapshot;dur=${timingDuration(snapshotMs)}, total;dur=${timingDuration(requestMs)}`);
235
- return {
236
- runId: published.runId,
237
- ok: true,
238
- skipped: true,
239
- log: publishedRun.log ?? "",
240
- diagnostics: parseCompileDiagnostics(publishedRun.log ?? "", "succeeded"),
241
- mainFile,
242
- pdfUrl: compilePdfUrl(id, mainFile, published.runId),
243
- pdfCompiledAt: publishedRun.finished_at,
244
- timings: { snapshotMs, requestMs }
293
+ // Admission must stay cheap. In particular, do not call
294
+ // captureCompileSnapshot here: several collaborators can reach this
295
+ // endpoint at the same time, and the coordinator needs to coalesce them
296
+ // before any project files are copied or hashed.
297
+ const admission = await projectMutations.runSnapshot(id, () => {
298
+ const currentProject = accessibleProject(db, id, user);
299
+ if (!currentProject || !canEdit(currentProject))
300
+ throw new Error("Project access changed while waiting for a source snapshot");
301
+ const currentMainFile = compileMainFile(config, id, currentProject.main_file, body.mainFile);
302
+ if (!currentMainFile)
303
+ throw new Error("Selected main document is no longer available");
304
+ if (!requestedMainFile && currentMainFile !== initialMainFile) {
305
+ throw mainDocumentChangedError();
306
+ }
307
+ // flushProject() is also the final durability barrier for collaborative
308
+ // edits. Its revision is stable for a no-op flush, so concurrent
309
+ // admissions observe the same generation without reading file contents.
310
+ const revisionBeforeFlush = collaboration.currentRevision(id);
311
+ const receipt = collaboration.hasPendingChanges(id) ? projectMutations.flushProject(id) : null;
312
+ const refreshedProject = accessibleProject(db, id, user);
313
+ if (!refreshedProject || !canEdit(refreshedProject)) {
314
+ throw new Error("Project access changed while preparing a compile request");
315
+ }
316
+ const generationRevision = receipt?.revision ?? revisionBeforeFlush;
317
+ const admissionResult = {
318
+ project: refreshedProject,
319
+ mainFile: currentMainFile,
320
+ generation: compileRequestGeneration(refreshedProject, generationRevision, collaboration.currentGeneration(id), config.extraArgs)
245
321
  };
246
- }
247
- db.prepare("INSERT INTO compile_runs (id, project_id, requested_by, main_file, status, created_at) VALUES (?, ?, ?, ?, 'queued', ?)")
248
- .run(runId, id, user.id, mainFile, now());
322
+ // Insert the queued probe while the project lock is still held. A
323
+ // concurrent clean request must observe this row before the compile
324
+ // coordinator starts copying a snapshot.
325
+ db.prepare("INSERT INTO compile_runs (id, project_id, requested_by, main_file, status, created_at) VALUES (?, ?, ?, ?, 'queued', ?)")
326
+ .run(runId, id, user.id, currentMainFile, now());
327
+ return admissionResult;
328
+ });
329
+ let project = admission.project;
330
+ let mainFile = admission.mainFile;
249
331
  const requestedBy = { id: user.id, username: user.username, name: user.display_name };
250
332
  let phase = "queued";
251
- const broadcast = () => collaboration.signalCompileState(id, { mainFile, runId, status: phase, requestedBy, updatedAt: now() });
333
+ let snapshotStale = false;
334
+ let snapshotGeneration = null;
335
+ const refreshSnapshotStale = () => {
336
+ if (!snapshotGeneration)
337
+ return;
338
+ if (collaboration.hasPendingChanges(id)) {
339
+ snapshotStale = true;
340
+ return;
341
+ }
342
+ const currentProject = accessibleProject(db, id, user);
343
+ if (!currentProject)
344
+ return;
345
+ const currentGeneration = compileRequestGeneration(currentProject, collaboration.currentRevision(id), collaboration.currentGeneration(id), config.extraArgs);
346
+ if (currentGeneration !== snapshotGeneration)
347
+ snapshotStale = true;
348
+ };
349
+ const broadcast = () => collaboration.signalCompileState(id, {
350
+ mainFile, runId, status: phase, requestedBy, updatedAt: now(),
351
+ ...(phase === "succeeded" && snapshotStale ? { stale: true } : {})
352
+ });
252
353
  const result = await compileCoordinator.request({
253
354
  projectId: id,
254
355
  target: mainFile,
255
356
  runId,
256
- revision: snapshot.revision,
357
+ // This key is computed before snapshot creation. The content digest
358
+ // returned by captureCompileSnapshot is only the result revision.
359
+ generation: admission.generation,
360
+ revision: admission.generation,
257
361
  onQueued: broadcast,
258
362
  onSelected: broadcast,
259
363
  onDiscarded: () => {
260
364
  db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
261
- discardCompileSnapshot(snapshot);
262
365
  },
263
- execute: async () => {
264
- phase = "running";
265
- db.prepare("UPDATE compile_runs SET status = 'running' WHERE id = ?").run(runId);
266
- broadcast();
267
- let compiled;
366
+ // The compile reservation protects the shared compiler cache and
367
+ // prevents Git/cleanup/deletion from replacing the project while
368
+ // latexmk is running, but it deliberately does not occupy the ordinary
369
+ // project queue for the duration of the subprocess.
370
+ execute: async () => projectMutations.runCompile(id, async () => {
371
+ let snapshot = null;
372
+ let snapshotMs = 0;
373
+ const snapshotStartedAt = performance.now();
268
374
  try {
269
- compiled = await compileProject(config, snapshot, mainFile, project.engine, project.latexmkrc);
270
- if (compiled.ok && compiled.pdfPath) {
271
- if (!db.prepare("SELECT 1 FROM projects WHERE id = ?").get(id))
272
- throw new Error("项目已被删除");
273
- const publishStartedAt = performance.now();
274
- publishCompileArtifacts(config, id, snapshot, compiled);
275
- if (compiled.timings) {
276
- compiled.timings.publishMs = performance.now() - publishStartedAt;
277
- compiled.timings.totalMs += compiled.timings.publishMs;
375
+ // Refresh the cheap generation after waiting for any queued source
376
+ // operation. This makes the no-op check safe when a Git/HTTP write
377
+ // completed after admission but before this compile got a worker.
378
+ const refreshed = await projectMutations.runSerialized(id, () => {
379
+ const currentProject = accessibleProject(db, id, user);
380
+ if (!currentProject || !canEdit(currentProject)) {
381
+ throw new Error("Project access changed while waiting for a source snapshot");
278
382
  }
383
+ const currentMainFile = compileMainFile(config, id, currentProject.main_file, body.mainFile);
384
+ if (!currentMainFile)
385
+ throw new Error("Selected main document is no longer available");
386
+ if (!requestedMainFile && currentMainFile !== mainFile)
387
+ throw mainDocumentChangedError();
388
+ project = currentProject;
389
+ mainFile = currentMainFile;
390
+ return {
391
+ generation: compileRequestGeneration(currentProject, collaboration.currentRevision(id), collaboration.currentGeneration(id), config.extraArgs)
392
+ };
393
+ });
394
+ const currentGeneration = refreshed.generation;
395
+ const publishedBeforeSnapshot = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
396
+ const publishedRunBeforeSnapshot = publishedBeforeSnapshot ? db.prepare(`SELECT id, status, log, finished_at
397
+ FROM compile_runs WHERE id = ? AND project_id = ?`).get(publishedBeforeSnapshot.runId, id) : undefined;
398
+ const latestRunBeforeSnapshot = db.prepare(`SELECT id, status FROM compile_runs
399
+ WHERE project_id = ? AND main_file = ? AND id <> ? ORDER BY created_at DESC LIMIT 1`).get(id, mainFile, runId);
400
+ const activeRunBeforeSnapshot = db.prepare(`SELECT 1 AS active FROM compile_runs
401
+ WHERE project_id = ? AND main_file = ? AND id <> ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile, runId);
402
+ if (!activeRunBeforeSnapshot && publishedBeforeSnapshot?.generation === currentGeneration
403
+ && publishedRunBeforeSnapshot?.status === "succeeded"
404
+ && latestRunBeforeSnapshot?.id === publishedRunBeforeSnapshot.id
405
+ && hasCompileCache(config, id, mainFile)) {
406
+ db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
407
+ phase = "succeeded";
408
+ broadcast();
409
+ return {
410
+ runId: publishedBeforeSnapshot.runId,
411
+ ok: true,
412
+ skipped: true,
413
+ log: publishedRunBeforeSnapshot.log ?? "",
414
+ diagnostics: parseCompileDiagnostics(publishedRunBeforeSnapshot.log ?? "", "succeeded"),
415
+ pdfPath: null,
416
+ synctexPath: publishedBeforeSnapshot.synctex,
417
+ revision: publishedBeforeSnapshot.revision,
418
+ snapshotMs: 0
419
+ };
279
420
  }
280
- else {
421
+ const captured = await projectMutations.runSerialized(id, async () => {
422
+ const currentProject = accessibleProject(db, id, user);
423
+ if (!currentProject || !canEdit(currentProject)) {
424
+ throw new Error("Project access changed while waiting for a source snapshot");
425
+ }
426
+ const currentMainFile = compileMainFile(config, id, currentProject.main_file, body.mainFile);
427
+ if (!currentMainFile)
428
+ throw new Error("Selected main document is no longer available");
429
+ if (!requestedMainFile && currentMainFile !== mainFile)
430
+ throw mainDocumentChangedError();
431
+ project = currentProject;
432
+ mainFile = currentMainFile;
433
+ // Capture only after all exclusive source-tree operations queued
434
+ // before this request have completed. A collaboration barrier
435
+ // keeps autosave and explicit flushes from changing the source
436
+ // directory while the asynchronous copy is in progress.
437
+ for (let attempt = 0; attempt < MAX_SNAPSHOT_ATTEMPTS; attempt += 1) {
438
+ const revisionBeforeFlush = collaboration.currentRevision(id);
439
+ const receipt = collaboration.hasPendingChanges(id) ? projectMutations.flushProject(id) : null;
440
+ const expectedRevision = receipt?.revision ?? revisionBeforeFlush;
441
+ const generationKey = compileRequestGeneration(project, expectedRevision, collaboration.currentGeneration(id), config.extraArgs);
442
+ let candidate = null;
443
+ let captureError = null;
444
+ let releaseError = null;
445
+ let afterBarrierRevision = null;
446
+ collaboration.beginSnapshotBarrier(id);
447
+ try {
448
+ candidate = await captureCompileSnapshot(config, id, runId, {
449
+ mainFile,
450
+ engine: project.engine,
451
+ latexmkrc: project.latexmkrc,
452
+ extraArgs: config.extraArgs,
453
+ generation: generationKey
454
+ });
455
+ }
456
+ catch (error) {
457
+ captureError = error;
458
+ }
459
+ try {
460
+ const afterBarrier = collaboration.endSnapshotBarrier(id);
461
+ afterBarrierRevision = afterBarrier?.revision ?? collaboration.currentRevision(id);
462
+ }
463
+ catch (error) {
464
+ releaseError = error;
465
+ }
466
+ if (captureError) {
467
+ if (candidate)
468
+ discardCompileSnapshot(candidate);
469
+ throw captureError;
470
+ }
471
+ if (releaseError) {
472
+ if (candidate)
473
+ discardCompileSnapshot(candidate);
474
+ throw releaseError;
475
+ }
476
+ if (!candidate)
477
+ throw new Error("Unable to create a compile snapshot");
478
+ // The barrier makes the candidate internally consistent. Edits
479
+ // that arrive while files are being copied stay in Yjs memory
480
+ // and are flushed only after the barrier is released; they do
481
+ // not make the candidate a mixed or unsafe snapshot. Keep the
482
+ // snapshot and surface that it predates those newer edits.
483
+ const stable = collaboration.isStable(id, afterBarrierRevision);
484
+ if (stable) {
485
+ snapshotStale = afterBarrierRevision !== expectedRevision;
486
+ snapshotGeneration = candidate.generation ?? null;
487
+ return candidate;
488
+ }
489
+ discardCompileSnapshot(candidate);
490
+ }
491
+ throw new CompileSnapshotBusyError();
492
+ }, { flush: false });
493
+ snapshot = captured;
494
+ snapshotMs = performance.now() - snapshotStartedAt;
495
+ metrics.record("compile.snapshot", snapshotMs);
496
+ const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
497
+ const publishedRun = published ? db.prepare(`SELECT id, status, log, finished_at
498
+ FROM compile_runs WHERE id = ? AND project_id = ?`).get(published.runId, id) : undefined;
499
+ // The current queued row is intentionally excluded. It is a request
500
+ // probe, not evidence that the last successful compile is stale.
501
+ const latestRun = db.prepare(`SELECT id, status FROM compile_runs
502
+ WHERE project_id = ? AND main_file = ? AND id <> ? ORDER BY created_at DESC LIMIT 1`).get(id, mainFile, runId);
503
+ const activeRun = db.prepare(`SELECT 1 AS active FROM compile_runs
504
+ WHERE project_id = ? AND main_file = ? AND id <> ? AND status IN ('queued', 'running') LIMIT 1`).get(id, mainFile, runId);
505
+ if (!activeRun && published && publishedRun?.status === "succeeded"
506
+ && latestRun?.id === publishedRun.id && snapshot.revision === published.revision
507
+ && hasCompileCache(config, id, mainFile)) {
281
508
  discardCompileSnapshot(snapshot);
509
+ snapshot = null;
510
+ db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
511
+ phase = "succeeded";
512
+ broadcast();
513
+ return {
514
+ runId: published.runId,
515
+ ok: true,
516
+ skipped: true,
517
+ stale: snapshotStale,
518
+ log: publishedRun.log ?? "",
519
+ diagnostics: parseCompileDiagnostics(publishedRun.log ?? "", "succeeded"),
520
+ pdfPath: null,
521
+ synctexPath: published.synctex,
522
+ revision: published.revision,
523
+ snapshotMs
524
+ };
525
+ }
526
+ phase = "running";
527
+ db.prepare("UPDATE compile_runs SET status = 'running' WHERE id = ?").run(runId);
528
+ broadcast();
529
+ let compiled;
530
+ try {
531
+ compiled = await compileProject(config, snapshot, mainFile, project.engine, project.latexmkrc);
532
+ refreshSnapshotStale();
533
+ if (compiled.ok && compiled.pdfPath) {
534
+ const publishStartedAt = performance.now();
535
+ // Publishing is short and atomic, but still goes through the
536
+ // ordinary project queue so it cannot overlap a source-tree
537
+ // archive/read or another output mutation. The long latexmk
538
+ // process above remains outside that queue.
539
+ await projectMutations.runSerialized(id, () => {
540
+ if (!db.prepare("SELECT 1 FROM projects WHERE id = ?").get(id))
541
+ throw new Error("项目已被删除");
542
+ publishCompileArtifacts(config, id, snapshot, compiled);
543
+ }, { flush: false });
544
+ refreshSnapshotStale();
545
+ if (compiled.timings) {
546
+ compiled.timings.publishMs = performance.now() - publishStartedAt;
547
+ compiled.timings.totalMs += compiled.timings.publishMs;
548
+ }
549
+ }
550
+ else {
551
+ discardCompileSnapshot(snapshot);
552
+ snapshot = null;
553
+ }
554
+ }
555
+ catch (error) {
556
+ if (snapshot)
557
+ discardCompileSnapshot(snapshot);
558
+ snapshot = null;
559
+ compiled = {
560
+ ok: false,
561
+ log: error instanceof Error ? error.message : String(error),
562
+ diagnostics: parseCompileDiagnostics(error instanceof Error ? error.message : String(error), "failed"),
563
+ pdfPath: null,
564
+ synctexPath: null
565
+ };
282
566
  }
567
+ phase = compiled.ok ? "succeeded" : "failed";
568
+ db.prepare("UPDATE compile_runs SET status = ?, log = ?, finished_at = ? WHERE id = ?")
569
+ .run(phase, compiled.log, now(), runId);
570
+ broadcast();
571
+ return { ...compiled, runId, revision: snapshot?.revision ?? admission.generation, stale: snapshotStale, snapshotMs };
283
572
  }
284
573
  catch (error) {
285
- discardCompileSnapshot(snapshot);
286
- compiled = {
574
+ if (snapshot)
575
+ discardCompileSnapshot(snapshot);
576
+ if (error instanceof CompileSnapshotBusyError) {
577
+ snapshotMs = performance.now() - snapshotStartedAt;
578
+ metrics.record("compile.snapshot", snapshotMs);
579
+ phase = "failed";
580
+ db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
581
+ broadcast();
582
+ return {
583
+ ok: false,
584
+ retryable: true,
585
+ errorCode: error.code,
586
+ log: error.message,
587
+ diagnostics: { warnings: [], errors: [] },
588
+ pdfPath: null,
589
+ synctexPath: null,
590
+ runId,
591
+ revision: admission.generation,
592
+ snapshotMs
593
+ };
594
+ }
595
+ const log = error instanceof Error ? error.message : String(error);
596
+ phase = "failed";
597
+ db.prepare("UPDATE compile_runs SET status = 'failed', log = ?, finished_at = ? WHERE id = ?")
598
+ .run(log, now(), runId);
599
+ broadcast();
600
+ return {
287
601
  ok: false,
288
- log: error instanceof Error ? error.message : String(error),
289
- diagnostics: parseCompileDiagnostics(error instanceof Error ? error.message : String(error), "failed"),
602
+ log,
603
+ diagnostics: parseCompileDiagnostics(log, "failed"),
290
604
  pdfPath: null,
291
- synctexPath: null
605
+ synctexPath: null,
606
+ runId,
607
+ revision: admission.generation,
608
+ snapshotMs
292
609
  };
293
610
  }
294
- phase = compiled.ok ? "succeeded" : "failed";
295
- db.prepare("UPDATE compile_runs SET status = ?, log = ?, finished_at = ? WHERE id = ?")
296
- .run(phase, compiled.log, now(), runId);
297
- broadcast();
298
- return { ...compiled, runId, revision: snapshot.revision };
299
- }
611
+ }),
300
612
  });
301
613
  const completed = db.prepare("SELECT finished_at FROM compile_runs WHERE id = ?").get(result.runId);
302
614
  const requestMs = performance.now() - requestStartedAt;
303
615
  metrics.record("compile.request", requestMs);
616
+ if (result.retryable) {
617
+ await projectMutations.runSerialized(id, () => { pruneCompileRuns(id); }, { flush: false });
618
+ reply.header("Retry-After", String(SNAPSHOT_RETRY_AFTER_SECONDS));
619
+ reply.header("Server-Timing", [
620
+ `snapshot;dur=${timingDuration(result.snapshotMs ?? 0)}`,
621
+ `total;dur=${timingDuration(requestMs)}`
622
+ ].join(", "));
623
+ return apiError(reply, 409, result.errorCode ?? "COMPILE_SNAPSHOT_BUSY", result.log, { retryable: true, retryAfterSeconds: SNAPSHOT_RETRY_AFTER_SECONDS });
624
+ }
304
625
  if (result.timings) {
305
626
  metrics.record("compile.cacheSync", result.timings.cacheSyncMs);
306
627
  metrics.record("compile.latexmk", result.timings.latexmkMs);
307
628
  metrics.record("compile.artifactCopy", result.timings.artifactCopyMs);
308
629
  }
309
- pruneCompileRuns(id);
310
- const timings = result.timings ? { snapshotMs, ...result.timings, requestMs } : { snapshotMs, requestMs };
630
+ await projectMutations.runSerialized(id, () => { pruneCompileRuns(id); }, { flush: false });
631
+ const timings = result.timings
632
+ ? { snapshotMs: result.snapshotMs ?? 0, ...result.timings, requestMs }
633
+ : { snapshotMs: result.snapshotMs ?? 0, requestMs };
311
634
  reply.header("Server-Timing", [
312
- `snapshot;dur=${timingDuration(snapshotMs)}`,
635
+ `snapshot;dur=${timingDuration(result.snapshotMs ?? 0)}`,
313
636
  result.timings ? `cache;dur=${timingDuration(result.timings.cacheSyncMs)}` : "",
314
637
  result.timings ? `latexmk;dur=${timingDuration(result.timings.latexmkMs)}` : "",
315
638
  result.timings ? `artifacts;dur=${timingDuration(result.timings.artifactCopyMs)}` : "",
@@ -317,12 +640,17 @@ export function registerCompileRoutes(app, context) {
317
640
  `total;dur=${timingDuration(requestMs)}`
318
641
  ].filter(Boolean).join(", "));
319
642
  return {
320
- mainFile, runId: result.runId, ok: result.ok, skipped: false, log: result.log, diagnostics: result.diagnostics,
643
+ mainFile, runId: result.runId, ok: result.ok, skipped: result.skipped === true, log: result.log, diagnostics: result.diagnostics,
321
644
  pdfUrl: result.ok ? compilePdfUrl(id, mainFile, result.runId) : null,
322
645
  pdfCompiledAt: result.ok ? completed?.finished_at ?? null : null,
323
646
  timings
324
647
  };
325
648
  });
649
+ // Published PDFs are retained, immutable artifacts. Keep this read outside
650
+ // projectMutations: runSerialized/runConsistentRead wait for a cold Yjs room
651
+ // to finish initializing, which would delay the first PDF paint after a
652
+ // server restart. The manifest and per-run bundle are swapped atomically,
653
+ // so a concurrent cleanup can safely result in a normal 404 instead.
326
654
  app.get("/api/projects/:id/pdf", async (request, reply) => {
327
655
  const user = requireUser(request, reply, db);
328
656
  if (!user)
@@ -336,17 +664,43 @@ export function registerCompileRoutes(app, context) {
336
664
  if (!mainFile)
337
665
  return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
338
666
  const downloading = query.download === "1";
339
- const artifact = availablePdf(config, id, mainFile, project.main_file);
667
+ let artifact = availablePdf(config, id, mainFile, project.main_file);
340
668
  if (!artifact)
341
669
  return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
670
+ let versioned = false;
671
+ if (query.run) {
672
+ if (/^[a-f0-9-]{36}$/i.test(query.run)) {
673
+ const run = db.prepare(`SELECT main_file, status FROM compile_runs
674
+ WHERE id = ? AND project_id = ?`).get(query.run, id);
675
+ if (!run || run.status !== "succeeded" || run.main_file !== mainFile) {
676
+ return apiError(reply, 404, "PDF_NOT_FOUND", "指定的编译 PDF 不存在");
677
+ }
678
+ artifact = compileRunPdf(config, id, mainFile, query.run);
679
+ if (!artifact)
680
+ return apiError(reply, 404, "PDF_NOT_FOUND", "指定的编译 PDF 已被清理");
681
+ versioned = true;
682
+ }
683
+ else if (artifact.version !== query.run) {
684
+ return apiError(reply, 404, "PDF_NOT_FOUND", "指定的编译 PDF 不存在");
685
+ }
686
+ }
342
687
  const pdf = artifact.path;
343
- const stat = fs.statSync(pdf);
688
+ const stat = regularFileStat(pdf);
689
+ if (!stat)
690
+ return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
344
691
  const etag = `"${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}"`;
345
692
  reply.header("Content-Type", "application/pdf");
346
- const targetSuffix = mainFile === project.main_file ? "" : `-${path.basename(mainFile, ".tex")}`;
693
+ const targetSuffix = mainFile === project.main_file ? "" : `-${texFileStem(mainFile)}`;
347
694
  const filename = downloading ? `${project.name}${targetSuffix}-${pdfDownloadTimestamp()}.pdf` : `${project.name}${targetSuffix}.pdf`;
348
695
  reply.header("Content-Disposition", contentDisposition(filename, downloading ? "attachment" : "inline"));
349
- reply.header("Cache-Control", "private, no-cache");
696
+ // The run query identifies an immutable successful compile. Keep it in the
697
+ // browser's private cache so reopening a project does not re-download the
698
+ // same PDF; a new successful compile receives a new run URL.
699
+ reply.header("Cache-Control", downloading
700
+ ? "private, no-store"
701
+ : versioned
702
+ ? "private, max-age=31536000, immutable"
703
+ : "private, max-age=60, must-revalidate");
350
704
  reply.header("Accept-Ranges", "bytes");
351
705
  reply.header("ETag", etag);
352
706
  reply.header("Last-Modified", stat.mtime.toUTCString());
@@ -361,10 +715,59 @@ export function registerCompileRoutes(app, context) {
361
715
  if (range) {
362
716
  reply.header("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
363
717
  reply.header("Content-Length", range.end - range.start + 1);
364
- return reply.code(206).send(fs.createReadStream(pdf, range));
718
+ let stream;
719
+ try {
720
+ stream = await openReadStream(fs.createReadStream(pdf, range));
721
+ }
722
+ catch (error) {
723
+ if (isMissingFileError(error))
724
+ return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
725
+ throw error;
726
+ }
727
+ return reply.code(206).send(stream);
365
728
  }
366
729
  reply.header("Content-Length", stat.size);
367
- return reply.send(fs.createReadStream(pdf));
730
+ let stream;
731
+ try {
732
+ stream = await openReadStream(fs.createReadStream(pdf));
733
+ }
734
+ catch (error) {
735
+ if (isMissingFileError(error))
736
+ return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
737
+ throw error;
738
+ }
739
+ return reply.code(200).send(stream);
740
+ });
741
+ }
742
+ function regularFileStat(filePath) {
743
+ try {
744
+ const stat = fs.statSync(filePath);
745
+ return stat.isFile() ? stat : null;
746
+ }
747
+ catch {
748
+ return null;
749
+ }
750
+ }
751
+ function isMissingFileError(error) {
752
+ return typeof error === "object" && error !== null && "code" in error
753
+ && (error.code === "ENOENT" || error.code === "ENOTDIR");
754
+ }
755
+ async function openReadStream(stream) {
756
+ const opened = new Promise((resolve, reject) => {
757
+ stream.once("open", () => resolve());
758
+ stream.once("error", reject);
759
+ });
760
+ await opened;
761
+ return stream;
762
+ }
763
+ function compileRequestGeneration(project, persistedRevision, sourceGeneration, extraArgs) {
764
+ return JSON.stringify({
765
+ mainFile: project.main_file,
766
+ engine: project.engine,
767
+ latexmkrc: project.latexmkrc,
768
+ persistedRevision,
769
+ sourceGeneration,
770
+ extraArgs
368
771
  });
369
772
  }
370
773
  function pdfDownloadTimestamp(date = new Date()) {