texlite 0.7.5 → 0.7.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +19 -3
  2. package/README.zh-CN.md +8 -3
  3. package/dist/client/assets/{GitDialog-pn8n5Afm.js → GitDialog-W3hLOWrF.js} +2 -2
  4. package/dist/client/assets/HistoryDialog-7ADYJcGi.js +5 -0
  5. package/dist/client/assets/LatexEditor-ChHQwJXZ.js +59 -0
  6. package/dist/client/assets/PdfPreview-CuvWphzY.js +1 -0
  7. package/dist/client/assets/{ProjectNavigationDialogs-CaovMu4V.js → ProjectNavigationDialogs-CzFhIw6B.js} +1 -1
  8. package/dist/client/assets/SystemMetricsDialog-C0m9JUDy.js +1 -0
  9. package/dist/client/assets/index-DRShM2ob.js +64 -0
  10. package/dist/client/assets/index-DzcH7hBu.css +1 -0
  11. package/dist/client/assets/latexFormatterWorker-DYkYtcFy.js +6 -0
  12. package/dist/client/assets/minus-BfqMIeUg.js +1 -0
  13. package/dist/client/assets/{react-CsO_xvbl.js → react-BoEjKzFZ.js} +1 -1
  14. package/dist/client/assets/spellCheck-BBnCnxmQ.js +4 -0
  15. package/dist/client/index.html +3 -3
  16. package/dist/server/anchors.js +22 -1
  17. package/dist/server/app.js +103 -2335
  18. package/dist/server/auth.js +3 -2
  19. package/dist/server/cli.js +8 -2
  20. package/dist/server/collaboration.js +250 -2
  21. package/dist/server/compileArtifacts.js +10 -7
  22. package/dist/server/compiler.js +9 -4
  23. package/dist/server/config.js +25 -1
  24. package/dist/server/environment.js +5 -1
  25. package/dist/server/files.js +7 -6
  26. package/dist/server/git.js +28 -39
  27. package/dist/server/harper.js +144 -0
  28. package/dist/server/harperRuntime.js +38 -0
  29. package/dist/server/harperWorker.js +15 -0
  30. package/dist/server/history.js +11 -9
  31. package/dist/server/http.js +33 -13
  32. package/dist/server/i18n.js +276 -0
  33. package/dist/server/latexRoot.js +78 -0
  34. package/dist/server/limits.js +9 -0
  35. package/dist/server/projectSearch.js +3 -4
  36. package/dist/server/routes/auth.js +77 -0
  37. package/dist/server/routes/citations.js +331 -0
  38. package/dist/server/routes/collaboration.js +17 -0
  39. package/dist/server/routes/comments.js +176 -0
  40. package/dist/server/routes/compile.js +71 -50
  41. package/dist/server/routes/projectFiles.js +572 -0
  42. package/dist/server/routes/projectGit.js +145 -0
  43. package/dist/server/routes/projectHistory.js +150 -0
  44. package/dist/server/routes/projectMembers.js +112 -0
  45. package/dist/server/routes/projectShared.js +238 -0
  46. package/dist/server/routes/projects.js +473 -0
  47. package/dist/server/routes/system.js +37 -0
  48. package/dist/server/routes/users.js +163 -0
  49. package/dist/server/security.js +2 -4
  50. package/dist/server/synctex.js +7 -4
  51. package/dist/server/zip.js +29 -13
  52. package/package.json +3 -3
  53. package/texlite.config.example.json +5 -1
  54. package/dist/client/assets/BinaryModule-Aj1vLnwf-CKY6Y_Pt.js +0 -4
  55. package/dist/client/assets/HistoryDialog-BpWEZrYf.js +0 -1
  56. package/dist/client/assets/LatexEditor-DNAmNvHu.js +0 -59
  57. package/dist/client/assets/PdfPreview-BVaG7TtG.js +0 -1
  58. package/dist/client/assets/SystemMetricsDialog-D8jj7epG.js +0 -1
  59. package/dist/client/assets/__vite-browser-external-BIHI7g3E.js +0 -1
  60. package/dist/client/assets/binary-BoldOm0z.js +0 -1
  61. package/dist/client/assets/harper_wasm_bg-LWXDb7Ll.wasm +0 -0
  62. package/dist/client/assets/index-BQCB3z5H.js +0 -64
  63. package/dist/client/assets/index-BnvpaWsa.css +0 -1
  64. package/dist/client/assets/index-Byn2Miy9.js +0 -3456
  65. package/dist/client/assets/index-rBWMBTI-.js +0 -5
  66. package/dist/client/assets/minus-K-5IK4nL.js +0 -1
  67. package/dist/client/assets/spellCheck-C32VQVUE.js +0 -5
  68. package/dist/client/assets/texFmtRuntime-EYyQNUnJ.js +0 -2
@@ -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, "GitHub token 格式不正确");
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, "GitHub 没有返回有效的用户信息");
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, "GitHub 仓库名称只能包含字母、数字、点、横线或下划线");
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, "GitHub 没有返回有效的仓库地址");
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, "提交说明不能为空且不能超过 300 个字符");
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, "当前处于 detached HEAD,请先返回默认分支再提交");
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, "请先创建 GitHub 仓库");
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, "当前处于 detached HEAD,请先返回默认分支再推送");
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, "该项目尚未初始化 Git");
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, "请先配置 GitHub token");
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, "Git 版本不存在");
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
- const message = typeof body.message === "string" ? body.message : `HTTP ${response.status}`;
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 body;
308
+ return await response.json();
310
309
  }
311
310
  catch (error) {
312
311
  if (error instanceof Error && error.name === "AbortError")
313
- throw httpError(504, "GitHub 请求超时");
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", (error) => { clearTimeout(timer); reject(httpError(500, `无法运行 Git:${error.message}`)); });
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, "Git 操作超时"));
375
+ return reject(httpError(504, "GIT_TIMEOUT"));
377
376
  if (size > MAX_COMMAND_OUTPUT)
378
- return reject(httpError(413, "Git 输出过大"));
377
+ return reject(httpError(413, "GIT_OUTPUT_TOO_LARGE"));
379
378
  if (result.code !== 0 && !allowedCodes.includes(result.code)) {
380
- const detail = (result.stderr || result.stdout).trim().slice(0, 2000) || `exit ${result.code}`;
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, "Git 版本格式不正确");
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, "Git token 加密密钥无效");
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, "GitHub token 无法解密");
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, "GitHub token 无法解密");
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
+ }
@@ -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 Object.assign(new Error("历史版本不存在"), { code: "HISTORY_VERSION_NOT_FOUND", statusCode: 404 });
146
+ throw httpError(404, "HISTORY_VERSION_NOT_FOUND");
145
147
  }
146
148
  const filePath = safeRelativePath(filePathInput);
147
149
  if (!manifest.files[filePath]) {
148
- throw Object.assign(new Error("该历史版本中不存在此文件"), { code: "HISTORY_FILE_NOT_FOUND", statusCode: 404 });
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 > 2 * 1024 * 1024)
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 Object.assign(new Error("历史版本不存在"), { code: "HISTORY_VERSION_NOT_FOUND", statusCode: 404 });
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 Object.assign(new Error("该历史版本中不存在此文件"), { code: "HISTORY_FILE_NOT_FOUND", statusCode: 404 });
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 Object.assign(new Error("历史文件恢复目标不是普通文件"), { code: "HISTORY_TARGET_CONFLICT", statusCode: 409 });
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 new Error("项目不存在");
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) {
@@ -1,8 +1,28 @@
1
- export class ValidationError extends Error {
2
- statusCode = 400;
3
- code = "REQUEST_INVALID";
4
- constructor(message) {
5
- super(message);
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 Error {
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.length
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, message, details = {}) {
28
- return reply.code(status).send({ code, error: message, ...details });
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, "_");