leafrelay 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -160,6 +160,7 @@ var OverleafRealtimeSocket = class {
160
160
  reconnectAttempts = 0;
161
161
  reconnectTimer;
162
162
  inactivityTimer;
163
+ connectionAbort;
163
164
  closeTimeoutMs = 6e4;
164
165
  acknowledgementId = 0;
165
166
  acknowledgements = /* @__PURE__ */ new Map();
@@ -225,6 +226,12 @@ var OverleafRealtimeSocket = class {
225
226
  return this;
226
227
  }
227
228
  async connect() {
229
+ if (this.explicitlyDisconnected) {
230
+ return;
231
+ }
232
+ const controller = new AbortController();
233
+ this.connectionAbort?.abort();
234
+ this.connectionAbort = controller;
228
235
  try {
229
236
  this.projectJoined = false;
230
237
  const handshakeUrl = new URL("/socket.io/1/", this.endpoint.origin);
@@ -234,7 +241,8 @@ var OverleafRealtimeSocket = class {
234
241
  handshakeUrl.searchParams.set("t", String(Date.now()));
235
242
  const response = await (this.options.fetch ?? fetch)(handshakeUrl, {
236
243
  headers: { origin: this.origin, cookie: this.cookie },
237
- redirect: "manual"
244
+ redirect: "manual",
245
+ signal: AbortSignal.any([controller.signal, AbortSignal.timeout(15e3)])
238
246
  });
239
247
  if (!response.ok) {
240
248
  throw new Error(`Socket.IO handshake failed: HTTP ${response.status} ${await response.text()}`);
@@ -249,22 +257,50 @@ var OverleafRealtimeSocket = class {
249
257
  const websocketUrl = new URL(`/socket.io/1/websocket/${sessionId}`, this.endpoint.origin);
250
258
  websocketUrl.protocol = websocketUrl.protocol === "https:" ? "wss:" : "ws:";
251
259
  websocketUrl.search = this.endpoint.search;
252
- this.socket = new WebSocket(websocketUrl, {
260
+ if (controller.signal.aborted) {
261
+ return;
262
+ }
263
+ const socket = new WebSocket(websocketUrl, {
253
264
  headers: { origin: this.origin, cookie: this.cookie }
254
265
  });
255
- this.socket.addEventListener("open", () => this.events.emit("connecting", "websocket"));
256
- this.socket.addEventListener("message", (event) => void this.receive(event.data));
257
- this.socket.addEventListener("error", (error2) => this.events.emit("error", error2));
258
- this.socket.addEventListener("close", (event) => this.handleClose(event));
266
+ this.socket = socket;
267
+ socket.addEventListener("open", () => {
268
+ if (this.socket !== socket) {
269
+ return;
270
+ }
271
+ this.resetInactivityTimer();
272
+ this.events.emit("connecting", "websocket");
273
+ });
274
+ socket.addEventListener("message", (event) => {
275
+ if (this.socket === socket) {
276
+ void this.receive(event.data, socket);
277
+ }
278
+ });
279
+ socket.addEventListener("error", (error2) => {
280
+ if (this.socket === socket) {
281
+ this.events.emit("error", error2);
282
+ }
283
+ });
284
+ socket.addEventListener("close", (event) => {
285
+ if (this.socket === socket) {
286
+ this.handleClose(event);
287
+ }
288
+ });
259
289
  } catch (error2) {
290
+ if (controller.signal.aborted) {
291
+ return;
292
+ }
260
293
  this.events.emit("connect_failed", error2);
261
294
  this.events.emit("error", error2);
262
295
  this.scheduleReconnect();
263
296
  }
264
297
  }
265
- async receive(data) {
298
+ async receive(data, socket) {
266
299
  this.resetInactivityTimer();
267
300
  const value = typeof data === "string" ? data : data instanceof Blob ? await data.text() : Buffer.from(data).toString("utf8");
301
+ if (this.socket !== socket) {
302
+ return;
303
+ }
268
304
  for (const packet of decodePayload(value)) {
269
305
  this.handlePacket(packet);
270
306
  }
@@ -339,6 +375,7 @@ var OverleafRealtimeSocket = class {
339
375
  }
340
376
  handleClose(event) {
341
377
  const wasConnected = this.connected;
378
+ this.socket = void 0;
342
379
  this.connected = false;
343
380
  this.projectJoined = false;
344
381
  if (this.inactivityTimer) {
@@ -353,6 +390,8 @@ var OverleafRealtimeSocket = class {
353
390
  this.scheduleReconnect();
354
391
  }
355
392
  closeSocket(reason) {
393
+ this.connectionAbort?.abort();
394
+ this.connectionAbort = void 0;
356
395
  const wasConnected = this.connected;
357
396
  this.connected = false;
358
397
  this.projectJoined = false;
@@ -381,7 +420,7 @@ var OverleafRealtimeSocket = class {
381
420
  if (!this.reconnectEnabled || this.explicitlyDisconnected || this.reconnectTimer) {
382
421
  return;
383
422
  }
384
- const maxAttempts = this.options.maxReconnectionAttempts ?? 10;
423
+ const maxAttempts = this.options.maxReconnectionAttempts ?? Infinity;
385
424
  if (this.reconnectAttempts >= maxAttempts) {
386
425
  this.events.emit("reconnect_failed");
387
426
  return;
@@ -398,6 +437,9 @@ var OverleafRealtimeSocket = class {
398
437
  }
399
438
  };
400
439
  var sink;
440
+ function setLogSink(nextSink) {
441
+ sink = nextSink;
442
+ }
401
443
  function write(level, args) {
402
444
  if (sink !== void 0) {
403
445
  sink(level, args);
@@ -445,7 +487,7 @@ var HttpRequestScheduler = class {
445
487
  fetchImplementation;
446
488
  now;
447
489
  wait;
448
- fetch(input, init) {
490
+ fetch(input, init, timeoutMs = this.options.requestTimeoutMs ?? 6e4) {
449
491
  const url = input instanceof Request ? input.url : String(input);
450
492
  const history = url.includes("/updates?") || url.includes("/filetree/diff?") || url.includes("/diff?");
451
493
  return this.enqueue(async () => {
@@ -459,7 +501,20 @@ var HttpRequestScheduler = class {
459
501
  if (history) {
460
502
  this.historyNextRequestAt = requestAt + this.historyIntervalMs;
461
503
  }
462
- const response = await this.fetchImplementation(input, init);
504
+ const timeout = AbortSignal.timeout(timeoutMs);
505
+ const signals = [timeout, init?.signal, input instanceof Request ? input.signal : void 0].filter((signal) => Boolean(signal));
506
+ let response;
507
+ try {
508
+ response = await this.fetchImplementation(input, { ...init, signal: AbortSignal.any(signals) });
509
+ } catch (error2) {
510
+ const destination = new URL(url);
511
+ error("HTTP request failed", {
512
+ method: init?.method ?? (input instanceof Request ? input.method : "GET"),
513
+ url: `${destination.origin}${destination.pathname}`,
514
+ error: error2
515
+ });
516
+ throw error2;
517
+ }
463
518
  if (response.status === 429) {
464
519
  this.rateLimitedUntil = Math.max(
465
520
  this.rateLimitedUntil,
@@ -655,7 +710,7 @@ var BaseAPI = class {
655
710
  }
656
711
  return false;
657
712
  }
658
- async request(type, route, body, callback, extraHeaders) {
713
+ async request(type, route, body, callback, extraHeaders, timeoutMs) {
659
714
  if (this.identity === void 0) {
660
715
  return Promise.reject();
661
716
  }
@@ -674,7 +729,7 @@ var BaseAPI = class {
674
729
  "Cookie": this.identity.cookies,
675
730
  ...extraHeaders
676
731
  }
677
- });
732
+ }, timeoutMs);
678
733
  break;
679
734
  case "POST":
680
735
  const content_type = body instanceof FormData ? void 0 : { "Content-Type": "application/json" };
@@ -692,7 +747,7 @@ var BaseAPI = class {
692
747
  ...extraHeaders
693
748
  },
694
749
  body: raw_body
695
- });
750
+ }, timeoutMs);
696
751
  break;
697
752
  case "PUT":
698
753
  break;
@@ -706,7 +761,7 @@ var BaseAPI = class {
706
761
  "X-Csrf-Token": this.identity.csrfToken,
707
762
  ...extraHeaders
708
763
  }
709
- });
764
+ }, timeoutMs);
710
765
  break;
711
766
  }
712
767
  ;
@@ -956,7 +1011,7 @@ var BaseAPI = class {
956
1011
  return this.request("POST", `project/${projectId}/compile?auto_compile=true`, body, (res) => {
957
1012
  const compile = JSON.parse(res);
958
1013
  return { compile };
959
- }, { "X-Csrf-Token": identity.csrfToken });
1014
+ }, { "X-Csrf-Token": identity.csrfToken }, 3e5);
960
1015
  }
961
1016
  async stopCompile(identity, projectId) {
962
1017
  this.setIdentity(identity);
@@ -2394,18 +2449,30 @@ var SyncEngine = class {
2394
2449
  stopRemoteReconnect;
2395
2450
  eventTimers = /* @__PURE__ */ new Map();
2396
2451
  stopped = false;
2452
+ failedPaths = /* @__PURE__ */ new Map();
2453
+ retryTimer;
2454
+ retryDelayMs = 3e4;
2455
+ pendingRemoteVersion;
2456
+ pendingInitialization = false;
2457
+ get syncError() {
2458
+ if (this.failedPaths.size === 0) {
2459
+ return void 0;
2460
+ }
2461
+ const [path, failure] = this.failedPaths.entries().next().value;
2462
+ return `${this.failedPaths.size} path(s) could not synchronize, including ${path}: ${failure.message}. Other paths remain active; failed paths will retry automatically.`;
2463
+ }
2397
2464
  async start() {
2398
2465
  this.stopped = false;
2399
- this.state = await this.options.stateStore.load();
2400
- const currentVersion = await this.options.remote.getCurrentVersion();
2401
- const localFiles = await this.options.local.listFiles();
2402
- const remoteFiles = this.remoteFilePaths();
2403
- if (!this.state || !this.state.initialized) {
2404
- await this.initializeState(currentVersion, localFiles, remoteFiles);
2405
- } else {
2406
- this.restoreConflicts();
2407
- await this.startupReconcile(currentVersion, localFiles, remoteFiles);
2466
+ const startup = this.enqueue(() => this.initialize());
2467
+ try {
2468
+ await startup;
2469
+ } catch (error2) {
2470
+ await this.stop();
2471
+ throw error2;
2408
2472
  }
2473
+ }
2474
+ async initialize() {
2475
+ this.state = await this.options.stateStore.load();
2409
2476
  this.stopLocalWatch = await this.options.local.watch((path) => this.schedule(path, "local"));
2410
2477
  this.stopRemoteChange = this.options.remote.onChange((change) => {
2411
2478
  if (this.stopped) {
@@ -2424,10 +2491,24 @@ var SyncEngine = class {
2424
2491
  this.options.log(`Failed to reconcile after reconnecting: ${error2 instanceof Error ? error2.message : String(error2)}`);
2425
2492
  });
2426
2493
  });
2427
- this.options.log(`Synchronization active at Overleaf version ${this.state.remoteVersion}.`);
2494
+ const currentVersion = await this.options.remote.getCurrentVersion();
2495
+ const localFiles = await this.options.local.listFiles();
2496
+ const remoteFiles = this.remoteFilePaths();
2497
+ this.options.log(`Checking replica at Overleaf version ${currentVersion}: ${localFiles.length} local file(s), ${remoteFiles.length} remote file(s).`);
2498
+ if (!this.state || !this.state.initialized) {
2499
+ await this.initializeState(currentVersion, localFiles, remoteFiles);
2500
+ } else {
2501
+ this.restoreConflicts();
2502
+ await this.startupReconcile(currentVersion, localFiles, remoteFiles);
2503
+ }
2504
+ this.options.log(this.syncError ? `Synchronization active with pending retries. ${this.syncError}` : `Synchronization active at Overleaf version ${this.state.remoteVersion}.`);
2428
2505
  }
2429
2506
  async stop() {
2430
2507
  this.stopped = true;
2508
+ if (this.retryTimer) {
2509
+ clearTimeout(this.retryTimer);
2510
+ this.retryTimer = void 0;
2511
+ }
2431
2512
  this.stopRemoteChange?.();
2432
2513
  this.stopRemoteChange = void 0;
2433
2514
  this.stopRemoteReconnect?.();
@@ -2501,21 +2582,25 @@ var SyncEngine = class {
2501
2582
  await this.options.stateStore.save(this.state);
2502
2583
  }
2503
2584
  this.restoreConflicts();
2585
+ this.pendingRemoteVersion = currentVersion;
2586
+ this.pendingInitialization = true;
2504
2587
  const paths = /* @__PURE__ */ new Set([...localFiles, ...remoteFiles, ...Object.keys(this.state.files)]);
2505
2588
  for (const path of [...paths].sort()) {
2506
2589
  const normalized = normalizePath2(path);
2507
2590
  if (this.conflicts.has(normalized)) {
2508
2591
  continue;
2509
2592
  }
2510
- if (this.state.files[normalized] !== void 0) {
2511
- await this.reconcilePath(normalized, "local");
2512
- continue;
2593
+ try {
2594
+ if (this.state.files[normalized] !== void 0) {
2595
+ await this.synchronizePath(normalized, "local");
2596
+ } else {
2597
+ await this.initializePath(normalized);
2598
+ }
2599
+ } catch (error2) {
2600
+ this.recordFailure(normalized, "local", error2);
2513
2601
  }
2514
- await this.initializePath(normalized);
2515
2602
  }
2516
- this.state.remoteVersion = currentVersion;
2517
- this.state.initialized = true;
2518
- await this.options.stateStore.save(this.state);
2603
+ await this.finishRemoteBatch();
2519
2604
  if (this.conflicts.size !== 0) {
2520
2605
  this.options.log(`Synchronization remains active; ${this.conflicts.size} conflicting path(s) are paused.`);
2521
2606
  }
@@ -2588,7 +2673,7 @@ var SyncEngine = class {
2588
2673
  throw new Error("Synchronization paused: Overleaf history is unavailable, so remote changes cannot be distinguished safely.");
2589
2674
  }
2590
2675
  }
2591
- const candidates = new Set(localChanged);
2676
+ const candidates = /* @__PURE__ */ new Set([...localChanged, ...this.failedPaths.keys()]);
2592
2677
  const knownFiles = /* @__PURE__ */ new Set([...Object.keys(state.files), ...localFiles, ...remoteFiles]);
2593
2678
  for (const path of [...localFiles, ...remoteFiles]) {
2594
2679
  if (state.files[path] === void 0) {
@@ -2596,6 +2681,9 @@ var SyncEngine = class {
2596
2681
  }
2597
2682
  }
2598
2683
  for (const change of remoteDiff.diff) {
2684
+ if (!change.operation) {
2685
+ continue;
2686
+ }
2599
2687
  const paths = [change.pathname, change.newPathname].filter((path) => Boolean(path));
2600
2688
  for (const changedPath of paths.map(normalizePath2)) {
2601
2689
  let matched = false;
@@ -2610,20 +2698,19 @@ var SyncEngine = class {
2610
2698
  }
2611
2699
  }
2612
2700
  }
2613
- let failed = false;
2701
+ this.pendingRemoteVersion = currentVersion;
2702
+ this.options.log(`Reconciling ${candidates.size} changed path(s).`);
2614
2703
  for (const path of [...candidates].sort()) {
2704
+ if (this.stopped) {
2705
+ return;
2706
+ }
2615
2707
  try {
2616
- await this.reconcilePath(path, localChanged.has(path) ? "local" : "remote");
2708
+ await this.synchronizePath(path, localChanged.has(path) ? "local" : "remote");
2617
2709
  } catch (error2) {
2618
- failed = true;
2619
- this.options.log(`Failed to synchronize ${path}: ${error2 instanceof Error ? error2.message : String(error2)}`);
2710
+ this.recordFailure(path, localChanged.has(path) ? "local" : "remote", error2);
2620
2711
  }
2621
2712
  }
2622
- if (failed) {
2623
- throw new Error("Synchronization paused for failed paths; successful paths were checkpointed.");
2624
- }
2625
- state.remoteVersion = currentVersion;
2626
- await this.options.stateStore.save(state);
2713
+ await this.finishRemoteBatch();
2627
2714
  if (this.conflicts.size !== 0) {
2628
2715
  this.options.log(`Synchronization remains active; ${this.conflicts.size} conflicting path(s) are paused.`);
2629
2716
  }
@@ -2638,7 +2725,7 @@ var SyncEngine = class {
2638
2725
  await this.options.local.listFiles(),
2639
2726
  this.remoteFilePaths()
2640
2727
  );
2641
- this.options.log(`Realtime connection restored at Overleaf version ${currentVersion}.`);
2728
+ this.options.log(this.syncError ? `Realtime connection restored; ${this.syncError}` : `Realtime connection restored at Overleaf version ${currentVersion}.`);
2642
2729
  }
2643
2730
  schedule(path, source, actor) {
2644
2731
  const normalized = normalizePath2(path);
@@ -2668,13 +2755,72 @@ var SyncEngine = class {
2668
2755
  return;
2669
2756
  }
2670
2757
  const remoteActor = pending.unknownRemoteActor ? void 0 : pending.actors.size === 1 ? [...pending.actors][0] : pending.actors.size > 1 ? "multiple Overleaf collaborators" : void 0;
2671
- this.queue = this.queue.then(() => this.reconcilePath(normalized, pending.source, remoteActor)).catch((error2) => {
2672
- this.options.log(`Failed to synchronize ${normalized}: ${error2 instanceof Error ? error2.message : String(error2)}`);
2758
+ this.queue = this.queue.then(() => this.synchronizePath(normalized, pending.source, remoteActor)).catch((error2) => {
2759
+ this.recordFailure(normalized, pending.source, error2, remoteActor);
2673
2760
  });
2674
2761
  }, 250)
2675
2762
  };
2676
2763
  this.eventTimers.set(normalized, pending);
2677
2764
  }
2765
+ async synchronizePath(path, source, actor) {
2766
+ if (this.pendingInitialization && this.state.files[path] === void 0) {
2767
+ await this.initializePath(path);
2768
+ } else {
2769
+ await this.reconcilePath(path, source, actor);
2770
+ }
2771
+ if (this.failedPaths.delete(path)) {
2772
+ this.options.onSyncError?.(this.syncError);
2773
+ }
2774
+ }
2775
+ recordFailure(path, source, error2, actor) {
2776
+ const message = error2 instanceof Error ? error2.message : String(error2);
2777
+ this.failedPaths.set(path, { source, actor, message });
2778
+ this.options.log(`Failed to synchronize ${path}: ${message}; retrying without stopping other paths.`);
2779
+ this.options.onSyncError?.(this.syncError);
2780
+ this.scheduleRetry();
2781
+ }
2782
+ scheduleRetry() {
2783
+ if (this.stopped || this.retryTimer || this.failedPaths.size === 0) {
2784
+ return;
2785
+ }
2786
+ this.retryTimer = setTimeout(() => {
2787
+ this.retryTimer = void 0;
2788
+ void this.enqueue(async () => {
2789
+ if (this.stopped) {
2790
+ return;
2791
+ }
2792
+ for (const [path, failure] of [...this.failedPaths]) {
2793
+ if (this.stopped) {
2794
+ return;
2795
+ }
2796
+ try {
2797
+ await this.synchronizePath(path, failure.source, failure.actor);
2798
+ this.options.log(`Recovered synchronization for ${path}.`);
2799
+ } catch (error2) {
2800
+ const message = error2 instanceof Error ? error2.message : String(error2);
2801
+ this.failedPaths.set(path, { ...failure, message });
2802
+ this.options.log(`Retry failed for ${path}: ${message}`);
2803
+ }
2804
+ }
2805
+ await this.finishRemoteBatch();
2806
+ }).catch((error2) => this.options.log(`Could not checkpoint retry batch: ${String(error2)}`)).finally(() => {
2807
+ this.retryDelayMs = this.failedPaths.size ? Math.min(this.retryDelayMs * 2, 12e4) : 3e4;
2808
+ this.scheduleRetry();
2809
+ });
2810
+ }, this.retryDelayMs);
2811
+ }
2812
+ async finishRemoteBatch() {
2813
+ if (this.stopped || this.failedPaths.size || this.pendingRemoteVersion === void 0) {
2814
+ return;
2815
+ }
2816
+ this.state.remoteVersion = this.pendingRemoteVersion;
2817
+ if (this.pendingInitialization) {
2818
+ this.state.initialized = true;
2819
+ }
2820
+ await this.options.stateStore.save(this.state);
2821
+ this.pendingRemoteVersion = void 0;
2822
+ this.pendingInitialization = false;
2823
+ }
2678
2824
  enqueue(operation) {
2679
2825
  const result = this.queue.then(operation);
2680
2826
  this.queue = result.then(() => void 0, () => void 0);
@@ -3137,6 +3283,7 @@ async function startServe(directory = process.cwd(), options = {}) {
3137
3283
  remote,
3138
3284
  stateStore: new FileSyncStateStore(root, settings.uri),
3139
3285
  ignore,
3286
+ onSyncError: options.onSyncError,
3140
3287
  log: options.log ?? log,
3141
3288
  onConflict: options.onConflict ?? ((path, reason) => {
3142
3289
  (options.log ?? log)(`Conflict paused for ${path}: ${reason}`);
@@ -3154,6 +3301,7 @@ async function startServe(directory = process.cwd(), options = {}) {
3154
3301
  serverName: settings.serverName,
3155
3302
  projectId: settings.projectId,
3156
3303
  projectName: settings.projectName,
3304
+ syncError: () => engine.syncError,
3157
3305
  conflicts: () => engine.getConflicts(),
3158
3306
  resolveConflict: (path, winner) => engine.resolveConflict(path, winner),
3159
3307
  retry: (path) => engine.retry(path),
@@ -3165,6 +3313,7 @@ async function startServe(directory = process.cwd(), options = {}) {
3165
3313
  }
3166
3314
 
3167
3315
  export {
3316
+ setLogSink,
3168
3317
  HttpRequestScheduler,
3169
3318
  ExtendedBaseAPI,
3170
3319
  SocketIOAPI,
@@ -3181,4 +3330,4 @@ export {
3181
3330
  RemoteProject,
3182
3331
  startServe
3183
3332
  };
3184
- //# sourceMappingURL=chunk-OZ4FLSVP.js.map
3333
+ //# sourceMappingURL=chunk-3TTRDNHP.js.map