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.
- package/README.md +1 -0
- package/dist/client/assets/{BinaryModule-Aj1vLnwf-BsIXahIW.js → BinaryModule-Aj1vLnwf-hZ2K_uyt.js} +1 -1
- package/dist/client/assets/{GitDialog-CFzsF7ys.js → GitDialog-s54eAFX6.js} +1 -1
- package/dist/client/assets/{HistoryDialog-YPiAOGU_.js → HistoryDialog-DrUnIy0a.js} +1 -1
- package/dist/client/assets/{LatexEditor-BX4WLdhS.js → LatexEditor-Y5KfVqQI.js} +20 -20
- package/dist/client/assets/PdfPreview-BPKX-qpr.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-BZanedGt.js → ProjectNavigationDialogs-CjZX0g7O.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-DnH4c7Se.js → SystemMetricsDialog-B97Og0La.js} +1 -1
- package/dist/client/assets/binary-DCBFVo4B.js +1 -0
- package/dist/client/assets/index-5ajAy27z.css +1 -0
- package/dist/client/assets/index-CCitTPyj.js +17 -0
- package/dist/client/assets/{index-B5iMrEn5.js → index-CQG_gpuU.js} +1 -1
- package/dist/client/assets/{plus-B-DG5h4y.js → plus-Bp4_lUor.js} +1 -1
- package/dist/client/assets/{spellCheck-4xuNPF_M.js → spellCheck-0f5ZG5IV.js} +3 -3
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +889 -337
- package/dist/server/archive.js +20 -3
- package/dist/server/auth.js +13 -2
- package/dist/server/collaboration.js +815 -124
- package/dist/server/compileArtifacts.js +37 -9
- package/dist/server/compiler.js +69 -20
- package/dist/server/db.js +6 -0
- package/dist/server/environment.js +4 -2
- package/dist/server/files.js +172 -25
- package/dist/server/git.js +152 -124
- package/dist/server/history.js +86 -18
- package/dist/server/http.js +26 -0
- package/dist/server/index.js +39 -26
- package/dist/server/instanceLock.js +137 -0
- package/dist/server/latexCompletion.js +23 -1
- package/dist/server/latexFormatter.js +4 -2
- package/dist/server/processTree.js +31 -0
- package/dist/server/projectMutations.js +255 -0
- package/dist/server/projectOutline.js +113 -1
- package/dist/server/projectSearch.js +6 -0
- package/dist/server/routes/compile.js +535 -132
- package/dist/server/security.js +61 -1
- package/dist/server/synctex.js +11 -2
- package/dist/server/zip.js +2 -1
- package/package.json +1 -1
- package/dist/client/assets/PdfPreview-FQ8oxyUx.js +0 -1
- package/dist/client/assets/binary-MtJxbY5b.js +0 -1
- package/dist/client/assets/index-CUa7L47p.css +0 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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: "
|
|
199
|
+
cleanMode: "artifacts",
|
|
122
200
|
requestedBy,
|
|
123
201
|
updatedAt: now()
|
|
124
202
|
});
|
|
125
|
-
return { ok: true, mode: "
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
204
|
-
if (!
|
|
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
|
|
208
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
const
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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
|
|
289
|
-
diagnostics: parseCompileDiagnostics(
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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 =
|
|
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 ? "" : `-${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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()) {
|