@aloth/olcli 0.1.7 → 0.2.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/dist/client.js CHANGED
@@ -8,6 +8,8 @@ import * as cheerio from 'cheerio';
8
8
  import { readFileSync } from 'node:fs';
9
9
  import { dirname, join } from 'node:path';
10
10
  import { fileURLToPath } from 'node:url';
11
+ import * as https from 'node:https';
12
+ import * as http from 'node:http';
11
13
  // Read version from package.json
12
14
  const __dirname = dirname(fileURLToPath(import.meta.url));
13
15
  const pkg = JSON.parse(readFileSync(join(__dirname, '..', 'package.json'), 'utf-8'));
@@ -48,24 +50,20 @@ export class OverleafClient {
48
50
  [cookieName]: sessionCookie
49
51
  };
50
52
  // Fetch CSRF token from project page
51
- const response = await fetch(`${baseUrl}/project`, {
52
- headers: {
53
- 'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '),
54
- 'User-Agent': USER_AGENT
55
- }
53
+ const initialHeaders = {
54
+ 'Cookie': Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '),
55
+ 'User-Agent': USER_AGENT
56
+ };
57
+ const bootstrapClient = new OverleafClient({ cookies, csrf: 'bootstrap', baseUrl });
58
+ const response = await bootstrapClient.httpRequest(`${baseUrl}/project`, {
59
+ headers: initialHeaders,
60
+ expect: 'text'
56
61
  });
57
62
  if (!response.ok) {
58
- throw new Error(`Failed to fetch projects page: ${response.status} ${response.statusText}`);
59
- }
60
- // Capture any new cookies from response
61
- const setCookieHeaders = response.headers.getSetCookie?.() || [];
62
- for (const setCookie of setCookieHeaders) {
63
- const match = setCookie.match(/^([^=]+)=([^;]+)/);
64
- if (match) {
65
- cookies[match[1]] = match[2];
66
- }
63
+ throw new Error(`Failed to fetch projects page: ${response.status}`);
67
64
  }
68
- const html = await response.text();
65
+ bootstrapClient.applySetCookieHeaders(response.headers['set-cookie']);
66
+ const html = response.body;
69
67
  const $ = cheerio.load(html);
70
68
  // Try multiple methods to find CSRF token (based on PR #66, #82)
71
69
  let csrf;
@@ -90,30 +88,13 @@ export class OverleafClient {
90
88
  if (!csrf) {
91
89
  throw new Error('Could not find CSRF token. Session may have expired.');
92
90
  }
93
- return new OverleafClient({ cookies, csrf, baseUrl });
91
+ // Update cookies if the bootstrap request added anything
92
+ const updatedCookies = bootstrapClient.cookies;
93
+ return new OverleafClient({ cookies: updatedCookies, csrf, baseUrl });
94
94
  }
95
95
  getCookieHeader() {
96
96
  return Object.entries(this.cookies).map(([k, v]) => `${k}=${v}`).join('; ');
97
97
  }
98
- applySetCookieHeaders(headers) {
99
- const setCookieHeaders = headers.getSetCookie?.() || [];
100
- for (const setCookie of setCookieHeaders) {
101
- const match = setCookie.match(/^([^=]+)=([^;]+)/);
102
- if (match) {
103
- this.cookies[match[1]] = match[2];
104
- }
105
- }
106
- }
107
- async fetchWithTimeout(url, init, timeoutMs) {
108
- const controller = new AbortController();
109
- const timeout = setTimeout(() => controller.abort(), timeoutMs);
110
- try {
111
- return await fetch(url, { ...init, signal: controller.signal });
112
- }
113
- finally {
114
- clearTimeout(timeout);
115
- }
116
- }
117
98
  getHeaders(includeContentType = false) {
118
99
  const headers = {
119
100
  'Cookie': this.getCookieHeader(),
@@ -125,17 +106,110 @@ export class OverleafClient {
125
106
  }
126
107
  return headers;
127
108
  }
109
+ normalizeHeaders(headers) {
110
+ const normalized = {};
111
+ if (!headers)
112
+ return normalized;
113
+ for (const [key, value] of Object.entries(headers)) {
114
+ if (typeof value === 'string') {
115
+ normalized[key] = value;
116
+ }
117
+ }
118
+ return normalized;
119
+ }
120
+ applySetCookieHeaders(setCookie) {
121
+ if (!setCookie)
122
+ return;
123
+ for (const setCookieHeader of setCookie) {
124
+ const match = setCookieHeader.match(/^([^=]+)=([^;]+)/);
125
+ if (match) {
126
+ this.cookies[match[1]] = match[2];
127
+ }
128
+ }
129
+ }
130
+ async httpRequest(url, options = {}) {
131
+ const method = options.method || 'GET';
132
+ const timeoutMs = options.timeoutMs ?? 10000;
133
+ const maxRedirects = options.maxRedirects ?? 5;
134
+ const expect = options.expect ?? 'text';
135
+ // Normalize FormData bodies into a multipart Buffer + headers using Node's
136
+ // built-in Web Fetch primitives. Keeps every code path on httpRequest
137
+ // (no fetch() reintroduction) while properly serializing multipart uploads.
138
+ let bodyBuffer;
139
+ let extraHeaders = {};
140
+ if (options.body instanceof FormData) {
141
+ const req = new Request('http://x/', { method: 'POST', body: options.body });
142
+ const arrayBuf = await req.arrayBuffer();
143
+ bodyBuffer = Buffer.from(arrayBuf);
144
+ const ct = req.headers.get('content-type');
145
+ if (ct)
146
+ extraHeaders['Content-Type'] = ct;
147
+ extraHeaders['Content-Length'] = String(bodyBuffer.length);
148
+ }
149
+ else if (options.body !== undefined) {
150
+ bodyBuffer = options.body;
151
+ }
152
+ const doRequest = (reqUrl, redirectsLeft) => {
153
+ return new Promise((resolve, reject) => {
154
+ const parsedUrl = new URL(reqUrl);
155
+ const transport = parsedUrl.protocol === 'https:' ? https : http;
156
+ const headers = this.normalizeHeaders({ ...extraHeaders, ...options.headers });
157
+ const req = transport.request(reqUrl, { method, headers }, (res) => {
158
+ const status = res.statusCode || 0;
159
+ const resHeaders = res.headers;
160
+ if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) {
161
+ const redirectUrl = new URL(res.headers.location, reqUrl).toString();
162
+ res.resume();
163
+ doRequest(redirectUrl, redirectsLeft - 1).then(resolve, reject);
164
+ return;
165
+ }
166
+ const chunks = [];
167
+ res.on('data', (chunk) => chunks.push(chunk));
168
+ res.on('end', () => {
169
+ const buffer = Buffer.concat(chunks);
170
+ let body = buffer;
171
+ if (expect === 'text') {
172
+ body = buffer.toString('utf-8');
173
+ }
174
+ else if (expect === 'json') {
175
+ try {
176
+ body = JSON.parse(buffer.toString('utf-8'));
177
+ }
178
+ catch (e) {
179
+ return reject(new Error(`Failed to parse JSON response from ${reqUrl}`));
180
+ }
181
+ }
182
+ resolve({ status, ok: status >= 200 && status < 300, headers: resHeaders, body });
183
+ });
184
+ res.on('error', reject);
185
+ });
186
+ req.on('error', reject);
187
+ if (timeoutMs) {
188
+ req.setTimeout(timeoutMs, () => {
189
+ req.destroy(new Error(`Request timeout after ${timeoutMs}ms`));
190
+ });
191
+ }
192
+ if (bodyBuffer !== undefined) {
193
+ req.write(bodyBuffer);
194
+ }
195
+ req.end();
196
+ });
197
+ };
198
+ return doRequest(url, maxRedirects);
199
+ }
128
200
  /**
129
201
  * Get all projects (not archived, not trashed)
130
202
  */
131
203
  async listProjects() {
132
- const response = await fetch(this.projectUrl(), {
133
- headers: this.getHeaders()
204
+ const response = await this.httpRequest(this.projectUrl(), {
205
+ headers: this.getHeaders(),
206
+ expect: 'text'
134
207
  });
135
208
  if (!response.ok) {
136
209
  throw new Error(`Failed to fetch projects: ${response.status}`);
137
210
  }
138
- const html = await response.text();
211
+ this.applySetCookieHeaders(response.headers['set-cookie']);
212
+ const html = response.body;
139
213
  const $ = cheerio.load(html);
140
214
  // Try new Overleaf structure first (PR #82)
141
215
  let projectsData = [];
@@ -212,13 +286,15 @@ export class OverleafClient {
212
286
  * Get detailed project info including file tree
213
287
  */
214
288
  async getProjectInfo(projectId) {
215
- const response = await fetch(`${this.projectUrl()}/${projectId}`, {
216
- headers: this.getHeaders()
289
+ const response = await this.httpRequest(`${this.projectUrl()}/${projectId}`, {
290
+ headers: this.getHeaders(),
291
+ expect: 'text'
217
292
  });
218
293
  if (!response.ok) {
219
294
  throw new Error(`Failed to fetch project info: ${response.status}`);
220
295
  }
221
- const html = await response.text();
296
+ this.applySetCookieHeaders(response.headers['set-cookie']);
297
+ const html = response.body;
222
298
  const $ = cheerio.load(html);
223
299
  // Look for project data in meta tags
224
300
  let projectInfo;
@@ -248,11 +324,104 @@ export class OverleafClient {
248
324
  }
249
325
  }
250
326
  }
327
+ // Fallback: Overleaf no longer ships the project tree in meta tags.
328
+ // Use the Socket.IO joinProjectResponse payload (same source used for
329
+ // root folder discovery) to retrieve the full project info.
330
+ if (!projectInfo) {
331
+ const socketProject = await this.getProjectFromSocket(projectId);
332
+ if (socketProject) {
333
+ projectInfo = socketProject;
334
+ }
335
+ }
251
336
  if (!projectInfo) {
252
337
  throw new Error('Could not parse project info');
253
338
  }
254
339
  return projectInfo;
255
340
  }
341
+ /**
342
+ * Fetch the full project object via the collaboration socket.
343
+ * Returns the `project` field of the joinProjectResponse, which contains
344
+ * the rootFolder tree and other metadata that used to live in ol-project.
345
+ */
346
+ async getProjectFromSocket(projectId) {
347
+ let sid = null;
348
+ try {
349
+ const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
350
+ const handshakeResponse = await this.httpRequest(handshakeUrl, {
351
+ headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT },
352
+ expect: 'text',
353
+ timeoutMs: 5000
354
+ });
355
+ if (!handshakeResponse.ok)
356
+ return null;
357
+ this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
358
+ const handshakeBody = handshakeResponse.body.trim();
359
+ sid = handshakeBody.split(':')[0];
360
+ if (!sid)
361
+ return null;
362
+ const buildPollUrl = () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
363
+ for (let attempt = 0; attempt < 6; attempt++) {
364
+ const pollResponse = await this.httpRequest(buildPollUrl(), {
365
+ headers: { 'Cookie': this.getCookieHeader(), 'User-Agent': USER_AGENT },
366
+ expect: 'text',
367
+ timeoutMs: 5000
368
+ });
369
+ if (!pollResponse.ok)
370
+ return null;
371
+ this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
372
+ const packets = this.decodeSocketIoPayload(pollResponse.body);
373
+ for (const packet of packets) {
374
+ if (packet.startsWith('5:::')) {
375
+ try {
376
+ const payload = JSON.parse(packet.slice(4));
377
+ if (payload?.name === 'joinProjectResponse' && payload?.args?.[0]?.project) {
378
+ return payload.args[0].project;
379
+ }
380
+ }
381
+ catch { /* ignore */ }
382
+ }
383
+ if (packet.startsWith('2::')) {
384
+ const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
385
+ method: 'POST',
386
+ headers: {
387
+ 'Cookie': this.getCookieHeader(),
388
+ 'User-Agent': USER_AGENT,
389
+ 'Content-Type': 'text/plain;charset=UTF-8'
390
+ },
391
+ body: '2::',
392
+ expect: 'text',
393
+ timeoutMs: 5000
394
+ });
395
+ this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
396
+ }
397
+ }
398
+ }
399
+ }
400
+ catch {
401
+ // fall through
402
+ }
403
+ finally {
404
+ if (sid) {
405
+ try {
406
+ const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
407
+ const disconnectResponse = await this.httpRequest(disconnectUrl, {
408
+ method: 'POST',
409
+ headers: {
410
+ 'Cookie': this.getCookieHeader(),
411
+ 'User-Agent': USER_AGENT,
412
+ 'Content-Type': 'text/plain;charset=UTF-8'
413
+ },
414
+ body: '0::',
415
+ expect: 'text',
416
+ timeoutMs: 5000
417
+ });
418
+ this.applySetCookieHeaders(disconnectResponse.headers['set-cookie']);
419
+ }
420
+ catch { /* ignore */ }
421
+ }
422
+ }
423
+ return null;
424
+ }
256
425
  /**
257
426
  * Download a URL as a Buffer using Node.js http/https modules.
258
427
  *
@@ -261,34 +430,15 @@ export class OverleafClient {
261
430
  * project names). See: https://github.com/aloth/olcli/issues/2
262
431
  */
263
432
  async downloadBuffer(url) {
264
- const { default: https } = await import('node:https');
265
- const { default: http } = await import('node:http');
266
- const doRequest = (reqUrl) => {
267
- return new Promise((resolve, reject) => {
268
- const parsedUrl = new URL(reqUrl);
269
- const transport = parsedUrl.protocol === 'https:' ? https : http;
270
- const req = transport.get(reqUrl, {
271
- headers: this.getHeaders(),
272
- }, (res) => {
273
- // Follow redirects
274
- if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
275
- const redirectUrl = new URL(res.headers.location, reqUrl).toString();
276
- doRequest(redirectUrl).then(resolve, reject);
277
- return;
278
- }
279
- if (res.statusCode && res.statusCode >= 400) {
280
- reject(new Error(`Download failed: ${res.statusCode}`));
281
- return;
282
- }
283
- const chunks = [];
284
- res.on('data', (chunk) => chunks.push(chunk));
285
- res.on('end', () => resolve(Buffer.concat(chunks)));
286
- res.on('error', reject);
287
- });
288
- req.on('error', reject);
289
- });
290
- };
291
- return doRequest(url);
433
+ const response = await this.httpRequest(url, {
434
+ headers: this.getHeaders(),
435
+ expect: 'buffer'
436
+ });
437
+ if (!response.ok) {
438
+ throw new Error(`Download failed: ${response.status}`);
439
+ }
440
+ this.applySetCookieHeaders(response.headers['set-cookie']);
441
+ return response.body;
292
442
  }
293
443
  /**
294
444
  * Download project as zip
@@ -303,7 +453,7 @@ export class OverleafClient {
303
453
  * Compile project and get PDF
304
454
  */
305
455
  async compileProject(projectId) {
306
- const response = await fetch(this.compileUrl(projectId), {
456
+ const response = await this.httpRequest(this.compileUrl(projectId), {
307
457
  method: 'POST',
308
458
  headers: this.getHeaders(true),
309
459
  body: JSON.stringify({
@@ -311,12 +461,14 @@ export class OverleafClient {
311
461
  draft: false,
312
462
  check: 'silent',
313
463
  incrementalCompilesEnabled: true
314
- })
464
+ }),
465
+ expect: 'json'
315
466
  });
316
467
  if (!response.ok) {
317
468
  throw new Error(`Failed to compile project: ${response.status}`);
318
469
  }
319
- const data = await response.json();
470
+ this.applySetCookieHeaders(response.headers['set-cookie']);
471
+ const data = response.body;
320
472
  if (data.status !== 'success') {
321
473
  throw new Error(`Compilation failed: ${data.status}`);
322
474
  }
@@ -340,13 +492,14 @@ export class OverleafClient {
340
492
  * Create a folder in a project
341
493
  */
342
494
  async createFolder(projectId, parentFolderId, name) {
343
- const response = await fetch(this.folderUrl(projectId), {
495
+ const response = await this.httpRequest(this.folderUrl(projectId), {
344
496
  method: 'POST',
345
497
  headers: this.getHeaders(true),
346
498
  body: JSON.stringify({
347
499
  parent_folder_id: parentFolderId,
348
500
  name
349
- })
501
+ }),
502
+ expect: 'json'
350
503
  });
351
504
  if (response.status === 400) {
352
505
  // Folder already exists
@@ -355,7 +508,8 @@ export class OverleafClient {
355
508
  if (!response.ok) {
356
509
  throw new Error(`Failed to create folder: ${response.status}`);
357
510
  }
358
- const data = await response.json();
511
+ this.applySetCookieHeaders(response.headers['set-cookie']);
512
+ const data = response.body;
359
513
  return data._id;
360
514
  }
361
515
  /**
@@ -454,16 +608,18 @@ export class OverleafClient {
454
608
  let sid = null;
455
609
  try {
456
610
  const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
457
- const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, {
611
+ const handshakeResponse = await this.httpRequest(handshakeUrl, {
458
612
  headers: {
459
613
  'Cookie': this.getCookieHeader(),
460
614
  'User-Agent': USER_AGENT
461
- }
462
- }, 5000);
615
+ },
616
+ expect: 'text',
617
+ timeoutMs: 5000
618
+ });
463
619
  if (!handshakeResponse.ok)
464
620
  return null;
465
- this.applySetCookieHeaders(handshakeResponse.headers);
466
- const handshakeBody = (await handshakeResponse.text()).trim();
621
+ this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
622
+ const handshakeBody = handshakeResponse.body.trim();
467
623
  sid = handshakeBody.split(':')[0];
468
624
  if (!sid)
469
625
  return null;
@@ -471,16 +627,18 @@ export class OverleafClient {
471
627
  let discoveredRootFolderId = null;
472
628
  // poll a few frames, first is usually connect ack, next includes joinProjectResponse
473
629
  for (let attempt = 0; attempt < 3; attempt++) {
474
- const pollResponse = await this.fetchWithTimeout(buildPollUrl(), {
630
+ const pollResponse = await this.httpRequest(buildPollUrl(), {
475
631
  headers: {
476
632
  'Cookie': this.getCookieHeader(),
477
633
  'User-Agent': USER_AGENT
478
- }
479
- }, 5000);
634
+ },
635
+ expect: 'text',
636
+ timeoutMs: 5000
637
+ });
480
638
  if (!pollResponse.ok)
481
639
  return null;
482
- this.applySetCookieHeaders(pollResponse.headers);
483
- const payload = await pollResponse.text();
640
+ this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
641
+ const payload = pollResponse.body;
484
642
  const packets = this.decodeSocketIoPayload(payload);
485
643
  for (const packet of packets) {
486
644
  const rootFolderId = this.extractRootFolderIdFromSocketPacket(packet);
@@ -490,16 +648,18 @@ export class OverleafClient {
490
648
  }
491
649
  if (packet.startsWith('2::')) {
492
650
  //reply to heartbeat to keep polling transport alive
493
- const heartbeatResponse = await this.fetchWithTimeout(buildPollUrl(), {
651
+ const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
494
652
  method: 'POST',
495
653
  headers: {
496
654
  'Cookie': this.getCookieHeader(),
497
655
  'User-Agent': USER_AGENT,
498
656
  'Content-Type': 'text/plain;charset=UTF-8'
499
657
  },
500
- body: '2::'
501
- }, 5000);
502
- this.applySetCookieHeaders(heartbeatResponse.headers);
658
+ body: '2::',
659
+ expect: 'text',
660
+ timeoutMs: 5000
661
+ });
662
+ this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
503
663
  }
504
664
  }
505
665
  if (discoveredRootFolderId) {
@@ -514,16 +674,18 @@ export class OverleafClient {
514
674
  if (sid) {
515
675
  try {
516
676
  const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
517
- const disconnectResponse = await this.fetchWithTimeout(disconnectUrl, {
677
+ const disconnectResponse = await this.httpRequest(disconnectUrl, {
518
678
  method: 'POST',
519
679
  headers: {
520
680
  'Cookie': this.getCookieHeader(),
521
681
  'User-Agent': USER_AGENT,
522
682
  'Content-Type': 'text/plain;charset=UTF-8'
523
683
  },
524
- body: '0::'
525
- }, 5000);
526
- this.applySetCookieHeaders(disconnectResponse.headers);
684
+ body: '0::',
685
+ expect: 'text',
686
+ timeoutMs: 5000
687
+ });
688
+ this.applySetCookieHeaders(disconnectResponse.headers['set-cookie']);
527
689
  }
528
690
  catch {
529
691
  // Ignore cleanup failures.
@@ -540,47 +702,53 @@ export class OverleafClient {
540
702
  let sid = null;
541
703
  try {
542
704
  const handshakeUrl = `${this.baseUrl}/socket.io/1/?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
543
- const handshakeResponse = await this.fetchWithTimeout(handshakeUrl, {
705
+ const handshakeResponse = await this.httpRequest(handshakeUrl, {
544
706
  headers: {
545
707
  'Cookie': this.getCookieHeader(),
546
708
  'User-Agent': USER_AGENT
547
- }
548
- }, 5000);
709
+ },
710
+ expect: 'text',
711
+ timeoutMs: 5000
712
+ });
549
713
  if (!handshakeResponse.ok)
550
714
  return null;
551
- this.applySetCookieHeaders(handshakeResponse.headers);
552
- const handshakeBody = (await handshakeResponse.text()).trim();
715
+ this.applySetCookieHeaders(handshakeResponse.headers['set-cookie']);
716
+ const handshakeBody = handshakeResponse.body.trim();
553
717
  sid = handshakeBody.split(':')[0];
554
718
  if (!sid)
555
719
  return null;
556
720
  const buildPollUrl = () => `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
557
721
  for (let attempt = 0; attempt < 3; attempt++) {
558
- const pollResponse = await this.fetchWithTimeout(buildPollUrl(), {
722
+ const pollResponse = await this.httpRequest(buildPollUrl(), {
559
723
  headers: {
560
724
  'Cookie': this.getCookieHeader(),
561
725
  'User-Agent': USER_AGENT
562
- }
563
- }, 5000);
726
+ },
727
+ expect: 'text',
728
+ timeoutMs: 5000
729
+ });
564
730
  if (!pollResponse.ok)
565
731
  return null;
566
- this.applySetCookieHeaders(pollResponse.headers);
567
- const payload = await pollResponse.text();
732
+ this.applySetCookieHeaders(pollResponse.headers['set-cookie']);
733
+ const payload = pollResponse.body;
568
734
  const packets = this.decodeSocketIoPayload(payload);
569
735
  for (const packet of packets) {
570
736
  const folderTree = this.extractFolderTreeFromSocketPacket(packet);
571
737
  if (folderTree)
572
738
  return folderTree;
573
739
  if (packet.startsWith('2::')) {
574
- const heartbeatResponse = await this.fetchWithTimeout(buildPollUrl(), {
740
+ const heartbeatResponse = await this.httpRequest(buildPollUrl(), {
575
741
  method: 'POST',
576
742
  headers: {
577
743
  'Cookie': this.getCookieHeader(),
578
744
  'User-Agent': USER_AGENT,
579
745
  'Content-Type': 'text/plain;charset=UTF-8'
580
746
  },
581
- body: '2::'
582
- }, 5000);
583
- this.applySetCookieHeaders(heartbeatResponse.headers);
747
+ body: '2::',
748
+ expect: 'text',
749
+ timeoutMs: 5000
750
+ });
751
+ this.applySetCookieHeaders(heartbeatResponse.headers['set-cookie']);
584
752
  }
585
753
  }
586
754
  }
@@ -592,15 +760,17 @@ export class OverleafClient {
592
760
  if (sid) {
593
761
  try {
594
762
  const disconnectUrl = `${this.baseUrl}/socket.io/1/xhr-polling/${sid}?projectId=${encodeURIComponent(projectId)}&t=${Date.now()}`;
595
- await this.fetchWithTimeout(disconnectUrl, {
763
+ await this.httpRequest(disconnectUrl, {
596
764
  method: 'POST',
597
765
  headers: {
598
766
  'Cookie': this.getCookieHeader(),
599
767
  'User-Agent': USER_AGENT,
600
768
  'Content-Type': 'text/plain;charset=UTF-8'
601
769
  },
602
- body: '0::'
603
- }, 5000);
770
+ body: '0::',
771
+ expect: 'text',
772
+ timeoutMs: 5000
773
+ });
604
774
  }
605
775
  catch {
606
776
  // Ignore cleanup failures.
@@ -704,16 +874,21 @@ export class OverleafClient {
704
874
  formData.append('name', testFileName);
705
875
  formData.append('type', 'text/plain');
706
876
  formData.append('qqfile', new Blob(['probe']), testFileName);
707
- const response = await fetch(`${this.uploadUrl(projectId)}?folder_id=${folderId}`, {
877
+ const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${folderId}`, {
708
878
  method: 'POST',
709
879
  headers: {
710
880
  'Cookie': this.getCookieHeader(),
711
881
  'User-Agent': USER_AGENT,
712
882
  'X-Csrf-Token': this.csrf
713
883
  },
714
- body: formData
884
+ body: formData,
885
+ expect: 'json'
715
886
  });
716
- const data = await response.json();
887
+ if (!response.ok) {
888
+ continue;
889
+ }
890
+ this.applySetCookieHeaders(response.headers['set-cookie']);
891
+ const data = response.body;
717
892
  if (data.success !== false && data.entity_id) {
718
893
  // Success! Delete the probe file and return this folder ID
719
894
  try {
@@ -771,17 +946,18 @@ export class OverleafClient {
771
946
  formData.append('name', baseName);
772
947
  formData.append('type', mimeType);
773
948
  formData.append('qqfile', new Blob([content]), baseName);
774
- const response = await fetch(`${this.uploadUrl(projectId)}?folder_id=${encodeURIComponent(fid)}`, {
949
+ const response = await this.httpRequest(`${this.uploadUrl(projectId)}?folder_id=${encodeURIComponent(fid)}`, {
775
950
  method: 'POST',
776
951
  headers: {
777
952
  'Cookie': this.getCookieHeader(),
778
953
  'User-Agent': USER_AGENT,
779
954
  'X-Csrf-Token': this.csrf
780
955
  },
781
- body: formData
956
+ body: formData,
957
+ expect: 'text'
782
958
  });
783
959
  if (!response.ok) {
784
- const text = await response.text();
960
+ const text = response.body;
785
961
  // Overleaf returns folder_not_found as HTTP 422 JSON.
786
962
  // Parse the body first so caller can trigger folder probing fallback.
787
963
  try {
@@ -795,7 +971,8 @@ export class OverleafClient {
795
971
  }
796
972
  return { success: false, error: `${response.status} - ${text}` };
797
973
  }
798
- const data = await response.json();
974
+ this.applySetCookieHeaders(response.headers['set-cookie']);
975
+ const data = JSON.parse(response.body);
799
976
  if (data.success === false && data.error === 'folder_not_found') {
800
977
  return { success: false, error: 'folder_not_found' };
801
978
  }
@@ -837,25 +1014,29 @@ export class OverleafClient {
837
1014
  */
838
1015
  async deleteEntity(projectId, entityId, entityType) {
839
1016
  const url = this.deleteUrl(projectId, entityType, entityId);
840
- const response = await fetch(url, {
1017
+ const response = await this.httpRequest(url, {
841
1018
  method: 'DELETE',
842
- headers: this.getHeaders()
1019
+ headers: this.getHeaders(),
1020
+ expect: 'text'
843
1021
  });
844
1022
  if (!response.ok) {
845
1023
  throw new Error(`Failed to delete entity: ${response.status}`);
846
1024
  }
1025
+ this.applySetCookieHeaders(response.headers['set-cookie']);
847
1026
  }
848
1027
  /**
849
1028
  * Get list of entities (files/docs) with paths
850
1029
  */
851
1030
  async getEntities(projectId) {
852
- const response = await fetch(`${this.baseUrl}/project/${projectId}/entities`, {
853
- headers: this.getHeaders()
1031
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/entities`, {
1032
+ headers: this.getHeaders(),
1033
+ expect: 'json'
854
1034
  });
855
1035
  if (!response.ok) {
856
1036
  throw new Error(`Failed to get entities: ${response.status}`);
857
1037
  }
858
- const data = await response.json();
1038
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1039
+ const data = response.body;
859
1040
  return data.entities || [];
860
1041
  }
861
1042
  /**
@@ -901,35 +1082,38 @@ export class OverleafClient {
901
1082
  */
902
1083
  async downloadFile(projectId, fileId, fileType) {
903
1084
  const endpoint = fileType === 'doc' ? 'doc' : 'file';
904
- const response = await fetch(`${this.baseUrl}/project/${projectId}/${endpoint}/${fileId}`, {
905
- headers: this.getHeaders()
1085
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${endpoint}/${fileId}`, {
1086
+ headers: this.getHeaders(),
1087
+ expect: fileType === 'doc' ? 'json' : 'buffer'
906
1088
  });
907
1089
  if (!response.ok) {
908
1090
  throw new Error(`Failed to download file: ${response.status}`);
909
1091
  }
1092
+ this.applySetCookieHeaders(response.headers['set-cookie']);
910
1093
  if (fileType === 'doc') {
911
1094
  // Docs return JSON with lines array
912
- const data = await response.json();
1095
+ const data = response.body;
913
1096
  const content = (data.lines || []).join('\n');
914
1097
  return Buffer.from(content, 'utf-8');
915
1098
  }
916
1099
  else {
917
- const arrayBuffer = await response.arrayBuffer();
918
- return Buffer.from(arrayBuffer);
1100
+ return response.body;
919
1101
  }
920
1102
  }
921
1103
  /**
922
1104
  * Rename a file, doc, or folder
923
1105
  */
924
1106
  async renameEntity(projectId, entityId, entityType, newName) {
925
- const response = await fetch(`${this.baseUrl}/project/${projectId}/${entityType}/${entityId}/rename`, {
1107
+ const response = await this.httpRequest(`${this.baseUrl}/project/${projectId}/${entityType}/${entityId}/rename`, {
926
1108
  method: 'POST',
927
1109
  headers: this.getHeaders(true),
928
- body: JSON.stringify({ name: newName })
1110
+ body: JSON.stringify({ name: newName }),
1111
+ expect: 'text'
929
1112
  });
930
1113
  if (!response.ok) {
931
1114
  throw new Error(`Failed to rename entity: ${response.status}`);
932
1115
  }
1116
+ this.applySetCookieHeaders(response.headers['set-cookie']);
933
1117
  }
934
1118
  /**
935
1119
  * Delete a file by path
@@ -988,7 +1172,7 @@ export class OverleafClient {
988
1172
  * Compile project and get all output files
989
1173
  */
990
1174
  async compileWithOutputs(projectId) {
991
- const response = await fetch(this.compileUrl(projectId), {
1175
+ const response = await this.httpRequest(this.compileUrl(projectId), {
992
1176
  method: 'POST',
993
1177
  headers: this.getHeaders(true),
994
1178
  body: JSON.stringify({
@@ -996,12 +1180,14 @@ export class OverleafClient {
996
1180
  draft: false,
997
1181
  check: 'silent',
998
1182
  incrementalCompilesEnabled: true
999
- })
1183
+ }),
1184
+ expect: 'json'
1000
1185
  });
1001
1186
  if (!response.ok) {
1002
1187
  throw new Error(`Failed to compile project: ${response.status}`);
1003
1188
  }
1004
- const data = await response.json();
1189
+ this.applySetCookieHeaders(response.headers['set-cookie']);
1190
+ const data = response.body;
1005
1191
  const pdfFile = data.outputFiles?.find((f) => f.type === 'pdf');
1006
1192
  return {
1007
1193
  status: data.status,