texlite 0.5.5 → 0.6.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/client/assets/{BinaryModule-Aj1vLnwf-BsIXahIW.js → BinaryModule-Aj1vLnwf-hZ2K_uyt.js} +1 -1
- package/dist/client/assets/{GitDialog-CFzsF7ys.js → GitDialog-s54eAFX6.js} +1 -1
- package/dist/client/assets/{HistoryDialog-YPiAOGU_.js → HistoryDialog-DrUnIy0a.js} +1 -1
- package/dist/client/assets/{LatexEditor-BX4WLdhS.js → LatexEditor-Y5KfVqQI.js} +20 -20
- package/dist/client/assets/PdfPreview-BPKX-qpr.js +1 -0
- package/dist/client/assets/{ProjectNavigationDialogs-BZanedGt.js → ProjectNavigationDialogs-CjZX0g7O.js} +1 -1
- package/dist/client/assets/{SystemMetricsDialog-DnH4c7Se.js → SystemMetricsDialog-B97Og0La.js} +1 -1
- package/dist/client/assets/binary-DCBFVo4B.js +1 -0
- package/dist/client/assets/index-5ajAy27z.css +1 -0
- package/dist/client/assets/index-CCitTPyj.js +17 -0
- package/dist/client/assets/{index-B5iMrEn5.js → index-CQG_gpuU.js} +1 -1
- package/dist/client/assets/{plus-B-DG5h4y.js → plus-Bp4_lUor.js} +1 -1
- package/dist/client/assets/{spellCheck-4xuNPF_M.js → spellCheck-0f5ZG5IV.js} +3 -3
- package/dist/client/index.html +2 -2
- package/dist/server/app.js +889 -337
- package/dist/server/archive.js +20 -3
- package/dist/server/auth.js +13 -2
- package/dist/server/collaboration.js +815 -124
- package/dist/server/compileArtifacts.js +37 -9
- package/dist/server/compiler.js +69 -20
- package/dist/server/db.js +6 -0
- package/dist/server/environment.js +4 -2
- package/dist/server/files.js +172 -25
- package/dist/server/git.js +152 -124
- package/dist/server/history.js +86 -18
- package/dist/server/http.js +26 -0
- package/dist/server/index.js +39 -26
- package/dist/server/instanceLock.js +137 -0
- package/dist/server/latexCompletion.js +23 -1
- package/dist/server/latexFormatter.js +4 -2
- package/dist/server/processTree.js +31 -0
- package/dist/server/projectMutations.js +255 -0
- package/dist/server/projectOutline.js +113 -1
- package/dist/server/projectSearch.js +6 -0
- package/dist/server/routes/compile.js +535 -132
- package/dist/server/security.js +61 -1
- package/dist/server/synctex.js +11 -2
- package/dist/server/zip.js +2 -1
- package/package.json +1 -1
- package/dist/client/assets/PdfPreview-FQ8oxyUx.js +0 -1
- package/dist/client/assets/binary-MtJxbY5b.js +0 -1
- package/dist/client/assets/index-CUa7L47p.css +0 -1
- package/dist/client/assets/index-DdFlmXqA.js +0 -17
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
export const LOCK_FILE_NAME = ".texlite.lock";
|
|
5
|
+
export function isProcessAlive(pid) {
|
|
6
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
7
|
+
return false;
|
|
8
|
+
try {
|
|
9
|
+
process.kill(pid, 0);
|
|
10
|
+
return true;
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
const code = error?.code;
|
|
14
|
+
return code === "EPERM";
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export function readDataDirectoryLock(dataDir) {
|
|
18
|
+
const lockPath = path.join(dataDir, LOCK_FILE_NAME);
|
|
19
|
+
const metadataPath = path.join(lockPath, "owner.json");
|
|
20
|
+
try {
|
|
21
|
+
if (!fs.existsSync(lockPath))
|
|
22
|
+
return null;
|
|
23
|
+
// New locks are complete directories installed with atomic rename, so
|
|
24
|
+
// readers never need to delete an empty/partially-written lock. Read the
|
|
25
|
+
// old regular-file
|
|
26
|
+
// format as a migration path for crashed instances from older releases.
|
|
27
|
+
const lockStat = fs.lstatSync(lockPath);
|
|
28
|
+
const content = fs.readFileSync(lockStat.isDirectory() ? metadataPath : lockPath, "utf8");
|
|
29
|
+
const parsed = JSON.parse(content);
|
|
30
|
+
if (typeof parsed?.pid === "number" && typeof parsed?.startedAt === "string") {
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
export function acquireDataDirectoryLock(config) {
|
|
40
|
+
fs.mkdirSync(config.dataDir, { recursive: true, mode: 0o700 });
|
|
41
|
+
const lockPath = path.join(config.dataDir, LOCK_FILE_NAME);
|
|
42
|
+
const lockInfo = {
|
|
43
|
+
pid: process.pid,
|
|
44
|
+
startedAt: new Date().toISOString(),
|
|
45
|
+
token: randomUUID(),
|
|
46
|
+
configPath: config.configPath,
|
|
47
|
+
host: config.host,
|
|
48
|
+
port: config.port
|
|
49
|
+
};
|
|
50
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
51
|
+
try {
|
|
52
|
+
// mkdir is the ownership operation. Another process can never acquire
|
|
53
|
+
// the same directory; the owner metadata is then installed atomically
|
|
54
|
+
// inside it. Readers never remove a fresh directory while this write is
|
|
55
|
+
// in progress.
|
|
56
|
+
fs.mkdirSync(lockPath, { mode: 0o700 });
|
|
57
|
+
const metadata = path.join(lockPath, "owner.json");
|
|
58
|
+
const temporary = `${metadata}.${process.pid}-${randomUUID()}.tmp`;
|
|
59
|
+
try {
|
|
60
|
+
fs.writeFileSync(temporary, `${JSON.stringify(lockInfo, null, 2)}\n`, {
|
|
61
|
+
encoding: "utf8", mode: 0o600, flag: "wx"
|
|
62
|
+
});
|
|
63
|
+
fs.renameSync(temporary, metadata);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
fs.rmSync(temporary, { force: true });
|
|
67
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
let released = false;
|
|
71
|
+
const release = () => {
|
|
72
|
+
if (released)
|
|
73
|
+
return;
|
|
74
|
+
released = true;
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(lockPath)) {
|
|
77
|
+
const current = readDataDirectoryLock(config.dataDir);
|
|
78
|
+
if (current?.pid === lockInfo.pid && current.startedAt === lockInfo.startedAt
|
|
79
|
+
&& current.token === lockInfo.token) {
|
|
80
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
catch { /* Ignore file system cleanup errors on exit */ }
|
|
85
|
+
};
|
|
86
|
+
const exitHandler = () => release();
|
|
87
|
+
process.once("exit", exitHandler);
|
|
88
|
+
return {
|
|
89
|
+
release: () => {
|
|
90
|
+
process.removeListener("exit", exitHandler);
|
|
91
|
+
release();
|
|
92
|
+
},
|
|
93
|
+
lockPath,
|
|
94
|
+
info: lockInfo
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
const code = error?.code;
|
|
99
|
+
if (code !== "EEXIST")
|
|
100
|
+
throw error;
|
|
101
|
+
const existing = readDataDirectoryLock(config.dataDir);
|
|
102
|
+
if (existing && isProcessAlive(existing.pid)) {
|
|
103
|
+
throw new Error(`The data directory is already locked by another TexLite instance:\n` +
|
|
104
|
+
` - PID: ${existing.pid}\n` +
|
|
105
|
+
` - Config: ${existing.configPath}\n` +
|
|
106
|
+
` - Address: http://${existing.host}:${existing.port}\n` +
|
|
107
|
+
` - Started: ${existing.startedAt}\n` +
|
|
108
|
+
` - Data directory: ${config.dataDir}\n` +
|
|
109
|
+
`Do not run multiple TexLite instances with the same data directory.`);
|
|
110
|
+
}
|
|
111
|
+
// An empty lock directory is normally another process between mkdir and
|
|
112
|
+
// its atomic owner.json rename. Never remove a fresh one: doing so would
|
|
113
|
+
// reintroduce the exact concurrent-start race this lock prevents.
|
|
114
|
+
if (!existing) {
|
|
115
|
+
try {
|
|
116
|
+
const stat = fs.statSync(lockPath);
|
|
117
|
+
if (Date.now() - stat.mtimeMs < 5_000) {
|
|
118
|
+
throw new Error("The data directory lock is being initialized by another TexLite instance. Try again shortly.");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
if (error instanceof Error && error.message.startsWith("The data directory lock is being initialized"))
|
|
123
|
+
throw error;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// Stale lock from a crashed process. The recursive removal also handles
|
|
127
|
+
// the legacy regular-file lock used by pre-directory-lock releases.
|
|
128
|
+
try {
|
|
129
|
+
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
// Ignore removal error and retry
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
throw new Error(`Unable to acquire the TexLite data directory lock: ${lockPath}`);
|
|
137
|
+
}
|
|
@@ -246,6 +246,7 @@ export function buildLatexCompletionIndex(config, projectId) {
|
|
|
246
246
|
export class LatexCompletionService {
|
|
247
247
|
config;
|
|
248
248
|
cache = new Map();
|
|
249
|
+
resultCache = new Map();
|
|
249
250
|
pending = new Map();
|
|
250
251
|
constructor(config) {
|
|
251
252
|
this.config = config;
|
|
@@ -263,12 +264,26 @@ export class LatexCompletionService {
|
|
|
263
264
|
}
|
|
264
265
|
invalidate(projectId) {
|
|
265
266
|
this.cache.delete(projectId);
|
|
267
|
+
this.resultCache.delete(projectId);
|
|
268
|
+
}
|
|
269
|
+
stats() {
|
|
270
|
+
return {
|
|
271
|
+
cachedProjects: this.resultCache.size,
|
|
272
|
+
pending: this.pending.size,
|
|
273
|
+
cachedFiles: [...this.cache.values()].reduce((total, files) => total + files.size, 0)
|
|
274
|
+
};
|
|
266
275
|
}
|
|
267
276
|
async buildIncremental(projectId) {
|
|
268
277
|
const index = standardIndex();
|
|
269
278
|
const projectCache = this.cache.get(projectId) ?? new Map();
|
|
270
279
|
this.cache.set(projectId, projectCache);
|
|
271
280
|
const allEntries = (await listProjectFilesAsync(this.config, projectId)).filter((entry) => entry.type === "file").slice(0, maxIndexedFiles);
|
|
281
|
+
const signature = allEntries.map((entry) => `${entry.path}:${entry.size ?? 0}:${entry.mtimeMs ?? 0}`).sort().join("\n");
|
|
282
|
+
const cachedResult = this.resultCache.get(projectId);
|
|
283
|
+
if (cachedResult?.signature === signature) {
|
|
284
|
+
cachedResult.touched = Date.now();
|
|
285
|
+
return cachedResult.index;
|
|
286
|
+
}
|
|
272
287
|
const livePaths = new Set(allEntries.map((entry) => entry.path));
|
|
273
288
|
for (const cachedPath of [...projectCache.keys()])
|
|
274
289
|
if (!livePaths.has(cachedPath))
|
|
@@ -300,10 +315,17 @@ export class LatexCompletionService {
|
|
|
300
315
|
}
|
|
301
316
|
mergeIndex(index, cached.index);
|
|
302
317
|
}
|
|
303
|
-
|
|
318
|
+
const result = {
|
|
304
319
|
commands: sorted(index.commands), environments: sorted(index.environments), labels: sorted(index.labels),
|
|
305
320
|
citations: sorted(index.citations), packages: sorted(index.packages), files: sorted(index.files)
|
|
306
321
|
};
|
|
322
|
+
this.resultCache.set(projectId, { signature, index: result, touched: Date.now() });
|
|
323
|
+
if (this.resultCache.size > 64) {
|
|
324
|
+
const oldest = [...this.resultCache.entries()].sort((left, right) => left[1].touched - right[1].touched)[0];
|
|
325
|
+
if (oldest)
|
|
326
|
+
this.resultCache.delete(oldest[0]);
|
|
327
|
+
}
|
|
328
|
+
return result;
|
|
307
329
|
}
|
|
308
330
|
}
|
|
309
331
|
function mergeIndex(target, source) {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
|
+
import { detachedProcessGroup, killProcessGroup } from "./processTree.js";
|
|
2
3
|
const DEFAULT_COMMAND = "tex-fmt";
|
|
3
4
|
const FORMAT_TIMEOUT_MS = 30_000;
|
|
4
5
|
const MAX_DIAGNOSTIC_BYTES = 16 * 1024;
|
|
@@ -50,6 +51,7 @@ function runCommand(command, args, input, cwd, timeoutMs) {
|
|
|
50
51
|
cwd,
|
|
51
52
|
env: { ...process.env, LC_ALL: "C" },
|
|
52
53
|
shell: false,
|
|
54
|
+
detached: detachedProcessGroup(),
|
|
53
55
|
stdio: ["pipe", "pipe", "pipe"]
|
|
54
56
|
});
|
|
55
57
|
}
|
|
@@ -82,7 +84,7 @@ function runCommand(command, args, input, cwd, timeoutMs) {
|
|
|
82
84
|
};
|
|
83
85
|
const timer = setTimeout(() => {
|
|
84
86
|
timedOut = true;
|
|
85
|
-
child
|
|
87
|
+
killProcessGroup(child);
|
|
86
88
|
}, timeoutMs);
|
|
87
89
|
child.stdout.on("data", (chunk) => {
|
|
88
90
|
if (outputTooLarge || stdoutBytes >= 64 * 1024 * 1024)
|
|
@@ -93,7 +95,7 @@ function runCommand(command, args, input, cwd, timeoutMs) {
|
|
|
93
95
|
stdoutBytes += bytes.byteLength;
|
|
94
96
|
if (chunk.byteLength > remaining) {
|
|
95
97
|
outputTooLarge = true;
|
|
96
|
-
child
|
|
98
|
+
killProcessGroup(child);
|
|
97
99
|
}
|
|
98
100
|
});
|
|
99
101
|
child.stderr.on("data", (chunk) => {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Start long-running external commands in their own process group on POSIX.
|
|
3
|
+
* A negative PID can then be used to signal the command and all descendants
|
|
4
|
+
* (for example latexmk's pdflatex/bibtex children) together.
|
|
5
|
+
*/
|
|
6
|
+
export function detachedProcessGroup() {
|
|
7
|
+
return process.platform !== "win32";
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Terminate an external command and its descendants when the platform
|
|
11
|
+
* supports process groups. The child-only fallback keeps this safe on
|
|
12
|
+
* Windows and for processes that have already exited.
|
|
13
|
+
*/
|
|
14
|
+
export function killProcessGroup(child, signal = "SIGKILL") {
|
|
15
|
+
if (process.platform !== "win32" && child.pid) {
|
|
16
|
+
try {
|
|
17
|
+
process.kill(-child.pid, signal);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error.code !== "ESRCH") {
|
|
22
|
+
// Fall through to child.kill(). It is possible for a process group
|
|
23
|
+
// to be unavailable even though the child itself is still alive.
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
try {
|
|
28
|
+
child.kill(signal);
|
|
29
|
+
}
|
|
30
|
+
catch { /* the process may have exited already */ }
|
|
31
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { SourceFlushError } from "./http.js";
|
|
2
|
+
/**
|
|
3
|
+
* Serializes operations that replace or move a project's source tree.
|
|
4
|
+
*
|
|
5
|
+
* Ordinary Yjs edits remain concurrent. An exclusive operation first flushes
|
|
6
|
+
* the current room, blocks new updates, and resets the collaboration epoch on
|
|
7
|
+
* completion so clients cannot replay edits made against the replaced tree.
|
|
8
|
+
*/
|
|
9
|
+
export class ProjectMutationCoordinator {
|
|
10
|
+
collaboration;
|
|
11
|
+
queues = new Map();
|
|
12
|
+
/**
|
|
13
|
+
* Compilation is deliberately not represented by the ordinary project
|
|
14
|
+
* queue. A compile runs from an immutable snapshot and may take minutes;
|
|
15
|
+
* keeping it in `queues` would make reads and ordinary edits wait for
|
|
16
|
+
* latexmk. The separate state below only prevents source-tree replacement
|
|
17
|
+
* and compile-cache cleanup from racing an active compile.
|
|
18
|
+
*/
|
|
19
|
+
compileStates = new Map();
|
|
20
|
+
constructor(collaboration) {
|
|
21
|
+
this.collaboration = collaboration;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Flush the live collaborative room and require a durable result. A null
|
|
25
|
+
* receipt means that no room is currently loaded, so the source directory
|
|
26
|
+
* is already authoritative for this request.
|
|
27
|
+
*/
|
|
28
|
+
flushProject(projectId) {
|
|
29
|
+
return this.requireSuccessfulFlush(this.collaboration.flushProject(projectId));
|
|
30
|
+
}
|
|
31
|
+
async runExclusive(projectId, reason, operation, options = {}) {
|
|
32
|
+
const exclusion = this.beginCompileExclusion(projectId);
|
|
33
|
+
try {
|
|
34
|
+
// Do not reserve the ordinary queue until active compilations have
|
|
35
|
+
// drained. Otherwise a compile waiting for a short snapshot lock could
|
|
36
|
+
// deadlock behind this exclusive operation.
|
|
37
|
+
await exclusion.waitForIdle();
|
|
38
|
+
const reservation = this.reserve(projectId);
|
|
39
|
+
const { previous, tail, release } = reservation;
|
|
40
|
+
await previous;
|
|
41
|
+
try {
|
|
42
|
+
await this.collaboration.waitForReady?.(projectId);
|
|
43
|
+
options.preflight?.();
|
|
44
|
+
// This call is synchronous. No WebSocket event can interleave between
|
|
45
|
+
// the final flush and the maintenance flag being installed.
|
|
46
|
+
if (options.flush !== false)
|
|
47
|
+
this.flushProject(projectId);
|
|
48
|
+
this.collaboration.beginMaintenance(projectId, reason);
|
|
49
|
+
try {
|
|
50
|
+
return await operation();
|
|
51
|
+
}
|
|
52
|
+
finally {
|
|
53
|
+
this.collaboration.endMaintenance(projectId);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
release();
|
|
58
|
+
if (this.queues.get(projectId)?.tail === tail)
|
|
59
|
+
this.queues.delete(projectId);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
exclusion.release();
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Run a long-lived compiler job without occupying the source mutation
|
|
68
|
+
* queue. The callback must use an immutable compile snapshot for all source
|
|
69
|
+
* reads. Exclusive source-tree operations and compile-cache cleanup wait for
|
|
70
|
+
* this callback to finish, while ordinary reads and edits continue.
|
|
71
|
+
*/
|
|
72
|
+
async runCompile(projectId, operation) {
|
|
73
|
+
const release = await this.acquireCompile(projectId);
|
|
74
|
+
try {
|
|
75
|
+
return await operation();
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
release();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Serialize an operation that removes or replaces compiler state without
|
|
83
|
+
* disconnecting collaborators. This is used by compile-cache/artifact
|
|
84
|
+
* cleanup, which must not run alongside latexmk but does not need the full
|
|
85
|
+
* maintenance mode used by Git checkout or history restore.
|
|
86
|
+
*/
|
|
87
|
+
async runCompileExclusive(projectId, operation, options = {}) {
|
|
88
|
+
const exclusion = this.beginCompileExclusion(projectId);
|
|
89
|
+
try {
|
|
90
|
+
await exclusion.waitForIdle();
|
|
91
|
+
return await this.runQueued(projectId, operation, options);
|
|
92
|
+
}
|
|
93
|
+
finally {
|
|
94
|
+
exclusion.release();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** Reserve a project operation after queued mutations without flushing. */
|
|
98
|
+
async runSnapshot(projectId, operation, options = {}) {
|
|
99
|
+
return this.runQueued(projectId, operation, options);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Serialize a project operation without entering maintenance mode.
|
|
103
|
+
*
|
|
104
|
+
* This is the common short lock for source-tree operations that must not
|
|
105
|
+
* overlap another queued operation, but should not disconnect active
|
|
106
|
+
* collaborators. Long-running compiler processes use `runCompile` instead;
|
|
107
|
+
* they operate on immutable snapshots and do not occupy this queue.
|
|
108
|
+
* The queue is deliberately held until the async operation settles (rather
|
|
109
|
+
* than only until it starts), so filesystem work cannot race another queued
|
|
110
|
+
* operation.
|
|
111
|
+
*/
|
|
112
|
+
async runSerialized(projectId, operation, options = {}) {
|
|
113
|
+
return this.runQueued(projectId, async () => {
|
|
114
|
+
if (options.flush !== false)
|
|
115
|
+
this.flushProject(projectId);
|
|
116
|
+
return operation();
|
|
117
|
+
}, options);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Run a source-tree read under a short snapshot barrier.
|
|
121
|
+
*
|
|
122
|
+
* Yjs edits continue in memory, but autosave and HTTP mutations cannot
|
|
123
|
+
* change the source tree while the asynchronous scan/archive is running.
|
|
124
|
+
* The deferred edits are flushed when the barrier is released.
|
|
125
|
+
*/
|
|
126
|
+
async runConsistentRead(projectId, operation, options = {}) {
|
|
127
|
+
return this.runBarrieredRead(projectId, operation, options, true);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Read the source tree under the same short barrier as a consistent read,
|
|
131
|
+
* but do not wait for a cold collaboration room to hydrate. The filesystem
|
|
132
|
+
* is authoritative until a room exists, so this is safe for first-paint
|
|
133
|
+
* file-list requests and prevents them from being serialized behind a large
|
|
134
|
+
* Yjs bootstrap.
|
|
135
|
+
*/
|
|
136
|
+
async runFilesystemRead(projectId, operation, options = {}) {
|
|
137
|
+
return this.runBarrieredRead(projectId, operation, options, false);
|
|
138
|
+
}
|
|
139
|
+
/**
|
|
140
|
+
* Serialize an ordinary filesystem write with compile snapshots without
|
|
141
|
+
* interrupting active collaborators. The operation flushes the current Yjs
|
|
142
|
+
* room first, so an HTTP file operation never overwrites a newer draft.
|
|
143
|
+
*/
|
|
144
|
+
async runWrite(projectId, operation, options = {}) {
|
|
145
|
+
return this.runSerialized(projectId, operation, options);
|
|
146
|
+
}
|
|
147
|
+
async runQueued(projectId, operation, options) {
|
|
148
|
+
const { previous, tail, release } = this.reserve(projectId);
|
|
149
|
+
await previous;
|
|
150
|
+
try {
|
|
151
|
+
await this.collaboration.waitForReady?.(projectId);
|
|
152
|
+
options.preflight?.();
|
|
153
|
+
return await operation();
|
|
154
|
+
}
|
|
155
|
+
finally {
|
|
156
|
+
release();
|
|
157
|
+
if (this.queues.get(projectId)?.tail === tail)
|
|
158
|
+
this.queues.delete(projectId);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
async runBarrieredRead(projectId, operation, options, waitForRoom) {
|
|
162
|
+
const { previous, tail, release } = this.reserve(projectId);
|
|
163
|
+
await previous;
|
|
164
|
+
let barrierStarted = false;
|
|
165
|
+
try {
|
|
166
|
+
if (waitForRoom)
|
|
167
|
+
await this.collaboration.waitForReady?.(projectId);
|
|
168
|
+
options.preflight?.();
|
|
169
|
+
this.flushProject(projectId);
|
|
170
|
+
this.collaboration.beginSnapshotBarrier(projectId);
|
|
171
|
+
barrierStarted = true;
|
|
172
|
+
return await operation();
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
try {
|
|
176
|
+
if (barrierStarted)
|
|
177
|
+
this.requireSuccessfulFlush(this.collaboration.endSnapshotBarrier(projectId));
|
|
178
|
+
}
|
|
179
|
+
finally {
|
|
180
|
+
release();
|
|
181
|
+
if (this.queues.get(projectId)?.tail === tail)
|
|
182
|
+
this.queues.delete(projectId);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
requireSuccessfulFlush(receipt) {
|
|
187
|
+
if (receipt && !receipt.ok)
|
|
188
|
+
throw new SourceFlushError(receipt.failedPaths ?? []);
|
|
189
|
+
return receipt;
|
|
190
|
+
}
|
|
191
|
+
reserve(projectId) {
|
|
192
|
+
const previous = this.queues.get(projectId)?.tail ?? Promise.resolve();
|
|
193
|
+
let release;
|
|
194
|
+
const gate = new Promise((resolve) => { release = resolve; });
|
|
195
|
+
const tail = previous.then(() => gate);
|
|
196
|
+
this.queues.set(projectId, { tail, release });
|
|
197
|
+
return { previous, tail, release };
|
|
198
|
+
}
|
|
199
|
+
stateFor(projectId) {
|
|
200
|
+
const existing = this.compileStates.get(projectId);
|
|
201
|
+
if (existing)
|
|
202
|
+
return existing;
|
|
203
|
+
const state = { active: 0, exclusions: 0, idleWaiters: [], startWaiters: [] };
|
|
204
|
+
this.compileStates.set(projectId, state);
|
|
205
|
+
return state;
|
|
206
|
+
}
|
|
207
|
+
beginCompileExclusion(projectId) {
|
|
208
|
+
const state = this.stateFor(projectId);
|
|
209
|
+
state.exclusions += 1;
|
|
210
|
+
let released = false;
|
|
211
|
+
return {
|
|
212
|
+
waitForIdle: () => state.active === 0
|
|
213
|
+
? Promise.resolve()
|
|
214
|
+
: new Promise((resolve) => state.idleWaiters.push(resolve)),
|
|
215
|
+
release: () => {
|
|
216
|
+
if (released)
|
|
217
|
+
return;
|
|
218
|
+
released = true;
|
|
219
|
+
state.exclusions = Math.max(0, state.exclusions - 1);
|
|
220
|
+
if (state.exclusions === 0) {
|
|
221
|
+
const waiters = state.startWaiters.splice(0);
|
|
222
|
+
for (const resolve of waiters)
|
|
223
|
+
resolve();
|
|
224
|
+
}
|
|
225
|
+
this.cleanupCompileState(projectId, state);
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
async acquireCompile(projectId) {
|
|
230
|
+
const state = this.stateFor(projectId);
|
|
231
|
+
while (state.exclusions > 0) {
|
|
232
|
+
await new Promise((resolve) => state.startWaiters.push(resolve));
|
|
233
|
+
}
|
|
234
|
+
state.active += 1;
|
|
235
|
+
let released = false;
|
|
236
|
+
return () => {
|
|
237
|
+
if (released)
|
|
238
|
+
return;
|
|
239
|
+
released = true;
|
|
240
|
+
state.active = Math.max(0, state.active - 1);
|
|
241
|
+
if (state.active === 0) {
|
|
242
|
+
const waiters = state.idleWaiters.splice(0);
|
|
243
|
+
for (const resolve of waiters)
|
|
244
|
+
resolve();
|
|
245
|
+
}
|
|
246
|
+
this.cleanupCompileState(projectId, state);
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
cleanupCompileState(projectId, state) {
|
|
250
|
+
if (state.active === 0 && state.exclusions === 0 && state.idleWaiters.length === 0 && state.startWaiters.length === 0) {
|
|
251
|
+
if (this.compileStates.get(projectId) === state)
|
|
252
|
+
this.compileStates.delete(projectId);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { resolveSourcePath, safeRelativePath } from "./files.js";
|
|
3
|
+
import { listProjectFilesAsync, resolveSourcePath, safeRelativePath } from "./files.js";
|
|
4
4
|
const levels = { part: 0, chapter: 0, section: 1, subsection: 2, subsubsection: 3, paragraph: 4 };
|
|
5
5
|
const commandPattern = /\\(part|chapter|section|subsection|subsubsection|paragraph)\*?(?:\s*\[[^\]]*\])?\s*\{|\\(?:input|include|subfile)\s*\{/g;
|
|
6
6
|
export function buildProjectOutline(config, projectId, mainFileInput) {
|
|
@@ -34,6 +34,101 @@ export function buildProjectOutline(config, projectId, mainFileInput) {
|
|
|
34
34
|
visit(mainFile);
|
|
35
35
|
return result;
|
|
36
36
|
}
|
|
37
|
+
/** Async outline builder used by the HTTP path so large projects do not block the event loop. */
|
|
38
|
+
export async function buildProjectOutlineAsync(config, projectId, mainFileInput) {
|
|
39
|
+
const mainFile = safeRelativePath(mainFileInput);
|
|
40
|
+
const result = [];
|
|
41
|
+
const visited = new Set();
|
|
42
|
+
const visit = async (filePath) => {
|
|
43
|
+
if (visited.has(filePath) || visited.size >= 200)
|
|
44
|
+
return;
|
|
45
|
+
visited.add(filePath);
|
|
46
|
+
let stat;
|
|
47
|
+
try {
|
|
48
|
+
stat = await fs.promises.stat(resolveSourcePath(config, projectId, filePath));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
if (!stat.isFile() || stat.size > 3 * 1024 * 1024)
|
|
54
|
+
return;
|
|
55
|
+
let content;
|
|
56
|
+
try {
|
|
57
|
+
content = stripCommentsPreserveLines(await fs.promises.readFile(resolveSourcePath(config, projectId, filePath), "utf8"));
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const lineStarts = sourceLineStarts(content);
|
|
63
|
+
const pattern = new RegExp(commandPattern.source, commandPattern.flags);
|
|
64
|
+
for (const command of content.matchAll(pattern)) {
|
|
65
|
+
const start = (command.index ?? 0) + command[0].length;
|
|
66
|
+
const argument = balancedArgument(content, start);
|
|
67
|
+
if (!argument)
|
|
68
|
+
continue;
|
|
69
|
+
if (command[1]) {
|
|
70
|
+
result.push({ path: filePath, line: lineAtOffset(lineStarts, command.index ?? 0), level: levels[command[1]], title: cleanTitle(argument.value) });
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
const included = await resolveIncludedFileAsync(config, projectId, filePath, argument.value);
|
|
74
|
+
if (included)
|
|
75
|
+
await visit(included);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
await visit(mainFile);
|
|
79
|
+
return result;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Caches the parsed outline by project tree metadata and main document. The
|
|
83
|
+
* pending map also coalesces simultaneous requests from multiple browser
|
|
84
|
+
* sessions opening the same project.
|
|
85
|
+
*/
|
|
86
|
+
export class ProjectOutlineService {
|
|
87
|
+
config;
|
|
88
|
+
cache = new Map();
|
|
89
|
+
pending = new Map();
|
|
90
|
+
constructor(config) {
|
|
91
|
+
this.config = config;
|
|
92
|
+
}
|
|
93
|
+
build(projectId, mainFileInput) {
|
|
94
|
+
const mainFile = safeRelativePath(mainFileInput);
|
|
95
|
+
const key = `${projectId}\0${mainFile}`;
|
|
96
|
+
const existing = this.pending.get(key);
|
|
97
|
+
if (existing)
|
|
98
|
+
return existing;
|
|
99
|
+
const request = this.buildCached(projectId, mainFile, key).finally(() => {
|
|
100
|
+
if (this.pending.get(key) === request)
|
|
101
|
+
this.pending.delete(key);
|
|
102
|
+
});
|
|
103
|
+
this.pending.set(key, request);
|
|
104
|
+
return request;
|
|
105
|
+
}
|
|
106
|
+
invalidate(projectId) {
|
|
107
|
+
for (const key of this.cache.keys())
|
|
108
|
+
if (key.startsWith(`${projectId}\0`))
|
|
109
|
+
this.cache.delete(key);
|
|
110
|
+
}
|
|
111
|
+
stats() {
|
|
112
|
+
return { cachedOutlines: this.cache.size, pending: this.pending.size };
|
|
113
|
+
}
|
|
114
|
+
async buildCached(projectId, mainFile, key) {
|
|
115
|
+
const entries = await listProjectFilesAsync(this.config, projectId);
|
|
116
|
+
const signature = entries.map((entry) => `${entry.type}:${entry.path}:${entry.size ?? 0}:${entry.mtimeMs ?? 0}`).sort().join("\n");
|
|
117
|
+
const cached = this.cache.get(key);
|
|
118
|
+
if (cached?.signature === signature) {
|
|
119
|
+
cached.touched = Date.now();
|
|
120
|
+
return cached.outline;
|
|
121
|
+
}
|
|
122
|
+
const outline = await buildProjectOutlineAsync(this.config, projectId, mainFile);
|
|
123
|
+
this.cache.set(key, { signature, outline, touched: Date.now() });
|
|
124
|
+
if (this.cache.size > 64) {
|
|
125
|
+
const oldest = [...this.cache.entries()].sort((left, right) => left[1].touched - right[1].touched)[0];
|
|
126
|
+
if (oldest)
|
|
127
|
+
this.cache.delete(oldest[0]);
|
|
128
|
+
}
|
|
129
|
+
return outline;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
37
132
|
function resolveIncludedFile(config, projectId, currentFile, value) {
|
|
38
133
|
const raw = value.trim();
|
|
39
134
|
if (!raw || /[\\#]/.test(raw))
|
|
@@ -50,6 +145,23 @@ function resolveIncludedFile(config, projectId, currentFile, value) {
|
|
|
50
145
|
}
|
|
51
146
|
return null;
|
|
52
147
|
}
|
|
148
|
+
async function resolveIncludedFileAsync(config, projectId, currentFile, value) {
|
|
149
|
+
const raw = value.trim();
|
|
150
|
+
if (!raw || /[\\#]/.test(raw))
|
|
151
|
+
return null;
|
|
152
|
+
const withExtension = path.posix.extname(raw) ? raw : `${raw}.tex`;
|
|
153
|
+
const candidates = [withExtension, path.posix.join(path.posix.dirname(currentFile), withExtension)];
|
|
154
|
+
for (const candidate of [...new Set(candidates)]) {
|
|
155
|
+
try {
|
|
156
|
+
const safe = safeRelativePath(candidate);
|
|
157
|
+
const stat = await fs.promises.stat(resolveSourcePath(config, projectId, safe));
|
|
158
|
+
if (stat.isFile())
|
|
159
|
+
return safe;
|
|
160
|
+
}
|
|
161
|
+
catch { /* Ignore includes outside the project. */ }
|
|
162
|
+
}
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
53
165
|
function balancedArgument(source, start) {
|
|
54
166
|
let depth = 1;
|
|
55
167
|
for (let index = start; index < source.length; index += 1) {
|
|
@@ -75,6 +75,12 @@ export async function replaceProject(config, projectId, options, replacement) {
|
|
|
75
75
|
const content = previous.replace(pattern, () => { count += 1; return replacement; });
|
|
76
76
|
if (!count || content === previous)
|
|
77
77
|
continue;
|
|
78
|
+
if (options.maxFileBytes !== undefined && Buffer.byteLength(content, "utf8") > options.maxFileBytes) {
|
|
79
|
+
await Promise.allSettled(staged.map((entry) => fs.promises.rm(entry.temporary, { force: true })));
|
|
80
|
+
throw Object.assign(new Error(`Replacement would exceed the ${Math.floor(options.maxFileBytes / 1024 / 1024)} MB collaborative text limit`), {
|
|
81
|
+
code: "FILE_TOO_LARGE", statusCode: 413
|
|
82
|
+
});
|
|
83
|
+
}
|
|
78
84
|
const temporary = `${absolute}.search-${process.pid}-${Date.now()}-${staged.length}.tmp`;
|
|
79
85
|
await fs.promises.writeFile(temporary, content, { encoding: "utf8", mode: 0o600 });
|
|
80
86
|
staged.push({ path: entry.path, absolute, temporary, previous, content, count });
|