texlite 0.7.5 → 0.7.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -3
- package/README.zh-CN.md +8 -3
- package/dist/client/assets/{GitDialog-pn8n5Afm.js → GitDialog-Bm5yMpQK.js} +2 -2
- package/dist/client/assets/HistoryDialog-Po4KHT_2.js +5 -0
- package/dist/client/assets/LatexEditor-CnKCXaHr.js +59 -0
- package/dist/client/assets/PdfPreview-Cd20VIjT.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-CaovMu4V.js → ProjectNavigationDialogs-BDOtIoTy.js} +1 -1
- package/dist/client/assets/SystemMetricsDialog-DdOlcqqK.js +1 -0
- package/dist/client/assets/index-MDwr9W9R.css +1 -0
- package/dist/client/assets/index-zIjCeZFK.js +64 -0
- package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
- package/dist/client/assets/minus-O6pK-6mA.js +1 -0
- package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
- package/dist/client/assets/spellCheck-Ce3o_xO-.js +4 -0
- package/dist/client/index.html +3 -3
- package/dist/server/anchors.js +22 -1
- package/dist/server/app.js +103 -2335
- package/dist/server/auth.js +3 -2
- package/dist/server/cli.js +8 -2
- package/dist/server/collaboration.js +283 -11
- package/dist/server/compileArtifacts.js +10 -7
- package/dist/server/compiler.js +9 -4
- package/dist/server/config.js +25 -1
- package/dist/server/environment.js +5 -1
- package/dist/server/files.js +7 -6
- package/dist/server/git.js +28 -39
- package/dist/server/harper.js +144 -0
- package/dist/server/harperRuntime.js +38 -0
- package/dist/server/harperWorker.js +15 -0
- package/dist/server/history.js +11 -9
- package/dist/server/http.js +33 -13
- package/dist/server/i18n.js +276 -0
- package/dist/server/latexRoot.js +78 -0
- package/dist/server/limits.js +9 -0
- package/dist/server/projectSearch.js +3 -4
- package/dist/server/routes/auth.js +77 -0
- package/dist/server/routes/citations.js +331 -0
- package/dist/server/routes/collaboration.js +17 -0
- package/dist/server/routes/comments.js +176 -0
- package/dist/server/routes/compile.js +71 -50
- package/dist/server/routes/projectFiles.js +572 -0
- package/dist/server/routes/projectGit.js +145 -0
- package/dist/server/routes/projectHistory.js +150 -0
- package/dist/server/routes/projectMembers.js +112 -0
- package/dist/server/routes/projectShared.js +238 -0
- package/dist/server/routes/projects.js +473 -0
- package/dist/server/routes/system.js +37 -0
- package/dist/server/routes/users.js +163 -0
- package/dist/server/security.js +2 -4
- package/dist/server/synctex.js +7 -4
- package/dist/server/zip.js +29 -13
- package/package.json +3 -3
- package/texlite.config.example.json +5 -1
- package/dist/client/assets/BinaryModule-Aj1vLnwf-CKY6Y_Pt.js +0 -4
- package/dist/client/assets/HistoryDialog-BpWEZrYf.js +0 -1
- package/dist/client/assets/LatexEditor-DNAmNvHu.js +0 -59
- package/dist/client/assets/PdfPreview-BVaG7TtG.js +0 -1
- package/dist/client/assets/SystemMetricsDialog-D8jj7epG.js +0 -1
- package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
- package/dist/client/assets/binary-BoldOm0z.js +0 -1
- package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
- package/dist/client/assets/index-BQCB3z5H.js +0 -64
- package/dist/client/assets/index-BnvpaWsa.css +0 -1
- package/dist/client/assets/index-Byn2Miy9.js +0 -3456
- package/dist/client/assets/index-rBWMBTI-.js +0 -5
- package/dist/client/assets/minus-K-5IK4nL.js +0 -1
- package/dist/client/assets/spellCheck-C32VQVUE.js +0 -5
- package/dist/client/assets/texFmtRuntime-EYyQNUnJ.js +0 -2
package/dist/server/files.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { httpError } from "./http.js";
|
|
4
5
|
export function projectRoot(config, projectId) {
|
|
5
6
|
return path.join(config.projectsDir, projectId);
|
|
6
7
|
}
|
|
@@ -49,14 +50,14 @@ export async function duplicateProjectFiles(config, sourceProjectId, targetProje
|
|
|
49
50
|
}
|
|
50
51
|
export function safeRelativePath(input) {
|
|
51
52
|
if (!input || input.includes("\0") || path.isAbsolute(input)) {
|
|
52
|
-
throw
|
|
53
|
+
throw httpError(400, "INVALID_PATH");
|
|
53
54
|
}
|
|
54
55
|
const normalized = path.posix.normalize(input.replaceAll("\\", "/"));
|
|
55
56
|
if (normalized === "." || normalized === ".." || normalized.startsWith("../")) {
|
|
56
|
-
throw
|
|
57
|
+
throw httpError(400, "INVALID_PATH");
|
|
57
58
|
}
|
|
58
59
|
if (normalized.split("/").some((segment) => segment.toLocaleLowerCase() === ".git")) {
|
|
59
|
-
throw
|
|
60
|
+
throw httpError(400, "RESERVED_PATH");
|
|
60
61
|
}
|
|
61
62
|
return normalized;
|
|
62
63
|
}
|
|
@@ -64,12 +65,12 @@ export function safeRelativePath(input) {
|
|
|
64
65
|
export function safePathSegment(input) {
|
|
65
66
|
const name = input.trim();
|
|
66
67
|
if (!name || name.includes("\0") || name.includes("/") || name.includes("\\")) {
|
|
67
|
-
throw
|
|
68
|
+
throw httpError(400, "INVALID_PATH");
|
|
68
69
|
}
|
|
69
70
|
return safeRelativePath(name);
|
|
70
71
|
}
|
|
71
72
|
export function symbolicLinkError(relativePath) {
|
|
72
|
-
return
|
|
73
|
+
return httpError(409, "SYMLINK_FORBIDDEN", { path: relativePath || "source" });
|
|
73
74
|
}
|
|
74
75
|
/**
|
|
75
76
|
* Check every existing component below the project directory without
|
|
@@ -256,7 +257,7 @@ export function removeProjectDirectory(config, projectId) {
|
|
|
256
257
|
catch {
|
|
257
258
|
fs.rmSync(root, { recursive: true, force: true });
|
|
258
259
|
if (fs.existsSync(root)) {
|
|
259
|
-
throw new Error(
|
|
260
|
+
throw new Error(`Unable to remove project directory: ${root}`);
|
|
260
261
|
}
|
|
261
262
|
}
|
|
262
263
|
}
|
package/dist/server/git.js
CHANGED
|
@@ -4,6 +4,7 @@ import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { assertGitAvailable } from "./environment.js";
|
|
6
6
|
import { assertNoSourceSymlinks, sourceRoot, symbolicLinkError } from "./files.js";
|
|
7
|
+
import { httpError } from "./http.js";
|
|
7
8
|
import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
|
|
8
9
|
const MAX_COMMAND_OUTPUT = 8 * 1024 * 1024;
|
|
9
10
|
const MAX_DIFF_OUTPUT = 1024 * 1024;
|
|
@@ -75,11 +76,11 @@ export class ProjectGitService {
|
|
|
75
76
|
async configureToken(project, tokenInput) {
|
|
76
77
|
const token = tokenInput.trim();
|
|
77
78
|
if (token.length < 20 || token.length > 500)
|
|
78
|
-
throw httpError(400, "
|
|
79
|
+
throw httpError(400, "GIT_TOKEN_INVALID");
|
|
79
80
|
await this.ensureGitAvailable();
|
|
80
81
|
const account = await this.githubRequest(token, "/user", { method: "GET" });
|
|
81
82
|
if (typeof account.login !== "string" || !account.login)
|
|
82
|
-
throw httpError(502, "
|
|
83
|
+
throw httpError(502, "GIT_ACCOUNT_INVALID");
|
|
83
84
|
await this.ensureRepository(project);
|
|
84
85
|
const timestamp = new Date().toISOString();
|
|
85
86
|
this.db.prepare(`INSERT INTO project_git_settings
|
|
@@ -97,18 +98,18 @@ export class ProjectGitService {
|
|
|
97
98
|
}
|
|
98
99
|
async createGitHubRepository(project, name, isPrivate) {
|
|
99
100
|
if (!/^[A-Za-z0-9._-]{1,100}$/.test(name))
|
|
100
|
-
throw httpError(400, "
|
|
101
|
+
throw httpError(400, "GIT_REPOSITORY_NAME_INVALID");
|
|
101
102
|
await this.ensureGitAvailable();
|
|
102
103
|
const settings = this.requireTokenSettings(project.id);
|
|
103
104
|
if (settings.remote_url)
|
|
104
|
-
throw httpError(409, "
|
|
105
|
+
throw httpError(409, "GIT_REMOTE_ALREADY_CONFIGURED");
|
|
105
106
|
const token = decryptToken(this.config, settings.token_ciphertext);
|
|
106
107
|
const repository = await this.githubRequest(token, "/user/repos", {
|
|
107
108
|
method: "POST",
|
|
108
109
|
body: JSON.stringify({ name, private: isPrivate, auto_init: false, description: `Backup of ${project.name}` })
|
|
109
110
|
});
|
|
110
111
|
if (typeof repository.clone_url !== "string" || typeof repository.html_url !== "string") {
|
|
111
|
-
throw httpError(502, "
|
|
112
|
+
throw httpError(502, "GIT_REPOSITORY_RESPONSE_INVALID");
|
|
112
113
|
}
|
|
113
114
|
await this.ensureRepository(project);
|
|
114
115
|
const root = sourceRoot(this.config, project.id);
|
|
@@ -127,16 +128,16 @@ export class ProjectGitService {
|
|
|
127
128
|
async commit(project, owner, messageInput) {
|
|
128
129
|
const message = messageInput.trim();
|
|
129
130
|
if (!message || message.length > 300)
|
|
130
|
-
throw httpError(400, "
|
|
131
|
+
throw httpError(400, "GIT_COMMIT_MESSAGE_INVALID");
|
|
131
132
|
const root = this.requireRepository(project.id);
|
|
132
133
|
assertNoSourceSymlinks(this.config, project.id);
|
|
133
134
|
const branch = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
|
|
134
135
|
if (branch.code !== 0)
|
|
135
|
-
throw httpError(409, "
|
|
136
|
+
throw httpError(409, "GIT_DETACHED_HEAD");
|
|
136
137
|
await this.git(root, ["add", "--all"]);
|
|
137
138
|
const staged = await this.git(root, ["diff", "--cached", "--quiet"], [1]);
|
|
138
139
|
if (staged.code === 0)
|
|
139
|
-
throw httpError(409, "
|
|
140
|
+
throw httpError(409, "GIT_NO_CHANGES");
|
|
140
141
|
await this.git(root, [
|
|
141
142
|
"-c", `user.name=${owner.username}`,
|
|
142
143
|
"-c", `user.email=${owner.username}@texlite.com`,
|
|
@@ -150,18 +151,18 @@ export class ProjectGitService {
|
|
|
150
151
|
async push(project) {
|
|
151
152
|
const settings = this.requireTokenSettings(project.id);
|
|
152
153
|
if (!settings.remote_url)
|
|
153
|
-
throw httpError(409, "
|
|
154
|
+
throw httpError(409, "GIT_REMOTE_NOT_CONFIGURED");
|
|
154
155
|
const root = this.requireRepository(project.id);
|
|
155
156
|
assertNoSourceSymlinks(this.config, project.id);
|
|
156
157
|
const head = await this.git(root, ["rev-parse", "--verify", "HEAD"], [128]);
|
|
157
158
|
if (head.code !== 0)
|
|
158
|
-
throw httpError(409, "
|
|
159
|
+
throw httpError(409, "GIT_NO_COMMIT");
|
|
159
160
|
const branchResult = await this.git(root, ["symbolic-ref", "--quiet", "--short", "HEAD"], [1]);
|
|
160
161
|
if (branchResult.code !== 0)
|
|
161
|
-
throw httpError(409, "
|
|
162
|
+
throw httpError(409, "GIT_DETACHED_HEAD");
|
|
162
163
|
const branch = branchResult.stdout.trim();
|
|
163
164
|
if (!/^[A-Za-z0-9._/-]+$/.test(branch))
|
|
164
|
-
throw httpError(400, "
|
|
165
|
+
throw httpError(400, "GIT_BRANCH_INVALID");
|
|
165
166
|
const token = decryptToken(this.config, settings.token_ciphertext);
|
|
166
167
|
await this.git(root, ["push", "--set-upstream", "origin", `HEAD:refs/heads/${branch}`], [], this.authEnvironment(token));
|
|
167
168
|
return this.status(project);
|
|
@@ -221,7 +222,7 @@ export class ProjectGitService {
|
|
|
221
222
|
const root = this.requireRepository(project.id);
|
|
222
223
|
const head = await this.git(root, ["rev-parse", "--verify", "HEAD"], [128]);
|
|
223
224
|
if (head.code !== 0)
|
|
224
|
-
throw httpError(409, "
|
|
225
|
+
throw httpError(409, "GIT_NO_COMMIT");
|
|
225
226
|
await this.assertRevisionHasNoSymlinks(root, head.stdout.trim());
|
|
226
227
|
await this.git(root, ["restore", "--source=HEAD", "--staged", "--worktree", "--", "."]);
|
|
227
228
|
assertNoSourceSymlinks(this.config, project.id);
|
|
@@ -260,7 +261,7 @@ export class ProjectGitService {
|
|
|
260
261
|
throw error;
|
|
261
262
|
}
|
|
262
263
|
if (!gitStat)
|
|
263
|
-
throw httpError(409, "
|
|
264
|
+
throw httpError(409, "GIT_REPOSITORY_NOT_INITIALIZED");
|
|
264
265
|
if (gitStat.isSymbolicLink() || !gitStat.isDirectory())
|
|
265
266
|
throw symbolicLinkError(".git");
|
|
266
267
|
return root;
|
|
@@ -271,13 +272,13 @@ export class ProjectGitService {
|
|
|
271
272
|
requireTokenSettings(projectId) {
|
|
272
273
|
const settings = this.settings(projectId);
|
|
273
274
|
if (!settings?.token_ciphertext)
|
|
274
|
-
throw httpError(409, "
|
|
275
|
+
throw httpError(409, "GIT_TOKEN_NOT_CONFIGURED");
|
|
275
276
|
return settings;
|
|
276
277
|
}
|
|
277
278
|
async verifyCommit(root, revision) {
|
|
278
279
|
const result = await this.git(root, ["rev-parse", "--verify", `${revision}^{commit}`], [128]);
|
|
279
280
|
if (result.code !== 0)
|
|
280
|
-
throw httpError(404, "
|
|
281
|
+
throw httpError(404, "GIT_REVISION_NOT_FOUND");
|
|
281
282
|
return result.stdout.trim();
|
|
282
283
|
}
|
|
283
284
|
async assertRevisionHasNoSymlinks(root, revision) {
|
|
@@ -301,16 +302,14 @@ export class ProjectGitService {
|
|
|
301
302
|
...(init.body ? { "Content-Type": "application/json" } : {})
|
|
302
303
|
}
|
|
303
304
|
});
|
|
304
|
-
const body = await response.json().catch(() => ({}));
|
|
305
305
|
if (!response.ok) {
|
|
306
|
-
|
|
307
|
-
throw httpError(response.status === 401 || response.status === 403 ? 400 : 502, `GitHub 请求失败:${message}`);
|
|
306
|
+
throw httpError(response.status === 401 || response.status === 403 ? 400 : 502, response.status === 401 || response.status === 403 ? "GIT_TOKEN_REJECTED" : "GIT_OPERATION_FAILED");
|
|
308
307
|
}
|
|
309
|
-
return
|
|
308
|
+
return await response.json();
|
|
310
309
|
}
|
|
311
310
|
catch (error) {
|
|
312
311
|
if (error instanceof Error && error.name === "AbortError")
|
|
313
|
-
throw httpError(504, "
|
|
312
|
+
throw httpError(504, "GIT_TIMEOUT");
|
|
314
313
|
throw error;
|
|
315
314
|
}
|
|
316
315
|
finally {
|
|
@@ -368,17 +367,16 @@ export class ProjectGitService {
|
|
|
368
367
|
};
|
|
369
368
|
child.stdout.on("data", collect(stdout));
|
|
370
369
|
child.stderr.on("data", collect(stderr));
|
|
371
|
-
child.once("error", (
|
|
370
|
+
child.once("error", () => { clearTimeout(timer); reject(httpError(500, "GIT_OPERATION_FAILED")); });
|
|
372
371
|
child.once("close", (code) => {
|
|
373
372
|
clearTimeout(timer);
|
|
374
373
|
const result = { stdout: Buffer.concat(stdout).toString("utf8"), stderr: Buffer.concat(stderr).toString("utf8"), code: code ?? -1 };
|
|
375
374
|
if (timedOut)
|
|
376
|
-
return reject(httpError(504, "
|
|
375
|
+
return reject(httpError(504, "GIT_TIMEOUT"));
|
|
377
376
|
if (size > MAX_COMMAND_OUTPUT)
|
|
378
|
-
return reject(httpError(413, "
|
|
377
|
+
return reject(httpError(413, "GIT_OUTPUT_TOO_LARGE"));
|
|
379
378
|
if (result.code !== 0 && !allowedCodes.includes(result.code)) {
|
|
380
|
-
|
|
381
|
-
return reject(httpError(400, `Git 操作失败:${detail}`));
|
|
379
|
+
return reject(httpError(400, "GIT_COMMAND_FAILED"));
|
|
382
380
|
}
|
|
383
381
|
resolve(result);
|
|
384
382
|
});
|
|
@@ -392,7 +390,7 @@ function parseCommit(line) {
|
|
|
392
390
|
function validateRevision(value) {
|
|
393
391
|
const revision = value.trim();
|
|
394
392
|
if (!/^[a-f0-9]{7,40}$/i.test(revision))
|
|
395
|
-
throw httpError(400, "
|
|
393
|
+
throw httpError(400, "GIT_REVISION_INVALID");
|
|
396
394
|
return revision;
|
|
397
395
|
}
|
|
398
396
|
function tokenKey(config) {
|
|
@@ -409,7 +407,7 @@ function tokenKey(config) {
|
|
|
409
407
|
}
|
|
410
408
|
const key = fs.readFileSync(target);
|
|
411
409
|
if (key.length !== 32)
|
|
412
|
-
throw httpError(500, "
|
|
410
|
+
throw httpError(500, "GIT_OPERATION_FAILED");
|
|
413
411
|
return key;
|
|
414
412
|
}
|
|
415
413
|
function encryptToken(config, token) {
|
|
@@ -421,22 +419,13 @@ function encryptToken(config, token) {
|
|
|
421
419
|
function decryptToken(config, encoded) {
|
|
422
420
|
const [version, ivText, tagText, encryptedText] = encoded.split(".");
|
|
423
421
|
if (version !== "v1" || !ivText || !tagText || !encryptedText)
|
|
424
|
-
throw httpError(500, "
|
|
422
|
+
throw httpError(500, "GIT_OPERATION_FAILED");
|
|
425
423
|
try {
|
|
426
424
|
const decipher = createDecipheriv("aes-256-gcm", tokenKey(config), Buffer.from(ivText, "base64url"));
|
|
427
425
|
decipher.setAuthTag(Buffer.from(tagText, "base64url"));
|
|
428
426
|
return Buffer.concat([decipher.update(Buffer.from(encryptedText, "base64url")), decipher.final()]).toString("utf8");
|
|
429
427
|
}
|
|
430
428
|
catch {
|
|
431
|
-
throw httpError(500, "
|
|
429
|
+
throw httpError(500, "GIT_OPERATION_FAILED");
|
|
432
430
|
}
|
|
433
431
|
}
|
|
434
|
-
function httpError(statusCode, message) {
|
|
435
|
-
const code = statusCode === 400 ? "GIT_INVALID"
|
|
436
|
-
: statusCode === 404 ? "GIT_REVISION_NOT_FOUND"
|
|
437
|
-
: statusCode === 409 ? "GIT_CONFLICT"
|
|
438
|
-
: statusCode === 413 ? "GIT_OUTPUT_TOO_LARGE"
|
|
439
|
-
: statusCode === 504 ? "GIT_TIMEOUT"
|
|
440
|
-
: statusCode >= 500 ? "GIT_OPERATION_FAILED" : "REQUEST_INVALID";
|
|
441
|
-
return Object.assign(new Error(message), { statusCode, code });
|
|
442
|
-
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { Worker } from "node:worker_threads";
|
|
2
|
+
const setupTimeoutMs = 45_000;
|
|
3
|
+
const lintTimeoutMs = 30_000;
|
|
4
|
+
function withTimeout(operation, timeoutMs, message) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
const timer = setTimeout(() => reject(new Error(message)), timeoutMs);
|
|
7
|
+
operation.then((value) => { clearTimeout(timer); resolve(value); }, (error) => { clearTimeout(timer); reject(error); });
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* One Harper runtime per TexLite server process. Production uses one Node
|
|
12
|
+
* Worker so WASM checks cannot block Fastify or collaboration messages. Source
|
|
13
|
+
* mode (tsx/Vitest) uses the same runtime in-process because the emitted worker
|
|
14
|
+
* entry does not exist until the server build has completed.
|
|
15
|
+
*/
|
|
16
|
+
export class HarperService {
|
|
17
|
+
useWorker = import.meta.url.endsWith(".js");
|
|
18
|
+
linter = null;
|
|
19
|
+
setupPromise = null;
|
|
20
|
+
worker = null;
|
|
21
|
+
nextRequestId = 1;
|
|
22
|
+
pending = new Map();
|
|
23
|
+
queue = Promise.resolve();
|
|
24
|
+
async preload() {
|
|
25
|
+
await this.ensureRuntime();
|
|
26
|
+
}
|
|
27
|
+
async lint(source) {
|
|
28
|
+
const operation = this.queue.then(async () => {
|
|
29
|
+
await this.ensureRuntime();
|
|
30
|
+
try {
|
|
31
|
+
return await withTimeout(this.useWorker ? this.lintInWorker(source) : this.lintInProcess(source), lintTimeoutMs, "Harper writing check timed out.");
|
|
32
|
+
}
|
|
33
|
+
catch (error) {
|
|
34
|
+
if (error instanceof Error && error.message === "Harper writing check timed out.")
|
|
35
|
+
this.stopFailedWorker(error);
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
this.queue = operation.then(() => undefined, () => undefined);
|
|
40
|
+
return operation;
|
|
41
|
+
}
|
|
42
|
+
async dispose() {
|
|
43
|
+
await this.queue.catch(() => undefined);
|
|
44
|
+
await this.setupPromise?.catch(() => undefined);
|
|
45
|
+
const worker = this.worker;
|
|
46
|
+
this.worker = null;
|
|
47
|
+
if (worker)
|
|
48
|
+
await worker.terminate().catch(() => undefined);
|
|
49
|
+
const linter = this.linter;
|
|
50
|
+
this.linter = null;
|
|
51
|
+
if (linter)
|
|
52
|
+
await linter.dispose().catch(() => undefined);
|
|
53
|
+
this.setupPromise = null;
|
|
54
|
+
this.rejectPending(new Error("Harper service stopped."));
|
|
55
|
+
}
|
|
56
|
+
ensureRuntime() {
|
|
57
|
+
if (this.setupPromise)
|
|
58
|
+
return this.setupPromise;
|
|
59
|
+
if (this.worker || this.linter)
|
|
60
|
+
return Promise.resolve();
|
|
61
|
+
this.setupPromise = withTimeout(this.useWorker ? this.startWorker() : this.startInProcess(), setupTimeoutMs, "Harper initialization timed out.")
|
|
62
|
+
.catch((error) => {
|
|
63
|
+
this.stopFailedWorker(error instanceof Error ? error : new Error(String(error)));
|
|
64
|
+
throw error;
|
|
65
|
+
})
|
|
66
|
+
.finally(() => { this.setupPromise = null; });
|
|
67
|
+
return this.setupPromise;
|
|
68
|
+
}
|
|
69
|
+
async startInProcess() {
|
|
70
|
+
const { createHarperLinter } = await import("./harperRuntime.js");
|
|
71
|
+
this.linter = await createHarperLinter();
|
|
72
|
+
}
|
|
73
|
+
startWorker() {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const worker = new Worker(new URL("./harperWorker.js", import.meta.url), { name: "texlite-harper" });
|
|
76
|
+
this.worker = worker;
|
|
77
|
+
const rejectStart = (error) => reject(error);
|
|
78
|
+
const onInitialMessage = (message) => {
|
|
79
|
+
if (!("type" in message))
|
|
80
|
+
return;
|
|
81
|
+
worker.off("message", onInitialMessage);
|
|
82
|
+
worker.off("error", rejectStart);
|
|
83
|
+
if (message.type === "ready")
|
|
84
|
+
resolve();
|
|
85
|
+
else
|
|
86
|
+
reject(new Error(message.message));
|
|
87
|
+
};
|
|
88
|
+
worker.on("message", onInitialMessage);
|
|
89
|
+
worker.once("error", rejectStart);
|
|
90
|
+
worker.on("message", (message) => this.handleWorkerMessage(message));
|
|
91
|
+
worker.on("error", (error) => this.stopFailedWorker(error));
|
|
92
|
+
worker.on("exit", (code) => {
|
|
93
|
+
if (this.worker !== worker)
|
|
94
|
+
return;
|
|
95
|
+
this.worker = null;
|
|
96
|
+
this.rejectPending(new Error(`Harper worker exited unexpectedly with code ${code}.`));
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
lintInWorker(source) {
|
|
101
|
+
const worker = this.worker;
|
|
102
|
+
if (!worker)
|
|
103
|
+
return Promise.reject(new Error("Harper worker is not ready."));
|
|
104
|
+
const id = this.nextRequestId++;
|
|
105
|
+
return new Promise((resolve, reject) => {
|
|
106
|
+
this.pending.set(id, { resolve, reject });
|
|
107
|
+
worker.postMessage({ id, source });
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
async lintInProcess(source) {
|
|
111
|
+
if (!this.linter)
|
|
112
|
+
throw new Error("Harper is not ready.");
|
|
113
|
+
const { collectHarperLints } = await import("./harperRuntime.js");
|
|
114
|
+
return collectHarperLints(this.linter, source);
|
|
115
|
+
}
|
|
116
|
+
handleWorkerMessage(message) {
|
|
117
|
+
if ("type" in message)
|
|
118
|
+
return;
|
|
119
|
+
const pending = this.pending.get(message.id);
|
|
120
|
+
if (!pending)
|
|
121
|
+
return;
|
|
122
|
+
this.pending.delete(message.id);
|
|
123
|
+
if (message.ok)
|
|
124
|
+
pending.resolve(message.lints);
|
|
125
|
+
else
|
|
126
|
+
pending.reject(new Error(message.message));
|
|
127
|
+
}
|
|
128
|
+
stopFailedWorker(error) {
|
|
129
|
+
const worker = this.worker;
|
|
130
|
+
this.worker = null;
|
|
131
|
+
if (worker)
|
|
132
|
+
void worker.terminate().catch(() => undefined);
|
|
133
|
+
const linter = this.linter;
|
|
134
|
+
this.linter = null;
|
|
135
|
+
if (linter)
|
|
136
|
+
void linter.dispose().catch(() => undefined);
|
|
137
|
+
this.rejectPending(error);
|
|
138
|
+
}
|
|
139
|
+
rejectPending(error) {
|
|
140
|
+
for (const pending of this.pending.values())
|
|
141
|
+
pending.reject(error);
|
|
142
|
+
this.pending.clear();
|
|
143
|
+
}
|
|
144
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { LocalLinter } from "harper.js";
|
|
2
|
+
import { binary } from "harper.js/binary";
|
|
3
|
+
function suggestionText(suggestion) {
|
|
4
|
+
return suggestion.get_replacement_text();
|
|
5
|
+
}
|
|
6
|
+
export async function createHarperLinter() {
|
|
7
|
+
const linter = new LocalLinter({ binary });
|
|
8
|
+
await linter.setup();
|
|
9
|
+
return linter;
|
|
10
|
+
}
|
|
11
|
+
export async function collectHarperLints(linter, source) {
|
|
12
|
+
const lints = await linter.lint(source, { language: "plaintext", dedup: true, isolateEnglish: false });
|
|
13
|
+
try {
|
|
14
|
+
return lints.map((lint) => {
|
|
15
|
+
const span = lint.span();
|
|
16
|
+
const suggestions = lint.suggestions();
|
|
17
|
+
try {
|
|
18
|
+
return {
|
|
19
|
+
start: span.start,
|
|
20
|
+
end: span.end,
|
|
21
|
+
problem: lint.get_problem_text(),
|
|
22
|
+
kind: lint.lint_kind(),
|
|
23
|
+
message: lint.message(),
|
|
24
|
+
suggestions: suggestions.map(suggestionText)
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
span.free();
|
|
29
|
+
for (const suggestion of suggestions)
|
|
30
|
+
suggestion.free();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
for (const lint of lints)
|
|
36
|
+
lint.free();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { parentPort } from "node:worker_threads";
|
|
2
|
+
import { collectHarperLints, createHarperLinter } from "./harperRuntime.js";
|
|
3
|
+
if (!parentPort)
|
|
4
|
+
throw new Error("Harper worker requires a parent port.");
|
|
5
|
+
const port = parentPort;
|
|
6
|
+
try {
|
|
7
|
+
const linter = await createHarperLinter();
|
|
8
|
+
port.on("message", (request) => {
|
|
9
|
+
void collectHarperLints(linter, request.source).then((lints) => port.postMessage({ id: request.id, ok: true, lints }), (error) => port.postMessage({ id: request.id, ok: false, message: error instanceof Error ? error.message : String(error) }));
|
|
10
|
+
});
|
|
11
|
+
port.postMessage({ type: "ready" });
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
port.postMessage({ type: "load-error", message: error instanceof Error ? error.message : String(error) });
|
|
15
|
+
}
|
package/dist/server/history.js
CHANGED
|
@@ -2,6 +2,8 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { createHash, randomUUID } from "node:crypto";
|
|
4
4
|
import { assertNoSourceSymlinks, listProjectFiles, outputRoot, projectRoot, resolveSourcePath, safeRelativePath, sourceRoot } from "./files.js";
|
|
5
|
+
import { httpError } from "./http.js";
|
|
6
|
+
import { MAX_TEXT_PREVIEW_BYTES } from "./limits.js";
|
|
5
7
|
const AUTOSAVE_COALESCE_MS = 2 * 60_000;
|
|
6
8
|
export class ProjectHistoryService {
|
|
7
9
|
config;
|
|
@@ -141,11 +143,11 @@ export class ProjectHistoryService {
|
|
|
141
143
|
return;
|
|
142
144
|
const manifest = this.manifest(projectId, versionId);
|
|
143
145
|
if (!manifest) {
|
|
144
|
-
throw
|
|
146
|
+
throw httpError(404, "HISTORY_VERSION_NOT_FOUND");
|
|
145
147
|
}
|
|
146
148
|
const filePath = safeRelativePath(filePathInput);
|
|
147
149
|
if (!manifest.files[filePath]) {
|
|
148
|
-
throw
|
|
150
|
+
throw httpError(404, "HISTORY_FILE_NOT_FOUND");
|
|
149
151
|
}
|
|
150
152
|
this.assertRestoreTargetIsFile(projectId, filePath);
|
|
151
153
|
}
|
|
@@ -157,19 +159,19 @@ export class ProjectHistoryService {
|
|
|
157
159
|
readTextFile(projectId, versionId, filePathInput) {
|
|
158
160
|
const filePath = safeRelativePath(filePathInput);
|
|
159
161
|
const entry = this.manifest(projectId, versionId)?.files[filePath];
|
|
160
|
-
if (!entry || entry.size >
|
|
162
|
+
if (!entry || entry.size > MAX_TEXT_PREVIEW_BYTES)
|
|
161
163
|
return null;
|
|
162
164
|
return fs.readFileSync(this.objectPath(projectId, entry.digest), "utf8");
|
|
163
165
|
}
|
|
164
166
|
restore(projectId, versionId, filePathInput) {
|
|
165
167
|
const manifest = this.manifest(projectId, versionId);
|
|
166
168
|
if (!manifest)
|
|
167
|
-
throw
|
|
169
|
+
throw httpError(404, "HISTORY_VERSION_NOT_FOUND");
|
|
168
170
|
if (filePathInput) {
|
|
169
171
|
const filePath = safeRelativePath(filePathInput);
|
|
170
172
|
const entry = manifest.files[filePath];
|
|
171
173
|
if (!entry)
|
|
172
|
-
throw
|
|
174
|
+
throw httpError(404, "HISTORY_FILE_NOT_FOUND");
|
|
173
175
|
this.assertRestoreTargetIsFile(projectId, filePath);
|
|
174
176
|
const target = resolveSourcePath(this.config, projectId, filePath);
|
|
175
177
|
fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
|
|
@@ -193,7 +195,7 @@ export class ProjectHistoryService {
|
|
|
193
195
|
const target = resolveSourcePath(this.config, projectId, filePath);
|
|
194
196
|
const targetStat = fs.existsSync(target) ? fs.lstatSync(target) : null;
|
|
195
197
|
if (targetStat && !targetStat.isFile()) {
|
|
196
|
-
throw
|
|
198
|
+
throw httpError(409, "HISTORY_TARGET_CONFLICT");
|
|
197
199
|
}
|
|
198
200
|
}
|
|
199
201
|
latestRow(projectId) {
|
|
@@ -203,7 +205,7 @@ export class ProjectHistoryService {
|
|
|
203
205
|
project(projectId) {
|
|
204
206
|
const project = this.db.prepare("SELECT * FROM projects WHERE id = ?").get(projectId);
|
|
205
207
|
if (!project)
|
|
206
|
-
throw
|
|
208
|
+
throw httpError(404, "PROJECT_NOT_FOUND");
|
|
207
209
|
return project;
|
|
208
210
|
}
|
|
209
211
|
emptyManifest(project) {
|
|
@@ -267,7 +269,7 @@ export class ProjectHistoryService {
|
|
|
267
269
|
}
|
|
268
270
|
objectPath(projectId, digest) {
|
|
269
271
|
if (!/^[a-f0-9]{64}$/.test(digest))
|
|
270
|
-
throw new Error("
|
|
272
|
+
throw new Error("Invalid history object digest");
|
|
271
273
|
return path.join(outputRoot(this.config, projectId), ".texlite", "history", "objects", digest.slice(0, 2), digest);
|
|
272
274
|
}
|
|
273
275
|
pruneVersions(projectId) {
|
|
@@ -442,7 +444,7 @@ function cloneManifest(manifest) {
|
|
|
442
444
|
function parseManifest(value) {
|
|
443
445
|
const parsed = JSON.parse(value);
|
|
444
446
|
if (parsed.version !== 1 || !parsed.files || !parsed.settings)
|
|
445
|
-
throw new Error("
|
|
447
|
+
throw new Error("History manifest is invalid");
|
|
446
448
|
return parsed;
|
|
447
449
|
}
|
|
448
450
|
function parseStringArray(value) {
|
package/dist/server/http.js
CHANGED
|
@@ -1,8 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
import { serverErrorMessage, serverLocale } from "./i18n.js";
|
|
2
|
+
export class HttpError extends Error {
|
|
3
|
+
statusCode;
|
|
4
|
+
code;
|
|
5
|
+
details;
|
|
6
|
+
constructor(statusCode, code, details = {}) {
|
|
7
|
+
// Keep a useful, locale-neutral message for internal callers and logs.
|
|
8
|
+
// HTTP responses are still rendered using the request's actual locale.
|
|
9
|
+
super(serverErrorMessage("en", code, details));
|
|
10
|
+
this.statusCode = statusCode;
|
|
11
|
+
this.code = code;
|
|
12
|
+
this.details = details;
|
|
13
|
+
this.name = "HttpError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Create a user-correctable error without putting a locale-specific string in
|
|
18
|
+
* service or route code. `apiError` resolves the code using the request locale.
|
|
19
|
+
*/
|
|
20
|
+
export function httpError(statusCode, code, details = {}) {
|
|
21
|
+
return new HttpError(statusCode, code, details);
|
|
22
|
+
}
|
|
23
|
+
export class ValidationError extends HttpError {
|
|
24
|
+
constructor(code = "REQUEST_INVALID", details = {}) {
|
|
25
|
+
super(400, code, details);
|
|
6
26
|
this.name = "ValidationError";
|
|
7
27
|
}
|
|
8
28
|
}
|
|
@@ -11,21 +31,21 @@ export class ValidationError extends Error {
|
|
|
11
31
|
* replace or remove source files must stop at this boundary instead of
|
|
12
32
|
* continuing with a stale filesystem snapshot.
|
|
13
33
|
*/
|
|
14
|
-
export class SourceFlushError extends
|
|
15
|
-
statusCode = 409;
|
|
16
|
-
code = "SOURCE_FLUSH_FAILED";
|
|
34
|
+
export class SourceFlushError extends HttpError {
|
|
17
35
|
failedPaths;
|
|
18
36
|
constructor(failedPaths = []) {
|
|
19
37
|
const paths = [...new Set(failedPaths)].slice(0, 100);
|
|
20
|
-
super(paths
|
|
21
|
-
? `部分源码未能保存至磁盘,无法继续项目操作:${paths.join(", ")}`
|
|
22
|
-
: "部分源码未能保存至磁盘,无法继续项目操作");
|
|
38
|
+
super(409, "SOURCE_FLUSH_FAILED", { failedPaths: paths });
|
|
23
39
|
this.name = "SourceFlushError";
|
|
24
40
|
this.failedPaths = paths;
|
|
25
41
|
}
|
|
26
42
|
}
|
|
27
|
-
export function apiError(reply, status, code,
|
|
28
|
-
return reply.code(status).send({
|
|
43
|
+
export function apiError(reply, status, code, details = {}) {
|
|
44
|
+
return reply.code(status).send({
|
|
45
|
+
code,
|
|
46
|
+
error: serverErrorMessage(serverLocale(reply.request?.headers), code, details),
|
|
47
|
+
...details
|
|
48
|
+
});
|
|
29
49
|
}
|
|
30
50
|
export function contentDisposition(filename, mode) {
|
|
31
51
|
const fallback = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|