leafrelay 0.2.2 → 0.2.4

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/cli.js CHANGED
@@ -5,11 +5,11 @@ import {
5
5
  daemonStatus,
6
6
  daemonStop,
7
7
  serve
8
- } from "./chunk-2EXJYAER.js";
8
+ } from "./chunk-Y543FNFD.js";
9
9
  import {
10
10
  configPath,
11
11
  normalizeServerUrl
12
- } from "./chunk-Q3R5N4YE.js";
12
+ } from "./chunk-OZ4FLSVP.js";
13
13
 
14
14
  // src/cli.ts
15
15
  import { Command } from "commander";
@@ -48,7 +48,7 @@ async function login(server, options) {
48
48
  }
49
49
 
50
50
  // src/cli.ts
51
- var program = new Command().name("leafrelay").description("Keep a local directory synchronized with an Overleaf project.").version("0.2.2");
51
+ var program = new Command().name("leafrelay").description("Keep a local directory synchronized with an Overleaf project.").version("0.2.4");
52
52
  program.command("login").description("Authenticate to Overleaf and save the session in ~/.leafrelay/config.json.").argument("[server]", "Overleaf server URL", "https://www.overleaf.com/").option("--cookie <cookie>", "Overleaf browser cookie").option("--email <email>", "Overleaf account email").option("--password <password>", "Overleaf account password").action(login);
53
53
  program.command("serve").description("Synchronize the current directory with its configured Overleaf project.").argument("[directory]", "Local replica directory", process.cwd()).action(serve);
54
54
  var daemon = program.command("daemon").description("Inspect or control the shared LeafRelay daemon.");
package/dist/daemon.js CHANGED
@@ -13,10 +13,10 @@ import {
13
13
  saveServerSession,
14
14
  serverKey,
15
15
  startServe
16
- } from "./chunk-Q3R5N4YE.js";
16
+ } from "./chunk-OZ4FLSVP.js";
17
17
 
18
18
  // ../daemon/src/server.ts
19
- import { randomBytes } from "crypto";
19
+ import { randomBytes, randomUUID as randomUUID2 } from "crypto";
20
20
  import { appendFile, chmod, mkdir, readFile, rm, writeFile } from "fs/promises";
21
21
  import { createServer } from "net";
22
22
 
@@ -97,7 +97,6 @@ function decodeRpcValue(value) {
97
97
  }
98
98
 
99
99
  // ../daemon/src/networkRuntime.ts
100
- var PROJECT_IDLE_MS = 6e4;
101
100
  var SERVER_OPERATIONS = /* @__PURE__ */ new Set([
102
101
  "userProjectsJson",
103
102
  "getProjectsJson",
@@ -256,10 +255,6 @@ var NetworkRuntimeRegistry = class {
256
255
  async openProject(clientId, server2, projectId) {
257
256
  const runtime = await this.getOrCreateProject(server2, projectId);
258
257
  const shared = runtime.owners.size !== 0 || runtime.replicaReferences !== 0;
259
- if (runtime.idleTimer) {
260
- clearTimeout(runtime.idleTimer);
261
- runtime.idleTimer = void 0;
262
- }
263
258
  runtime.owners.add(clientId);
264
259
  await runtime.ready;
265
260
  return { projectKey: runtime.key, shared };
@@ -294,10 +289,6 @@ var NetworkRuntimeRegistry = class {
294
289
  }
295
290
  async acquireReplica(server2, projectId) {
296
291
  const runtime = await this.getOrCreateProject(server2, projectId);
297
- if (runtime.idleTimer) {
298
- clearTimeout(runtime.idleTimer);
299
- runtime.idleTimer = void 0;
300
- }
301
292
  runtime.replicaReferences += 1;
302
293
  await runtime.ready;
303
294
  let released = false;
@@ -386,7 +377,7 @@ var NetworkRuntimeRegistry = class {
386
377
  onFileRemoved: (...args) => emit("onFileRemoved", ...args),
387
378
  onFileMoved: (...args) => emit("onFileMoved", ...args),
388
379
  onFileChanged: (...args) => emit("onFileChanged", ...args),
389
- onDisconnected: () => emit("onDisconnected"),
380
+ onDisconnected: (reason) => emit("onDisconnected", reason),
390
381
  onConnectionAccepted: (...args) => emit("onConnectionAccepted", ...args),
391
382
  onClientUpdated: (...args) => emit("onClientUpdated", ...args),
392
383
  onClientDisconnected: (...args) => emit("onClientDisconnected", ...args),
@@ -416,7 +407,8 @@ var NetworkRuntimeRegistry = class {
416
407
  remove: (path) => this.enqueueProjectOperation(runtime, () => runtime.remote.remove(path)),
417
408
  getCurrentVersion: () => this.enqueueProjectOperation(runtime, () => runtime.remote.getCurrentVersion()),
418
409
  getFileTreeDiff: (from, to) => this.enqueueProjectOperation(runtime, () => runtime.remote.getFileTreeDiff(from, to)),
419
- onChange: (handler) => runtime.remote.onChange(handler)
410
+ onChange: (handler) => runtime.remote.onChange(handler),
411
+ onReconnect: (handler) => runtime.remote.onReconnect(handler)
420
412
  };
421
413
  }
422
414
  enqueueProjectOperation(runtime, operation) {
@@ -425,19 +417,15 @@ var NetworkRuntimeRegistry = class {
425
417
  return result;
426
418
  }
427
419
  scheduleProjectStop(runtime) {
428
- if (runtime.owners.size !== 0 || runtime.replicaReferences !== 0 || runtime.idleTimer) {
420
+ if (runtime.owners.size !== 0 || runtime.replicaReferences !== 0) {
429
421
  return;
430
422
  }
431
- runtime.idleTimer = setTimeout(() => void this.stopProject(runtime), PROJECT_IDLE_MS);
432
- runtime.idleTimer.unref();
423
+ void this.stopProject(runtime);
433
424
  }
434
425
  async stopProject(runtime) {
435
426
  if (!this.projects.delete(runtime.key)) {
436
427
  return;
437
428
  }
438
- if (runtime.idleTimer) {
439
- clearTimeout(runtime.idleTimer);
440
- }
441
429
  runtime.socket.disconnect();
442
430
  }
443
431
  };
@@ -446,7 +434,6 @@ var NetworkRuntimeRegistry = class {
446
434
  import { randomUUID } from "crypto";
447
435
  import { realpath } from "fs/promises";
448
436
  import { resolve as resolve2 } from "path";
449
- var DETACHED_REPLICA_GRACE_MS = 6e4;
450
437
  var ReplicaAlreadyActiveError = class extends Error {
451
438
  constructor(projectId, activeRoot, requestedRoot) {
452
439
  super(`Overleaf project ${projectId} is already synchronized by ${activeRoot}; ${requestedRoot} cannot become a second writable replica.`);
@@ -470,9 +457,13 @@ var ReplicaRegistry = class {
470
457
  byRoot = /* @__PURE__ */ new Map();
471
458
  byProject = /* @__PURE__ */ new Map();
472
459
  projectOperations = /* @__PURE__ */ new Map();
460
+ clients = /* @__PURE__ */ new Set();
473
461
  get size() {
474
462
  return this.byId.size;
475
463
  }
464
+ registerClient(clientId) {
465
+ this.clients.add(clientId);
466
+ }
476
467
  async attach(clientId, params) {
477
468
  const root = await realpath(resolve2(params.directory));
478
469
  const settings = await readProjectSettings(root);
@@ -480,6 +471,9 @@ var ReplicaRegistry = class {
480
471
  return this.withProjectLock(projectKey, () => this.attachResolved(clientId, params, root, settings, projectKey));
481
472
  }
482
473
  async attachResolved(clientId, params, root, settings, projectKey) {
474
+ if (!this.clients.has(clientId)) {
475
+ throw new Error("The replica client disconnected before attachment started.");
476
+ }
483
477
  const rootEntry = this.byRoot.get(root);
484
478
  if (rootEntry) {
485
479
  if (rootEntry.projectKey !== projectKey) {
@@ -515,6 +509,11 @@ var ReplicaRegistry = class {
515
509
  this.events.status(entry.owners, { replicaId: entry.id, status: entry.status });
516
510
  }
517
511
  });
512
+ if (!this.clients.has(clientId)) {
513
+ await running.stop();
514
+ this.events.empty();
515
+ throw new Error("The replica client disconnected before attachment completed.");
516
+ }
518
517
  entry = {
519
518
  id,
520
519
  root,
@@ -537,17 +536,24 @@ var ReplicaRegistry = class {
537
536
  throw error;
538
537
  }
539
538
  }
540
- detach(clientId, replicaId) {
539
+ async detach(clientId, replicaId) {
541
540
  const entry = this.require(replicaId);
542
- entry.owners.delete(clientId);
543
- this.scheduleStopWhenDetached(entry);
541
+ await this.withProjectLock(entry.projectKey, async () => {
542
+ entry.owners.delete(clientId);
543
+ if (entry.owners.size === 0) {
544
+ await this.stop(entry);
545
+ }
546
+ });
544
547
  }
545
- detachClient(clientId) {
546
- for (const entry of this.byId.values()) {
547
- if (entry.owners.delete(clientId)) {
548
- this.scheduleStopWhenDetached(entry);
548
+ async detachClient(clientId) {
549
+ this.clients.delete(clientId);
550
+ const owned = [...this.byId.values()].filter((entry) => entry.owners.has(clientId));
551
+ await Promise.all(owned.map((entry) => this.withProjectLock(entry.projectKey, async () => {
552
+ entry.owners.delete(clientId);
553
+ if (entry.owners.size === 0) {
554
+ await this.stop(entry);
549
555
  }
550
- }
556
+ })));
551
557
  }
552
558
  async resolveConflict(replicaId, path, winner) {
553
559
  const entry = this.require(replicaId);
@@ -577,26 +583,12 @@ var ReplicaRegistry = class {
577
583
  await Promise.all([...this.byId.values()].map((entry) => this.stop(entry)));
578
584
  }
579
585
  addOwner(entry, clientId) {
580
- if (entry.stopTimer) {
581
- clearTimeout(entry.stopTimer);
582
- entry.stopTimer = void 0;
583
- }
584
586
  entry.owners.add(clientId);
585
587
  }
586
- scheduleStopWhenDetached(entry) {
587
- if (entry.owners.size !== 0 || entry.stopTimer) {
588
- return;
589
- }
590
- entry.stopTimer = setTimeout(() => void this.stop(entry), DETACHED_REPLICA_GRACE_MS);
591
- entry.stopTimer.unref();
592
- }
593
588
  async stop(entry) {
594
589
  if (!this.byId.delete(entry.id)) {
595
590
  return;
596
591
  }
597
- if (entry.stopTimer) {
598
- clearTimeout(entry.stopTimer);
599
- }
600
592
  this.byRoot.delete(entry.root);
601
593
  this.byProject.delete(entry.projectKey);
602
594
  entry.status = { state: "stopping" };
@@ -656,17 +648,17 @@ var CLOSED_SOCKET_ERRORS = /* @__PURE__ */ new Set([
656
648
  "ERR_STREAM_WRITE_AFTER_END"
657
649
  ]);
658
650
  var LeafRelaySocketWriter = class extends SocketMessageWriter {
659
- constructor(socket) {
660
- super(socket);
661
- this.socket = socket;
651
+ constructor(transportSocket) {
652
+ super(transportSocket);
653
+ this.transportSocket = transportSocket;
662
654
  }
663
- socket;
655
+ transportSocket;
664
656
  async write(message) {
665
657
  try {
666
658
  await super.write(message);
667
659
  } catch (error) {
668
660
  const code = error.code;
669
- if (this.socket.destroyed || this.socket.writableEnded || code && CLOSED_SOCKET_ERRORS.has(code)) {
661
+ if (this.transportSocket.destroyed || this.transportSocket.writableEnded || code && CLOSED_SOCKET_ERRORS.has(code)) {
670
662
  return;
671
663
  }
672
664
  throw error;
@@ -680,7 +672,7 @@ function createSocketRpcConnection(socket) {
680
672
  // ../daemon/src/server.ts
681
673
  var IDLE_EXIT_MS = 6e4;
682
674
  var REPLICA_ALREADY_ACTIVE = -32010;
683
- var daemonVersion = true ? "0.2.2" : "development";
675
+ var daemonVersion = true ? "0.2.4" : "development";
684
676
  var LeafRelayDaemonServer = class {
685
677
  constructor(options = {}) {
686
678
  this.options = options;
@@ -775,7 +767,7 @@ var LeafRelayDaemonServer = class {
775
767
  accept(socket) {
776
768
  this.cancelIdleExit();
777
769
  const rpc = createSocketRpcConnection(socket);
778
- const client = { socket, rpc, initialized: false };
770
+ const client = { leaseId: randomUUID2(), socket, rpc, initialized: false };
779
771
  this.clients.add(client);
780
772
  rpc.onRequest(RPC_METHOD.initialize, (params) => this.initialize(client, params));
781
773
  rpc.onRequest(RPC_METHOD.daemonStatus, () => this.authorized(client, () => this.status()));
@@ -785,7 +777,7 @@ var LeafRelayDaemonServer = class {
785
777
  }));
786
778
  rpc.onRequest(RPC_METHOD.replicaAttach, (params) => this.authorized(client, async () => {
787
779
  try {
788
- return await this.replicas.attach(client.clientId, params);
780
+ return await this.replicas.attach(client.leaseId, params);
789
781
  } catch (error) {
790
782
  if (error instanceof ReplicaAlreadyActiveError) {
791
783
  throw new ResponseError(REPLICA_ALREADY_ACTIVE, error.message, {
@@ -798,8 +790,8 @@ var LeafRelayDaemonServer = class {
798
790
  throw error;
799
791
  }
800
792
  }));
801
- rpc.onRequest(RPC_METHOD.replicaDetach, (params) => this.authorized(client, () => {
802
- this.replicas.detach(client.clientId, params.replicaId);
793
+ rpc.onRequest(RPC_METHOD.replicaDetach, (params) => this.authorized(client, async () => {
794
+ await this.replicas.detach(client.leaseId, params.replicaId);
803
795
  return null;
804
796
  }));
805
797
  rpc.onRequest(RPC_METHOD.replicaResolveConflict, (params) => this.authorized(client, async () => {
@@ -829,16 +821,16 @@ var LeafRelayDaemonServer = class {
829
821
  const result = await this.network.callServer(params.server, params.operation, decodeRpcValue(params.args));
830
822
  return encodeRpcValue(result);
831
823
  }));
832
- rpc.onRequest(RPC_METHOD.projectOpen, (params) => this.authorized(client, () => this.network.openProject(client.clientId, params.server, params.projectId)));
824
+ rpc.onRequest(RPC_METHOD.projectOpen, (params) => this.authorized(client, () => this.network.openProject(client.leaseId, params.server, params.projectId)));
833
825
  rpc.onRequest(RPC_METHOD.projectClose, (params) => this.authorized(client, () => {
834
- this.network.closeProject(client.clientId, params.projectKey);
826
+ this.network.closeProject(client.leaseId, params.projectKey);
835
827
  return null;
836
828
  }));
837
829
  rpc.onRequest(RPC_METHOD.projectCall, (params) => this.authorized(client, async () => {
838
830
  const result = await this.network.callProject(params.projectKey, params.operation, decodeRpcValue(params.args));
839
831
  return encodeRpcValue(await result);
840
832
  }));
841
- rpc.onClose(() => this.closeClient(client));
833
+ rpc.onClose(() => void this.closeClient(client));
842
834
  rpc.onError((error) => void this.log("error", `JSON-RPC connection error: ${String(error[0])}`));
843
835
  rpc.listen();
844
836
  }
@@ -854,6 +846,7 @@ var LeafRelayDaemonServer = class {
854
846
  }
855
847
  client.clientId = params.clientId;
856
848
  client.initialized = true;
849
+ this.replicas.registerClient(client.leaseId);
857
850
  void this.log("info", `${params.clientName} ${params.clientVersion} connected (${params.clientId}).`);
858
851
  return {
859
852
  daemonVersion,
@@ -868,21 +861,24 @@ var LeafRelayDaemonServer = class {
868
861
  }
869
862
  return operation();
870
863
  }
871
- closeClient(client) {
864
+ async closeClient(client) {
872
865
  if (!this.clients.delete(client)) {
873
866
  return;
874
867
  }
875
868
  if (client.clientId) {
876
- this.replicas.detachClient(client.clientId);
877
- }
878
- if (client.clientId) {
879
- this.network.detachClient(client.clientId);
869
+ try {
870
+ await this.replicas.detachClient(client.leaseId);
871
+ } catch (error) {
872
+ await this.log("error", `Failed to stop replicas for a disconnected client: ${error instanceof Error ? error.message : String(error)}`);
873
+ } finally {
874
+ this.network.detachClient(client.leaseId);
875
+ }
880
876
  }
881
877
  this.scheduleIdleExit();
882
878
  }
883
879
  notifyOwners(owners, method, notification) {
884
880
  for (const client of this.clients) {
885
- if (client.clientId && owners.has(client.clientId)) {
881
+ if (owners.has(client.leaseId)) {
886
882
  this.notifyClient(client, method, notification);
887
883
  }
888
884
  }