texlite 0.6.0 → 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-C2TCdTPg.js → BinaryModule-Aj1vLnwf-hZ2K_uyt.js} +1 -1
- package/dist/client/assets/{GitDialog-9mNxx8oX.js → GitDialog-s54eAFX6.js} +1 -1
- package/dist/client/assets/{HistoryDialog-CmoD9Cvf.js → HistoryDialog-DrUnIy0a.js} +1 -1
- package/dist/client/assets/{LatexEditor-Dp8EoZBP.js → LatexEditor-Y5KfVqQI.js} +20 -20
- package/dist/client/assets/PdfPreview-BPKX-qpr.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-veKVnRxc.js → ProjectNavigationDialogs-CjZX0g7O.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-D_oiVmBC.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-CyEG4l54.js → index-CQG_gpuU.js} +1 -1
- package/dist/client/assets/{plus-Bup73Wq6.js → plus-Bp4_lUor.js} +1 -1
- package/dist/client/assets/{spellCheck-BHW5bumN.js → spellCheck-0f5ZG5IV.js} +3 -3
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +888 -339
- package/dist/server/archive.js +20 -3
- package/dist/server/auth.js +13 -2
- package/dist/server/collaboration.js +815 -124
- package/dist/server/compileArtifacts.js +31 -13
- package/dist/server/compiler.js +69 -20
- package/dist/server/db.js +6 -0
- package/dist/server/environment.js +4 -2
- package/dist/server/files.js +172 -25
- package/dist/server/git.js +152 -124
- package/dist/server/history.js +86 -18
- package/dist/server/http.js +26 -0
- package/dist/server/index.js +39 -26
- package/dist/server/instanceLock.js +137 -0
- package/dist/server/latexCompletion.js +23 -1
- package/dist/server/latexFormatter.js +4 -2
- package/dist/server/processTree.js +31 -0
- package/dist/server/projectMutations.js +255 -0
- package/dist/server/projectOutline.js +113 -1
- package/dist/server/projectSearch.js +6 -0
- package/dist/server/routes/compile.js +505 -128
- package/dist/server/security.js +61 -1
- package/dist/server/synctex.js +11 -2
- package/dist/server/zip.js +2 -1
- package/package.json +1 -1
- package/dist/client/assets/PdfPreview-IfkmLMSU.js +0 -1
- package/dist/client/assets/binary-DTriWQzf.js +0 -1
- package/dist/client/assets/index-CI87C5Wy.js +0 -17
- package/dist/client/assets/index-DB6GC33g.css +0 -1
|
@@ -4,7 +4,7 @@ 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";
|
|
@@ -12,8 +12,20 @@ import { availablePdf, compileRunPdf, compileMainFile, compilePdfUrl, isTextComp
|
|
|
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)
|
|
@@ -70,29 +82,74 @@ export function registerCompileRoutes(app, context) {
|
|
|
70
82
|
if (!mainFile)
|
|
71
83
|
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
|
|
72
84
|
const published = publishedCompileArtifacts(config, id, mainFile, mainFile === project.main_file);
|
|
73
|
-
if (!query.path)
|
|
74
|
-
|
|
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
|
+
}
|
|
75
100
|
if (!published)
|
|
76
101
|
return apiError(reply, 404, "COMPILE_ARTIFACTS_NOT_FOUND", "尚无可用的编译产物");
|
|
77
102
|
const relative = safeRelativePath(query.path);
|
|
78
103
|
const absolute = path.join(published.output, relative);
|
|
79
104
|
const outputDirectory = path.resolve(published.output);
|
|
80
105
|
const resolved = path.resolve(absolute);
|
|
81
|
-
if (!resolved.startsWith(`${outputDirectory}${path.sep}`)
|
|
82
|
-
|| !fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
|
106
|
+
if (!resolved.startsWith(`${outputDirectory}${path.sep}`)) {
|
|
83
107
|
return apiError(reply, 404, "COMPILE_ARTIFACT_NOT_FOUND", "编译产物不存在");
|
|
84
108
|
}
|
|
85
|
-
|
|
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
|
+
}
|
|
86
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
|
+
}
|
|
87
133
|
reply.header("Content-Type", "application/octet-stream");
|
|
88
134
|
reply.header("Content-Disposition", contentDisposition(path.basename(relative), "attachment"));
|
|
89
135
|
reply.header("Content-Length", stat.size);
|
|
90
|
-
|
|
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);
|
|
91
141
|
}
|
|
92
142
|
if (stat.size > 2 * 1024 * 1024 || !isTextCompileArtifact(relative)) {
|
|
93
143
|
return apiError(reply, 415, "ARTIFACT_PREVIEW_UNSUPPORTED", "该产物不能作为文本预览,请下载后查看");
|
|
94
144
|
}
|
|
95
|
-
|
|
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
|
+
}
|
|
96
153
|
});
|
|
97
154
|
app.post("/api/projects/:id/compile/clean", async (request, reply) => {
|
|
98
155
|
const user = requireUser(request, reply, db);
|
|
@@ -109,37 +166,49 @@ export function registerCompileRoutes(app, context) {
|
|
|
109
166
|
if (body.mode !== "cache" && body.mode !== "artifacts") {
|
|
110
167
|
return apiError(reply, 400, "REQUEST_INVALID", "清理模式无效");
|
|
111
168
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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);
|
|
119
195
|
collaboration.signalCompileState(id, {
|
|
120
196
|
mainFile,
|
|
121
197
|
runId: `clean-${randomUUID()}`,
|
|
122
198
|
status: "cleaned",
|
|
123
|
-
cleanMode: "
|
|
199
|
+
cleanMode: "artifacts",
|
|
124
200
|
requestedBy,
|
|
125
201
|
updatedAt: now()
|
|
126
202
|
});
|
|
127
|
-
return { ok: true, mode: "
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
runId: `clean-${randomUUID()}`,
|
|
137
|
-
status: "cleaned",
|
|
138
|
-
cleanMode: "artifacts",
|
|
139
|
-
requestedBy,
|
|
140
|
-
updatedAt: now()
|
|
141
|
-
});
|
|
142
|
-
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
|
+
} });
|
|
143
212
|
});
|
|
144
213
|
app.get("/api/projects/:id/sync/pdf", async (request, reply) => {
|
|
145
214
|
const user = requireUser(request, reply, db);
|
|
@@ -159,13 +228,18 @@ export function registerCompileRoutes(app, context) {
|
|
|
159
228
|
if (!Number.isInteger(line) || line < 1 || !Number.isInteger(column) || column < 1) {
|
|
160
229
|
return apiError(reply, 400, "SYNC_SOURCE_INVALID", "源码位置无效");
|
|
161
230
|
}
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
} });
|
|
169
243
|
});
|
|
170
244
|
app.get("/api/projects/:id/sync/source", async (request, reply) => {
|
|
171
245
|
const user = requireUser(request, reply, db);
|
|
@@ -185,16 +259,21 @@ export function registerCompileRoutes(app, context) {
|
|
|
185
259
|
if (!Number.isInteger(page) || page < 1 || !Number.isFinite(x) || x < 0 || !Number.isFinite(y) || y < 0) {
|
|
186
260
|
return apiError(reply, 400, "SYNC_PDF_INVALID", "PDF 位置无效");
|
|
187
261
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
+
} });
|
|
198
277
|
});
|
|
199
278
|
app.post("/api/projects/:id/compile", async (request, reply) => {
|
|
200
279
|
const requestStartedAt = performance.now();
|
|
@@ -202,116 +281,358 @@ export function registerCompileRoutes(app, context) {
|
|
|
202
281
|
if (!user)
|
|
203
282
|
return;
|
|
204
283
|
const { id } = request.params;
|
|
205
|
-
const
|
|
206
|
-
if (!
|
|
284
|
+
const initialProject = accessibleProject(db, id, user);
|
|
285
|
+
if (!initialProject || !canEdit(initialProject))
|
|
207
286
|
return apiError(reply, 403, "COMPILE_FORBIDDEN", "没有编译权限");
|
|
208
287
|
const body = (request.body ?? {});
|
|
209
|
-
const
|
|
210
|
-
|
|
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)
|
|
211
291
|
return apiError(reply, 400, "MAIN_DOCUMENT_INVALID", "所选文件不是有效的 LaTeX 主文档");
|
|
212
|
-
collaboration.flushProject(id);
|
|
213
292
|
const runId = randomUUID();
|
|
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
|
-
|
|
241
|
-
|
|
242
|
-
diagnostics: parseCompileDiagnostics(publishedRun.log ?? "", "succeeded"),
|
|
243
|
-
mainFile,
|
|
244
|
-
pdfUrl: compilePdfUrl(id, mainFile, published.runId),
|
|
245
|
-
pdfCompiledAt: publishedRun.finished_at,
|
|
246
|
-
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)
|
|
247
321
|
};
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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;
|
|
251
331
|
const requestedBy = { id: user.id, username: user.username, name: user.display_name };
|
|
252
332
|
let phase = "queued";
|
|
253
|
-
|
|
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
|
+
});
|
|
254
353
|
const result = await compileCoordinator.request({
|
|
255
354
|
projectId: id,
|
|
256
355
|
target: mainFile,
|
|
257
356
|
runId,
|
|
258
|
-
|
|
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,
|
|
259
361
|
onQueued: broadcast,
|
|
260
362
|
onSelected: broadcast,
|
|
261
363
|
onDiscarded: () => {
|
|
262
364
|
db.prepare("DELETE FROM compile_runs WHERE id = ? AND status = 'queued'").run(runId);
|
|
263
|
-
discardCompileSnapshot(snapshot);
|
|
264
365
|
},
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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();
|
|
270
374
|
try {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
compiled.timings.publishMs = performance.now() - publishStartedAt;
|
|
279
|
-
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");
|
|
280
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
|
+
};
|
|
281
420
|
}
|
|
282
|
-
|
|
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)) {
|
|
283
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
|
+
};
|
|
284
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
|
+
};
|
|
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 };
|
|
285
572
|
}
|
|
286
573
|
catch (error) {
|
|
287
|
-
|
|
288
|
-
|
|
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 {
|
|
289
601
|
ok: false,
|
|
290
|
-
log
|
|
291
|
-
diagnostics: parseCompileDiagnostics(
|
|
602
|
+
log,
|
|
603
|
+
diagnostics: parseCompileDiagnostics(log, "failed"),
|
|
292
604
|
pdfPath: null,
|
|
293
|
-
synctexPath: null
|
|
605
|
+
synctexPath: null,
|
|
606
|
+
runId,
|
|
607
|
+
revision: admission.generation,
|
|
608
|
+
snapshotMs
|
|
294
609
|
};
|
|
295
610
|
}
|
|
296
|
-
|
|
297
|
-
db.prepare("UPDATE compile_runs SET status = ?, log = ?, finished_at = ? WHERE id = ?")
|
|
298
|
-
.run(phase, compiled.log, now(), runId);
|
|
299
|
-
broadcast();
|
|
300
|
-
return { ...compiled, runId, revision: snapshot.revision };
|
|
301
|
-
}
|
|
611
|
+
}),
|
|
302
612
|
});
|
|
303
613
|
const completed = db.prepare("SELECT finished_at FROM compile_runs WHERE id = ?").get(result.runId);
|
|
304
614
|
const requestMs = performance.now() - requestStartedAt;
|
|
305
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
|
+
}
|
|
306
625
|
if (result.timings) {
|
|
307
626
|
metrics.record("compile.cacheSync", result.timings.cacheSyncMs);
|
|
308
627
|
metrics.record("compile.latexmk", result.timings.latexmkMs);
|
|
309
628
|
metrics.record("compile.artifactCopy", result.timings.artifactCopyMs);
|
|
310
629
|
}
|
|
311
|
-
pruneCompileRuns(id);
|
|
312
|
-
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 };
|
|
313
634
|
reply.header("Server-Timing", [
|
|
314
|
-
`snapshot;dur=${timingDuration(snapshotMs)}`,
|
|
635
|
+
`snapshot;dur=${timingDuration(result.snapshotMs ?? 0)}`,
|
|
315
636
|
result.timings ? `cache;dur=${timingDuration(result.timings.cacheSyncMs)}` : "",
|
|
316
637
|
result.timings ? `latexmk;dur=${timingDuration(result.timings.latexmkMs)}` : "",
|
|
317
638
|
result.timings ? `artifacts;dur=${timingDuration(result.timings.artifactCopyMs)}` : "",
|
|
@@ -319,12 +640,17 @@ export function registerCompileRoutes(app, context) {
|
|
|
319
640
|
`total;dur=${timingDuration(requestMs)}`
|
|
320
641
|
].filter(Boolean).join(", "));
|
|
321
642
|
return {
|
|
322
|
-
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,
|
|
323
644
|
pdfUrl: result.ok ? compilePdfUrl(id, mainFile, result.runId) : null,
|
|
324
645
|
pdfCompiledAt: result.ok ? completed?.finished_at ?? null : null,
|
|
325
646
|
timings
|
|
326
647
|
};
|
|
327
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.
|
|
328
654
|
app.get("/api/projects/:id/pdf", async (request, reply) => {
|
|
329
655
|
const user = requireUser(request, reply, db);
|
|
330
656
|
if (!user)
|
|
@@ -359,10 +685,12 @@ export function registerCompileRoutes(app, context) {
|
|
|
359
685
|
}
|
|
360
686
|
}
|
|
361
687
|
const pdf = artifact.path;
|
|
362
|
-
const stat =
|
|
688
|
+
const stat = regularFileStat(pdf);
|
|
689
|
+
if (!stat)
|
|
690
|
+
return apiError(reply, 404, "PDF_NOT_FOUND", "尚未生成 PDF");
|
|
363
691
|
const etag = `"${stat.size.toString(16)}-${Math.trunc(stat.mtimeMs).toString(16)}"`;
|
|
364
692
|
reply.header("Content-Type", "application/pdf");
|
|
365
|
-
const targetSuffix = mainFile === project.main_file ? "" : `-${
|
|
693
|
+
const targetSuffix = mainFile === project.main_file ? "" : `-${texFileStem(mainFile)}`;
|
|
366
694
|
const filename = downloading ? `${project.name}${targetSuffix}-${pdfDownloadTimestamp()}.pdf` : `${project.name}${targetSuffix}.pdf`;
|
|
367
695
|
reply.header("Content-Disposition", contentDisposition(filename, downloading ? "attachment" : "inline"));
|
|
368
696
|
// The run query identifies an immutable successful compile. Keep it in the
|
|
@@ -387,10 +715,59 @@ export function registerCompileRoutes(app, context) {
|
|
|
387
715
|
if (range) {
|
|
388
716
|
reply.header("Content-Range", `bytes ${range.start}-${range.end}/${stat.size}`);
|
|
389
717
|
reply.header("Content-Length", range.end - range.start + 1);
|
|
390
|
-
|
|
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);
|
|
391
728
|
}
|
|
392
729
|
reply.header("Content-Length", stat.size);
|
|
393
|
-
|
|
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
|
|
394
771
|
});
|
|
395
772
|
}
|
|
396
773
|
function pdfDownloadTimestamp(date = new Date()) {
|