@netique/overleaf-mcp 0.1.0
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/LICENSE +693 -0
- package/README.md +152 -0
- package/dist/api/commentTypes.js +5 -0
- package/dist/api/commentTypes.js.map +1 -0
- package/dist/api/compileTypes.js +2 -0
- package/dist/api/compileTypes.js.map +1 -0
- package/dist/api/errors.js +17 -0
- package/dist/api/errors.js.map +1 -0
- package/dist/api/http.js +52 -0
- package/dist/api/http.js.map +1 -0
- package/dist/api/projectTypes.js +38 -0
- package/dist/api/projectTypes.js.map +1 -0
- package/dist/api/socket.js +348 -0
- package/dist/api/socket.js.map +1 -0
- package/dist/api/types.js +18 -0
- package/dist/api/types.js.map +1 -0
- package/dist/config.js +13 -0
- package/dist/config.js.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/ot/diff.js +32 -0
- package/dist/ot/diff.js.map +1 -0
- package/dist/ot/trackedChanges.js +19 -0
- package/dist/ot/trackedChanges.js.map +1 -0
- package/dist/session/activeProject.js +45 -0
- package/dist/session/activeProject.js.map +1 -0
- package/dist/session/docCache.js +21 -0
- package/dist/session/docCache.js.map +1 -0
- package/dist/session/identity.js +59 -0
- package/dist/session/identity.js.map +1 -0
- package/dist/tools/comments.js +195 -0
- package/dist/tools/comments.js.map +1 -0
- package/dist/tools/compile.js +188 -0
- package/dist/tools/compile.js.map +1 -0
- package/dist/tools/editFile.js +123 -0
- package/dist/tools/editFile.js.map +1 -0
- package/dist/tools/listFiles.js +42 -0
- package/dist/tools/listFiles.js.map +1 -0
- package/dist/tools/listProjects.js +88 -0
- package/dist/tools/listProjects.js.map +1 -0
- package/dist/tools/openProject.js +58 -0
- package/dist/tools/openProject.js.map +1 -0
- package/dist/tools/readFile.js +133 -0
- package/dist/tools/readFile.js.map +1 -0
- package/dist/tools/trackedChanges.js +260 -0
- package/dist/tools/trackedChanges.js.map +1 -0
- package/dist/util/logger.js +18 -0
- package/dist/util/logger.js.map +1 -0
- package/package.json +60 -0
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# overleaf-mcp
|
|
2
|
+
|
|
3
|
+
An MCP server for [Overleaf](https://www.overleaf.com) that lets a Claude or other agent navigate projects, read/edit `.tex` files, compile, and work with review-panel comments — over Overleaf's **real web/Socket.IO API**, the same channel the official web editor uses.
|
|
4
|
+
|
|
5
|
+
The one feature no existing Overleaf MCP can deliver: when a project has **track-changes** enabled, the agent's edits appear as **pending suggestions in the Review panel**, the same way a human collaborator's edits do. You and your collaborators can accept or reject each suggestion. You can also ask the agend to accept/reject them (e.g. *"accept all suggestions about typos"*).
|
|
6
|
+
|
|
7
|
+
## Why a new MCP
|
|
8
|
+
|
|
9
|
+
The three existing Overleaf MCPs ([mjyoo2/overleafmcp](https://github.com/mjyoo2/overleafmcp), [YounesBensafia/overleaf-mcp-server](https://github.com/YounesBensafia/overleaf-mcp-server), [GhoshSrinjoy/Overleaf-mcp](https://github.com/GhoshSrinjoy/Overleaf-mcp)) all write through Overleaf's **Git bridge**, which has two problems for collaborative academic work:
|
|
10
|
+
|
|
11
|
+
1. Commits show up in Overleaf with delay (the bridge polls).
|
|
12
|
+
2. Git-bridge writes **bypass tracked changes entirely** — even when track-changes mode is on, edits land as direct overwrites, not as suggestions for review.
|
|
13
|
+
|
|
14
|
+
The [`overleaf-workshop`](https://github.com/overleaf-workshop/overleaf-workshop) VSCode extension already uses Overleaf's Socket.IO API rather than Git, but doesn't yet emit tracked changes ([issue #94](https://github.com/overleaf-workshop/overleaf-workshop/issues/94)).
|
|
15
|
+
|
|
16
|
+
`overleaf-mcp` solves both: a minimal Socket.IO 0.9 client over `fetch` + `ws@8`, plus the `meta.tc` ID seed on `applyOtUpdate` that flips Overleaf's server-side `RangesTracker` into track-changes mode.
|
|
17
|
+
|
|
18
|
+
## Status
|
|
19
|
+
|
|
20
|
+
Working end-to-end against `overleaf.com` — 16 tools, tracked-changes edits and review-panel comments both verified. Published on npm as [`@netique/overleaf-mcp`](https://www.npmjs.com/package/@netique/overleaf-mcp).
|
|
21
|
+
|
|
22
|
+
## Tools
|
|
23
|
+
|
|
24
|
+
| Tool | Description |
|
|
25
|
+
|---|---|
|
|
26
|
+
| `ping` | Health check. Does not contact Overleaf. |
|
|
27
|
+
| `list_projects` | Lists projects on the configured account, sorted by most recently updated. Supports `name_contains`, `include_archived`, `include_trashed`, `limit`. |
|
|
28
|
+
| `open_project` | Joins a project's real-time session and caches its file tree. Returns rich metadata: `root_doc_path`, `compiler`, `spell_check_language`, `public_access_level`, owner + members (with privileges), and whether track-changes is on for your user. |
|
|
29
|
+
| `list_files` | Lists the file tree of the open project (cached, no network). Filter by `kind` and `path_contains`. |
|
|
30
|
+
| `read_file` | Reads a doc (returns text + OT version + a summary of tracked changes / comments) or a binary file (base64 + MIME). `path` is optional — defaults to the project's root doc. |
|
|
31
|
+
| `edit_file` | Replaces a doc's contents. Computes a minimal diff via `diff-match-patch`, submits it as an OT operation, and adds `meta.tc` so the edit lands as a pending suggestion in the Review panel by default. Pass `track: "off"` to write directly or `track: "auto"` to honor the project's track-changes setting. `path` is optional — defaults to the project's root doc. |
|
|
32
|
+
| `list_tracked_changes` | Enumerates every pending tracked-change suggestion across the open project, with author name + email, doc path, op kind (insert/delete), position, op text, change_id. Filter by `author_email`, `author_id_endswith`, `path_contains`, `kind`, `text_contains`, `limit`. |
|
|
33
|
+
| `accept_changes` | Accepts one or more tracked changes by `change_id` (from `list_tracked_changes`). Multi-doc groups are batched automatically. Irreversible. |
|
|
34
|
+
| `reject_changes` | Rejects one or more tracked changes by `change_id`. Implemented as `applyOtUpdate` with the inverse op + `u:true` (same pathway Overleaf's web client uses). Irreversible. |
|
|
35
|
+
| `compile` | Triggers an Overleaf compile and returns a unified summary: `status`, `built_cleanly` (true iff PDF + zero LaTeX errors), `error_count`, `warning_count`, `first_errors` (sample), `output_files`, timings. Already fetches and parses `output.log` inline — no extra `read_log` call needed for the happy path. Pass `root_doc`, `draft`, `stop_on_first_error` to control. |
|
|
36
|
+
| `read_log` | Returns the full `output.log` from the most recent compile, with `!`-prefixed error lines surfaced at the top. Use when `compile`'s inline summary isn't enough context. |
|
|
37
|
+
| `list_comments` | Lists review-panel comment threads with doc path, quoted text, author, latest-message preview. Supports `include_resolved`, `path_contains`, `full`. |
|
|
38
|
+
| `read_comment_thread` | Returns the full message history of one thread. |
|
|
39
|
+
| `reply_comment` | Posts a new message to an existing thread. |
|
|
40
|
+
| `resolve_comment` | Marks a thread resolved. |
|
|
41
|
+
| `reopen_comment` | Reopens a resolved thread. |
|
|
42
|
+
|
|
43
|
+
## Typical workflow
|
|
44
|
+
|
|
45
|
+
Things to ask Claude once `overleaf-mcp` is connected:
|
|
46
|
+
|
|
47
|
+
- _"Accept every pending tracked change by John Doe that's only adjusting punctuation or whitespace."_ — uses `list_tracked_changes(author_email: "...")` → LLM filters by op text → `accept_changes(...)`.
|
|
48
|
+
- _"List my recent Overleaf projects."_
|
|
49
|
+
- _"Open my thesis project and show me what comments my collaborators have left."_
|
|
50
|
+
- _"Read intro.tex and fix the missing comma in the second paragraph."_ → with track-changes on, this lands as a tracked suggestion.
|
|
51
|
+
- _"Compile the project and tell me what the LaTeX errors mean."_ → uses `compile` then `read_log` automatically.
|
|
52
|
+
- _"For each open comment thread, suggest a fix and reply with what you did."_ → end-to-end review workflow.
|
|
53
|
+
|
|
54
|
+
## Requirements
|
|
55
|
+
|
|
56
|
+
- Node ≥ 20
|
|
57
|
+
- An Overleaf account (overleaf.com or self-hosted Community Edition)
|
|
58
|
+
|
|
59
|
+
## Quick start
|
|
60
|
+
|
|
61
|
+
No local install needed — `npx` fetches and runs the latest version. Add this to your Claude Desktop / Claude Code MCP config:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"mcpServers": {
|
|
66
|
+
"overleaf": {
|
|
67
|
+
"command": "npx",
|
|
68
|
+
"args": ["-y", "@netique/overleaf-mcp"],
|
|
69
|
+
"env": {
|
|
70
|
+
"OL_BASE_URL": "https://www.overleaf.com",
|
|
71
|
+
"OL_COOKIE": "overleaf_session2=s%3A...."
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
For self-hosted Community Edition: set `OL_BASE_URL` to your server (e.g. `https://overleaf.mylab.edu`). Same cookie capture, same tools.
|
|
79
|
+
|
|
80
|
+
<details>
|
|
81
|
+
<summary>From source (for development)</summary>
|
|
82
|
+
|
|
83
|
+
```sh
|
|
84
|
+
git clone https://github.com/netique/overleaf-mcp.git
|
|
85
|
+
cd overleaf-mcp
|
|
86
|
+
npm install
|
|
87
|
+
npm run build
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Then point your MCP config at the built file:
|
|
91
|
+
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"mcpServers": {
|
|
95
|
+
"overleaf": {
|
|
96
|
+
"command": "node",
|
|
97
|
+
"args": ["/absolute/path/to/overleaf-mcp/dist/index.js"],
|
|
98
|
+
"env": { "OL_COOKIE": "overleaf_session2=s%3A...." }
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
</details>
|
|
104
|
+
|
|
105
|
+
## Authentication
|
|
106
|
+
|
|
107
|
+
overleaf-mcp authenticates with a session cookie pasted from your browser. The CSRF token is auto-discovered from the `/project` page after login, so you don't need to copy it separately. (Set `OL_CSRF` only if your Overleaf instance doesn't expose the `ol-csrfToken` meta tag.)
|
|
108
|
+
|
|
109
|
+
### Capturing the cookie
|
|
110
|
+
|
|
111
|
+
1. Log into Overleaf in your browser.
|
|
112
|
+
2. Open DevTools → **Application** (Chrome/Edge) or **Storage** (Firefox) → **Cookies** → `https://www.overleaf.com`.
|
|
113
|
+
3. Copy the **value** of `overleaf_session2` — it starts with `s%3A` and is long. That's your `OL_COOKIE` (format: `overleaf_session2=s%3A...`).
|
|
114
|
+
|
|
115
|
+
> ⚠️ The pasted session cookie grants full account access. Treat it like a password — do not commit it, share it, or paste it into shared configs. Cookies expire periodically; if you see auth errors, re-copy.
|
|
116
|
+
|
|
117
|
+
### Environment variables
|
|
118
|
+
|
|
119
|
+
| Var | Required | Default | Notes |
|
|
120
|
+
|---|---|---|---|
|
|
121
|
+
| `OL_COOKIE` | yes | — | Session cookie, see above. |
|
|
122
|
+
| `OL_BASE_URL` | no | `https://www.overleaf.com` | Override for self-hosted Overleaf. |
|
|
123
|
+
| `OL_CSRF` | no | auto-discovered | Force a specific CSRF token. Only needed if your server doesn't ship the `ol-csrfToken` meta tag. |
|
|
124
|
+
| `OL_MCP_LOG_LEVEL` | no | `info` | `debug`, `info`, `warn`, `error`. Goes to stderr; stdout is reserved for MCP JSON-RPC. |
|
|
125
|
+
|
|
126
|
+
## Troubleshooting
|
|
127
|
+
|
|
128
|
+
**`OverleafAuthError: Session cookie rejected (redirected to /login)`** — your `OL_COOKIE` has expired. Re-copy `overleaf_session2` from DevTools.
|
|
129
|
+
|
|
130
|
+
**`Socket.IO handshake returned 502`** — Overleaf's load balancer rejected the WebSocket upgrade. Almost always means the cookie was rejected. Same fix as above.
|
|
131
|
+
|
|
132
|
+
**`Could not find ol-csrfToken meta tag`** — your Overleaf server doesn't expose the CSRF meta tag (rare; mostly very old Community Edition). Set `OL_CSRF` explicitly.
|
|
133
|
+
|
|
134
|
+
**Edits land but don't show up as tracked suggestions** — confirm track-changes is on for *your user* on this project (Menu → Settings → Track Changes → "For me" or "For everyone"). `open_project` reports the detected state under `track_changes_on_for_me`. To force tracking regardless, pass `track: "on"` to `edit_file`.
|
|
135
|
+
|
|
136
|
+
**Compile succeeds but `read_log` returns 404** — Overleaf needs `?clsiserverid=...` to route to the right CLSI worker; we add this automatically from the previous compile response. If you see this, the previous compile may not have completed; re-run `compile` and then `read_log`.
|
|
137
|
+
|
|
138
|
+
## Acknowledgements
|
|
139
|
+
|
|
140
|
+
- [`overleaf-workshop`](https://github.com/overleaf-workshop/overleaf-workshop) by @iamhyc and contributors — protocol reference for the HTTP + Socket.IO flow, comment thread endpoints. The `94-review-panel` branch was the source for the comment data shapes.
|
|
141
|
+
- [`overleaf/overleaf`](https://github.com/overleaf/overleaf) — `libraries/ranges-tracker/index.cjs` and `services/document-updater/RangesManager.js` are the authoritative source for how tracked changes are emitted (the `update.meta.tc` flag and ID seed format).
|
|
142
|
+
- [`googlecolab/colab-mcp`](https://github.com/googlecolab/colab-mcp) — UX reference for what an agent-friendly MCP into a hosted editor should feel like.
|
|
143
|
+
|
|
144
|
+
## License
|
|
145
|
+
|
|
146
|
+
**AGPL-3.0-or-later** — see [`LICENSE`](./LICENSE).
|
|
147
|
+
|
|
148
|
+
overleaf-mcp incorporates code ported from two AGPL-3.0 projects (overleaf-workshop and overleaf/overleaf — see Acknowledgements), so the combined work is distributed under the same terms. Practical implications:
|
|
149
|
+
|
|
150
|
+
- You can use, study, and modify overleaf-mcp freely.
|
|
151
|
+
- If you redistribute it, modified or not, recipients must also receive the source under AGPL-3.0.
|
|
152
|
+
- If you run a **modified** version as a network service that users interact with, you must make the modified source available to those users. Running unmodified overleaf-mcp as your own personal MCP server is unaffected.
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Review-panel comment threads, ported from the overleaf-workshop 94-review-panel
|
|
2
|
+
// branch (extendedBase.ts). Shapes match what Overleaf's HTTP REST API actually
|
|
3
|
+
// returns; we keep them loose because field set varies a bit by server version.
|
|
4
|
+
export {};
|
|
5
|
+
//# sourceMappingURL=commentTypes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"commentTypes.js","sourceRoot":"","sources":["../../src/api/commentTypes.ts"],"names":[],"mappings":"AAAA,kFAAkF;AAClF,gFAAgF;AAChF,gFAAgF"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compileTypes.js","sourceRoot":"","sources":["../../src/api/compileTypes.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export class OverleafAuthError extends Error {
|
|
2
|
+
constructor(message) {
|
|
3
|
+
super(message);
|
|
4
|
+
this.name = "OverleafAuthError";
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
export class OverleafApiError extends Error {
|
|
8
|
+
status;
|
|
9
|
+
body;
|
|
10
|
+
constructor(status, body, hint) {
|
|
11
|
+
super(`Overleaf API error ${status}${hint ? ` (${hint})` : ""}: ${body.slice(0, 200)}`);
|
|
12
|
+
this.name = "OverleafApiError";
|
|
13
|
+
this.status = status;
|
|
14
|
+
this.body = body;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../../src/api/errors.ts"],"names":[],"mappings":"AAAA,MAAM,OAAO,iBAAkB,SAAQ,KAAK;IAC1C,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAC;QACf,IAAI,CAAC,IAAI,GAAG,mBAAmB,CAAC;IAClC,CAAC;CACF;AAED,MAAM,OAAO,gBAAiB,SAAQ,KAAK;IAChC,MAAM,CAAS;IACf,IAAI,CAAS;IACtB,YAAY,MAAc,EAAE,IAAY,EAAE,IAAa;QACrD,KAAK,CAAC,sBAAsB,MAAM,GAAG,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,GAAG,CAAC,CAAC,CAAC,EAAE,KAAK,IAAI,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC,CAAC;QACxF,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAC;QAC/B,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACnB,CAAC;CACF"}
|
package/dist/api/http.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { getIdentity } from "../session/identity.js";
|
|
2
|
+
import { OverleafApiError } from "./errors.js";
|
|
3
|
+
function joinUrl(base, path) {
|
|
4
|
+
return `${base}/${path.replace(/^\/+/, "")}`;
|
|
5
|
+
}
|
|
6
|
+
export async function olGet(path, extraHeaders = {}) {
|
|
7
|
+
const id = await getIdentity();
|
|
8
|
+
return fetch(joinUrl(id.baseUrl, path), {
|
|
9
|
+
method: "GET",
|
|
10
|
+
redirect: "manual",
|
|
11
|
+
headers: { Cookie: id.cookie, Connection: "keep-alive", ...extraHeaders },
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
export async function olPostJson(path, body = {}, extraHeaders = {}) {
|
|
15
|
+
const id = await getIdentity();
|
|
16
|
+
return fetch(joinUrl(id.baseUrl, path), {
|
|
17
|
+
method: "POST",
|
|
18
|
+
redirect: "manual",
|
|
19
|
+
headers: {
|
|
20
|
+
Cookie: id.cookie,
|
|
21
|
+
Connection: "keep-alive",
|
|
22
|
+
"Content-Type": "application/json",
|
|
23
|
+
"X-Csrf-Token": id.csrf,
|
|
24
|
+
...extraHeaders,
|
|
25
|
+
},
|
|
26
|
+
body: JSON.stringify({ _csrf: id.csrf, ...body }),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
export async function olDelete(path, extraHeaders = {}) {
|
|
30
|
+
const id = await getIdentity();
|
|
31
|
+
return fetch(joinUrl(id.baseUrl, path), {
|
|
32
|
+
method: "DELETE",
|
|
33
|
+
redirect: "manual",
|
|
34
|
+
headers: {
|
|
35
|
+
Cookie: id.cookie,
|
|
36
|
+
Connection: "keep-alive",
|
|
37
|
+
"X-Csrf-Token": id.csrf,
|
|
38
|
+
...extraHeaders,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
export async function expectOk(res, hint) {
|
|
43
|
+
if (res.ok)
|
|
44
|
+
return res;
|
|
45
|
+
const body = await res.text().catch(() => "");
|
|
46
|
+
throw new OverleafApiError(res.status, body, hint);
|
|
47
|
+
}
|
|
48
|
+
export async function asJson(res, hint) {
|
|
49
|
+
await expectOk(res, hint);
|
|
50
|
+
return (await res.json());
|
|
51
|
+
}
|
|
52
|
+
//# sourceMappingURL=http.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.js","sourceRoot":"","sources":["../../src/api/http.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AACrD,OAAO,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAC;AAE/C,SAAS,OAAO,CAAC,IAAY,EAAE,IAAY;IACzC,OAAO,GAAG,IAAI,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,EAAE,EAAE,CAAC,EAAE,CAAC;AAC/C,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,KAAK,CAAC,IAAY,EAAE,eAAuC,EAAE;IACjF,MAAM,EAAE,GAAG,MAAM,WAAW,EAAE,CAAC;IAC/B,OAAO,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE;QACtC,MAAM,EAAE,KAAK;QACb,QAAQ,EAAE,QAAQ;QAClB,OAAO,EAAE,EAAE,MAAM,EAAE,EAAE,CAAC,MAAM,EAAE,UAAU,EAAE,YAAY,EAAE,GAAG,YAAY,EAAE;KAC1E,CAAC,CAAC;AACL,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,UAAU,CAC9B,IAAY,EACZ,OAAgC,EAAE,EAClC,eAAuC,EAAE;IAEzC,MAAM,EAAE,GAAG,MAAM,WAAW,EAAE,CAAC;IAC/B,OAAO,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE;QACtC,MAAM,EAAE,MAAM;QACd,QAAQ,EAAE,QAAQ;QAClB,OAAO,EAAE;YACP,MAAM,EAAE,EAAE,CAAC,MAAM;YACjB,UAAU,EAAE,YAAY;YACxB,cAAc,EAAE,kBAAkB;YAClC,cAAc,EAAE,EAAE,CAAC,IAAI;YACvB,GAAG,YAAY;SAChB;QACD,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,EAAE,KAAK,EAAE,EAAE,CAAC,IAAI,EAAE,GAAG,IAAI,EAAE,CAAC;KAClD,CAAC,CAAC;AACL,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,IAAY,EAAE,eAAuC,EAAE;IACpF,MAAM,EAAE,GAAG,MAAM,WAAW,EAAE,CAAC;IAC/B,OAAO,KAAK,CAAC,OAAO,CAAC,EAAE,CAAC,OAAO,EAAE,IAAI,CAAC,EAAE;QACtC,MAAM,EAAE,QAAQ;QAChB,QAAQ,EAAE,QAAQ;QAClB,OAAO,EAAE;YACP,MAAM,EAAE,EAAE,CAAC,MAAM;YACjB,UAAU,EAAE,YAAY;YACxB,cAAc,EAAE,EAAE,CAAC,IAAI;YACvB,GAAG,YAAY;SAChB;KACF,CAAC,CAAC;AACL,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,GAAa,EAAE,IAAa;IACzD,IAAI,GAAG,CAAC,EAAE;QAAE,OAAO,GAAG,CAAC;IACvB,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,EAAE,CAAC,CAAC;IAC9C,MAAM,IAAI,gBAAgB,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,EAAE,IAAI,CAAC,CAAC;AACrD,CAAC;AAED,MAAM,CAAC,KAAK,UAAU,MAAM,CAAc,GAAa,EAAE,IAAa;IACpE,MAAM,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;IAC1B,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,EAAE,CAAM,CAAC;AACjC,CAAC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Shapes returned by Overleaf's joinProject / joinDoc Socket.IO events.
|
|
2
|
+
// Field names match what the server emits — keep raw, normalize in tools.
|
|
3
|
+
export function flattenTree(root, prefix = "") {
|
|
4
|
+
const out = [];
|
|
5
|
+
for (const folder of root.folders) {
|
|
6
|
+
const path = prefix ? `${prefix}/${folder.name}` : folder.name;
|
|
7
|
+
out.push({ kind: "folder", id: folder._id, path, name: folder.name, parentFolderId: root._id });
|
|
8
|
+
out.push(...flattenTree(folder, path));
|
|
9
|
+
}
|
|
10
|
+
for (const doc of root.docs) {
|
|
11
|
+
const path = prefix ? `${prefix}/${doc.name}` : doc.name;
|
|
12
|
+
out.push({ kind: "doc", id: doc._id, path, name: doc.name, parentFolderId: root._id });
|
|
13
|
+
}
|
|
14
|
+
for (const file of root.fileRefs) {
|
|
15
|
+
const path = prefix ? `${prefix}/${file.name}` : file.name;
|
|
16
|
+
out.push({ kind: "file", id: file._id, path, name: file.name, parentFolderId: root._id });
|
|
17
|
+
}
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
export function isTrackChangesOnForUser(project, userId) {
|
|
21
|
+
const state = project.track_changes_state ?? project.trackChangesState;
|
|
22
|
+
if (state === undefined || state === null)
|
|
23
|
+
return false;
|
|
24
|
+
if (typeof state === "boolean")
|
|
25
|
+
return state;
|
|
26
|
+
if (typeof state === "object") {
|
|
27
|
+
const m = state;
|
|
28
|
+
if (m[userId] === true)
|
|
29
|
+
return true;
|
|
30
|
+
if (m["__guests__"] === true)
|
|
31
|
+
return true;
|
|
32
|
+
// Some Overleaf builds use the value true on key '__everyone__' or 'all'.
|
|
33
|
+
if (m["__everyone__"] === true)
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=projectTypes.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"projectTypes.js","sourceRoot":"","sources":["../../src/api/projectTypes.ts"],"names":[],"mappings":"AAAA,wEAAwE;AACxE,0EAA0E;AA6D1E,MAAM,UAAU,WAAW,CAAC,IAAkB,EAAE,MAAM,GAAG,EAAE;IACzD,MAAM,GAAG,GAAiB,EAAE,CAAC;IAC7B,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,OAAO,EAAE,CAAC;QAClC,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,MAAM,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,MAAM,CAAC,IAAI,CAAC;QAC/D,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,QAAQ,EAAE,EAAE,EAAE,MAAM,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,EAAE,cAAc,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;QAChG,GAAG,CAAC,IAAI,CAAC,GAAG,WAAW,CAAC,MAAM,EAAE,IAAI,CAAC,CAAC,CAAC;IACzC,CAAC;IACD,KAAK,MAAM,GAAG,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC;QAC5B,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,GAAG,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,GAAG,CAAC,IAAI,CAAC;QACzD,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,KAAK,EAAE,EAAE,EAAE,GAAG,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,IAAI,EAAE,cAAc,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IACzF,CAAC;IACD,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,QAAQ,EAAE,CAAC;QACjC,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,GAAG,MAAM,IAAI,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC;QAC3D,GAAG,CAAC,IAAI,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,IAAI,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,IAAI,CAAC,IAAI,EAAE,cAAc,EAAE,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAC5F,CAAC;IACD,OAAO,GAAG,CAAC;AACb,CAAC;AAED,MAAM,UAAU,uBAAuB,CAAC,OAAsB,EAAE,MAAc;IAC5E,MAAM,KAAK,GAAG,OAAO,CAAC,mBAAmB,IAAI,OAAO,CAAC,iBAAiB,CAAC;IACvE,IAAI,KAAK,KAAK,SAAS,IAAI,KAAK,KAAK,IAAI;QAAE,OAAO,KAAK,CAAC;IACxD,IAAI,OAAO,KAAK,KAAK,SAAS;QAAE,OAAO,KAAK,CAAC;IAC7C,IAAI,OAAO,KAAK,KAAK,QAAQ,EAAE,CAAC;QAC9B,MAAM,CAAC,GAAG,KAAgC,CAAC;QAC3C,IAAI,CAAC,CAAC,MAAM,CAAC,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QACpC,IAAI,CAAC,CAAC,YAAY,CAAC,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;QAC1C,0EAA0E;QAC1E,IAAI,CAAC,CAAC,cAAc,CAAC,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC;IAC9C,CAAC;IACD,OAAO,KAAK,CAAC;AACf,CAAC"}
|
|
@@ -0,0 +1,348 @@
|
|
|
1
|
+
// Minimal Socket.IO 0.9 client tailored for Overleaf.
|
|
2
|
+
//
|
|
3
|
+
// Overleaf's bundled `socket.io-client@0.9.17-overleaf-N` fork accepts an
|
|
4
|
+
// `extraHeaders` option but never forwards it to either transport — XMLHttpRequest
|
|
5
|
+
// forbids the `Cookie` header and the WebSocket transport calls
|
|
6
|
+
// `new WebSocket(url)` with no header argument. That makes the fork unusable
|
|
7
|
+
// against overleaf.com from a server-side caller. We instead speak the wire
|
|
8
|
+
// protocol ourselves: handshake via `fetch` (cookies propagate naturally),
|
|
9
|
+
// upgrade via `ws@8` (which supports a `headers` option).
|
|
10
|
+
//
|
|
11
|
+
// Protocol reference: https://github.com/learnboost/socket.io-spec (v0.9.x)
|
|
12
|
+
// Frame format: `<type>:<id>:<endpoint>:<data>`
|
|
13
|
+
// 0 disconnect | 1 connect | 2 heartbeat | 3 message | 4 json
|
|
14
|
+
// 5 event { name, args } | 6 ack `:::<id>+<json>` | 7 error
|
|
15
|
+
//
|
|
16
|
+
// We only implement the subset Overleaf actually emits.
|
|
17
|
+
import WebSocket from "ws";
|
|
18
|
+
import { OverleafApiError, OverleafAuthError } from "./errors.js";
|
|
19
|
+
import { getIdentity } from "../session/identity.js";
|
|
20
|
+
import { logger } from "../util/logger.js";
|
|
21
|
+
function decodePackedUtf8(line) {
|
|
22
|
+
return Buffer.from(line, "latin1").toString("utf8");
|
|
23
|
+
}
|
|
24
|
+
class OverleafSocket {
|
|
25
|
+
projectId;
|
|
26
|
+
identity;
|
|
27
|
+
ws = null;
|
|
28
|
+
nextAckId = 1;
|
|
29
|
+
pending = new Map();
|
|
30
|
+
listeners = new Map();
|
|
31
|
+
heartbeatInterval = 60_000;
|
|
32
|
+
heartbeatTimer = null;
|
|
33
|
+
closed = false;
|
|
34
|
+
joinedProject = null;
|
|
35
|
+
publicId = null;
|
|
36
|
+
permissionsLevel = null;
|
|
37
|
+
protocolVersion = null;
|
|
38
|
+
constructor(projectId, identity) {
|
|
39
|
+
this.projectId = projectId;
|
|
40
|
+
this.identity = identity;
|
|
41
|
+
}
|
|
42
|
+
async connect(timeoutMs = 15_000) {
|
|
43
|
+
const base = this.identity.baseUrl;
|
|
44
|
+
const t = Date.now();
|
|
45
|
+
const hsUrl = `${base}/socket.io/1/?projectId=${encodeURIComponent(this.projectId)}&t=${t}`;
|
|
46
|
+
const hsRes = await fetch(hsUrl, {
|
|
47
|
+
method: "GET",
|
|
48
|
+
redirect: "manual",
|
|
49
|
+
headers: {
|
|
50
|
+
Cookie: this.identity.cookie,
|
|
51
|
+
Origin: new URL(base).origin,
|
|
52
|
+
Connection: "keep-alive",
|
|
53
|
+
},
|
|
54
|
+
});
|
|
55
|
+
if (hsRes.status !== 200) {
|
|
56
|
+
const body = await hsRes.text().catch(() => "");
|
|
57
|
+
throw new OverleafAuthError(`Socket.IO handshake returned ${hsRes.status}: ${body.slice(0, 200)}`);
|
|
58
|
+
}
|
|
59
|
+
const hsBody = await hsRes.text();
|
|
60
|
+
const [sid, hbStr, , transports] = hsBody.split(":");
|
|
61
|
+
if (!sid || !transports?.includes("websocket")) {
|
|
62
|
+
throw new OverleafApiError(0, hsBody, "handshake response did not include a sid or websocket transport");
|
|
63
|
+
}
|
|
64
|
+
this.heartbeatInterval = Math.max(15_000, (Number(hbStr) || 60) * 1000 - 5_000);
|
|
65
|
+
const wsUrl = `${base.replace(/^http/, "ws")}/socket.io/1/websocket/${sid}`;
|
|
66
|
+
const ws = new WebSocket(wsUrl, {
|
|
67
|
+
headers: { Cookie: this.identity.cookie, Origin: new URL(base).origin },
|
|
68
|
+
handshakeTimeout: timeoutMs,
|
|
69
|
+
});
|
|
70
|
+
this.ws = ws;
|
|
71
|
+
await new Promise((resolve, reject) => {
|
|
72
|
+
const settleTimer = setTimeout(() => reject(new OverleafApiError(0, "", `WebSocket upgrade did not complete within ${timeoutMs}ms`)), timeoutMs);
|
|
73
|
+
ws.once("open", () => {
|
|
74
|
+
clearTimeout(settleTimer);
|
|
75
|
+
logger.info(`socket.io connected (sid=${sid})`);
|
|
76
|
+
this.startHeartbeats();
|
|
77
|
+
// joinProjectResponse arrives auto-magically on v2-style URL.
|
|
78
|
+
const once = (args) => {
|
|
79
|
+
const payload = args[0];
|
|
80
|
+
if (payload) {
|
|
81
|
+
this.joinedProject = payload.project ?? null;
|
|
82
|
+
this.publicId = payload.publicId ?? null;
|
|
83
|
+
this.permissionsLevel = payload.permissionsLevel ?? null;
|
|
84
|
+
this.protocolVersion = payload.protocolVersion ?? null;
|
|
85
|
+
}
|
|
86
|
+
resolve();
|
|
87
|
+
};
|
|
88
|
+
this.once("joinProjectResponse", once);
|
|
89
|
+
// Some servers (older / v1 path) won't emit joinProjectResponse on the
|
|
90
|
+
// initial WS connect; fall back to emitting joinProject explicitly.
|
|
91
|
+
setTimeout(() => {
|
|
92
|
+
if (!this.joinedProject) {
|
|
93
|
+
logger.info("no joinProjectResponse received, falling back to explicit joinProject emit");
|
|
94
|
+
this.emit("joinProject", [{ project_id: this.projectId }])
|
|
95
|
+
.then((ret) => {
|
|
96
|
+
const tuple = Array.isArray(ret) ? ret : [ret];
|
|
97
|
+
const [project, perm, proto] = tuple;
|
|
98
|
+
this.joinedProject = project ?? null;
|
|
99
|
+
this.permissionsLevel = perm ?? null;
|
|
100
|
+
this.protocolVersion = typeof proto === "number" ? proto : null;
|
|
101
|
+
resolve();
|
|
102
|
+
})
|
|
103
|
+
.catch(reject);
|
|
104
|
+
}
|
|
105
|
+
}, 3_000);
|
|
106
|
+
});
|
|
107
|
+
ws.once("error", (err) => {
|
|
108
|
+
clearTimeout(settleTimer);
|
|
109
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
110
|
+
});
|
|
111
|
+
ws.on("message", (data) => this.handleFrame(data.toString("utf8")));
|
|
112
|
+
ws.once("close", (code, reason) => {
|
|
113
|
+
this.stopHeartbeats();
|
|
114
|
+
this.closed = true;
|
|
115
|
+
const r = reason?.toString?.() ?? "";
|
|
116
|
+
logger.warn(`socket closed code=${code} reason=${r}`);
|
|
117
|
+
for (const [, p] of this.pending) {
|
|
118
|
+
clearTimeout(p.timer);
|
|
119
|
+
p.reject(new Error(`socket closed (${code}) ${r}`));
|
|
120
|
+
}
|
|
121
|
+
this.pending.clear();
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
startHeartbeats() {
|
|
126
|
+
this.heartbeatTimer = setInterval(() => {
|
|
127
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
128
|
+
try {
|
|
129
|
+
this.ws.send("2::");
|
|
130
|
+
}
|
|
131
|
+
catch { /* ignore */ }
|
|
132
|
+
}
|
|
133
|
+
}, this.heartbeatInterval);
|
|
134
|
+
}
|
|
135
|
+
stopHeartbeats() {
|
|
136
|
+
if (this.heartbeatTimer)
|
|
137
|
+
clearInterval(this.heartbeatTimer);
|
|
138
|
+
this.heartbeatTimer = null;
|
|
139
|
+
}
|
|
140
|
+
handleFrame(frame) {
|
|
141
|
+
if (!frame)
|
|
142
|
+
return;
|
|
143
|
+
// Parse `<type>:<id>:<endpoint>:<data>`. Data may contain colons, so split
|
|
144
|
+
// only on the first three.
|
|
145
|
+
const m = frame.match(/^(\d+):([^:]*):([^:]*):?([\s\S]*)$/);
|
|
146
|
+
if (!m) {
|
|
147
|
+
logger.debug("ignoring unparseable frame", frame.slice(0, 120));
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const type = m[1];
|
|
151
|
+
const id = m[2];
|
|
152
|
+
const data = m[4];
|
|
153
|
+
switch (type) {
|
|
154
|
+
case "0": // disconnect
|
|
155
|
+
logger.warn("server sent disconnect frame");
|
|
156
|
+
try {
|
|
157
|
+
this.ws?.close();
|
|
158
|
+
}
|
|
159
|
+
catch { /* ignore */ }
|
|
160
|
+
return;
|
|
161
|
+
case "1": // connect ack — usually with empty endpoint
|
|
162
|
+
return;
|
|
163
|
+
case "2": // heartbeat from server, echo back
|
|
164
|
+
try {
|
|
165
|
+
this.ws?.send("2::");
|
|
166
|
+
}
|
|
167
|
+
catch { /* ignore */ }
|
|
168
|
+
return;
|
|
169
|
+
case "5": {
|
|
170
|
+
// Event: `5:<id>[+]::{"name":"event","args":[...]}`
|
|
171
|
+
let obj = {};
|
|
172
|
+
try {
|
|
173
|
+
obj = JSON.parse(data);
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const name = obj.name;
|
|
179
|
+
if (!name)
|
|
180
|
+
return;
|
|
181
|
+
const args = obj.args ?? [];
|
|
182
|
+
const ls = this.listeners.get(name);
|
|
183
|
+
if (ls)
|
|
184
|
+
for (const l of ls)
|
|
185
|
+
try {
|
|
186
|
+
l(args);
|
|
187
|
+
}
|
|
188
|
+
catch (e) {
|
|
189
|
+
logger.error(`listener for ${name} threw`, e);
|
|
190
|
+
}
|
|
191
|
+
// Track-changes / reciveNewDoc / etc may also acknowledge with msg id;
|
|
192
|
+
// we ignore that for now since Overleaf doesn't appear to expect a
|
|
193
|
+
// response from us for server-emitted events.
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
case "6": {
|
|
197
|
+
// ACK: `6:::<id>[+<data_json>]`. Note: the "id" field above will be
|
|
198
|
+
// empty for an ack frame; the ack id is at the start of `data`.
|
|
199
|
+
const plus = data.indexOf("+");
|
|
200
|
+
const ackIdStr = plus >= 0 ? data.slice(0, plus) : data;
|
|
201
|
+
const ackDataRaw = plus >= 0 ? data.slice(plus + 1) : "";
|
|
202
|
+
const ackId = Number(ackIdStr);
|
|
203
|
+
const pending = this.pending.get(ackId);
|
|
204
|
+
if (!pending)
|
|
205
|
+
return;
|
|
206
|
+
this.pending.delete(ackId);
|
|
207
|
+
clearTimeout(pending.timer);
|
|
208
|
+
let arr = [];
|
|
209
|
+
if (ackDataRaw) {
|
|
210
|
+
try {
|
|
211
|
+
arr = JSON.parse(ackDataRaw);
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
arr = [ackDataRaw];
|
|
215
|
+
}
|
|
216
|
+
if (!Array.isArray(arr))
|
|
217
|
+
arr = [arr];
|
|
218
|
+
}
|
|
219
|
+
// Overleaf's ack convention: first element is the error (null on
|
|
220
|
+
// success); remaining elements are the result.
|
|
221
|
+
const err = arr[0];
|
|
222
|
+
if (err)
|
|
223
|
+
pending.reject(err instanceof Error ? err : new Error(typeof err === "string" ? err : JSON.stringify(err)));
|
|
224
|
+
else
|
|
225
|
+
pending.resolve(arr.slice(1));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
case "7": {
|
|
229
|
+
// Error
|
|
230
|
+
logger.error("server error frame", data);
|
|
231
|
+
for (const [, p] of this.pending) {
|
|
232
|
+
clearTimeout(p.timer);
|
|
233
|
+
p.reject(new Error(`server error: ${data}`));
|
|
234
|
+
}
|
|
235
|
+
this.pending.clear();
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
default:
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
on(event, listener) {
|
|
243
|
+
const arr = this.listeners.get(event) ?? [];
|
|
244
|
+
arr.push(listener);
|
|
245
|
+
this.listeners.set(event, arr);
|
|
246
|
+
}
|
|
247
|
+
once(event, listener) {
|
|
248
|
+
const wrap = (args) => {
|
|
249
|
+
this.off(event, wrap);
|
|
250
|
+
listener(args);
|
|
251
|
+
};
|
|
252
|
+
this.on(event, wrap);
|
|
253
|
+
}
|
|
254
|
+
off(event, listener) {
|
|
255
|
+
const arr = this.listeners.get(event);
|
|
256
|
+
if (!arr)
|
|
257
|
+
return;
|
|
258
|
+
const i = arr.indexOf(listener);
|
|
259
|
+
if (i >= 0)
|
|
260
|
+
arr.splice(i, 1);
|
|
261
|
+
}
|
|
262
|
+
async emit(name, args = [], timeoutMs = 15_000) {
|
|
263
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
264
|
+
throw new OverleafApiError(0, "", "socket not open");
|
|
265
|
+
}
|
|
266
|
+
const ackId = this.nextAckId++;
|
|
267
|
+
const frame = `5:${ackId}+::${JSON.stringify({ name, args })}`;
|
|
268
|
+
return await new Promise((resolve, reject) => {
|
|
269
|
+
const timer = setTimeout(() => {
|
|
270
|
+
this.pending.delete(ackId);
|
|
271
|
+
reject(new Error(`event '${name}' timed out after ${timeoutMs}ms`));
|
|
272
|
+
}, timeoutMs);
|
|
273
|
+
this.pending.set(ackId, {
|
|
274
|
+
resolve: (data) => resolve((data.length <= 1 ? data[0] : data)),
|
|
275
|
+
reject,
|
|
276
|
+
timer,
|
|
277
|
+
});
|
|
278
|
+
try {
|
|
279
|
+
this.ws.send(frame);
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
clearTimeout(timer);
|
|
283
|
+
this.pending.delete(ackId);
|
|
284
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
isOpen() {
|
|
289
|
+
return !this.closed && this.ws?.readyState === WebSocket.OPEN;
|
|
290
|
+
}
|
|
291
|
+
disconnect() {
|
|
292
|
+
this.stopHeartbeats();
|
|
293
|
+
if (this.ws) {
|
|
294
|
+
try {
|
|
295
|
+
this.ws.close();
|
|
296
|
+
}
|
|
297
|
+
catch { /* ignore */ }
|
|
298
|
+
this.ws = null;
|
|
299
|
+
}
|
|
300
|
+
this.closed = true;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
let active = null;
|
|
304
|
+
export async function ensureSocketForProject(projectId) {
|
|
305
|
+
if (active && active.projectId === projectId && active.isOpen()) {
|
|
306
|
+
return { socket: active, publicId: active.publicId ?? undefined, joinedProject: active.joinedProject ?? undefined };
|
|
307
|
+
}
|
|
308
|
+
if (active) {
|
|
309
|
+
logger.info(`switching project: ${active.projectId} -> ${projectId}`);
|
|
310
|
+
active.disconnect();
|
|
311
|
+
active = null;
|
|
312
|
+
}
|
|
313
|
+
const identity = await getIdentity();
|
|
314
|
+
const s = new OverleafSocket(projectId, identity);
|
|
315
|
+
await s.connect();
|
|
316
|
+
active = s;
|
|
317
|
+
return { socket: s, publicId: s.publicId ?? undefined, joinedProject: s.joinedProject ?? undefined };
|
|
318
|
+
}
|
|
319
|
+
export function getActiveSocket() {
|
|
320
|
+
return active;
|
|
321
|
+
}
|
|
322
|
+
export async function joinDoc(docId) {
|
|
323
|
+
if (!active)
|
|
324
|
+
throw new OverleafApiError(0, "", "no active project — call open_project first");
|
|
325
|
+
// The ack returns `[docLinesAscii, version, updates, ranges]`.
|
|
326
|
+
const ret = await active.emit("joinDoc", [docId, { encodeRanges: true }]);
|
|
327
|
+
const tuple = Array.isArray(ret) ? ret : [ret];
|
|
328
|
+
const [docLinesAscii, version, updates, ranges] = tuple;
|
|
329
|
+
const docLines = (docLinesAscii ?? []).map(decodePackedUtf8);
|
|
330
|
+
return { docLines, version: version ?? 0, updates: updates ?? [], ranges };
|
|
331
|
+
}
|
|
332
|
+
export async function leaveDoc(docId) {
|
|
333
|
+
if (!active)
|
|
334
|
+
return;
|
|
335
|
+
await active.emit("leaveDoc", [docId]).catch(() => undefined);
|
|
336
|
+
}
|
|
337
|
+
export async function applyOtUpdate(docId, update) {
|
|
338
|
+
if (!active)
|
|
339
|
+
throw new OverleafApiError(0, "", "no active project — call open_project first");
|
|
340
|
+
await active.emit("applyOtUpdate", [docId, update]);
|
|
341
|
+
}
|
|
342
|
+
export function disconnectActive() {
|
|
343
|
+
if (active) {
|
|
344
|
+
active.disconnect();
|
|
345
|
+
active = null;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
//# sourceMappingURL=socket.js.map
|