leafrelay 0.1.7 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -9,6 +9,8 @@ cd /path/to/local-replica
9
9
  leafrelay serve
10
10
  ```
11
11
 
12
- `leafrelay serve` reads `.overleaf/settings.json` from the local project. Login sessions are stored per server in `~/.leafrelay/config.json`. Use `LEAFRELAY_CONFIG` to select another config file or `LEAFRELAY_COOKIE` to override the saved cookie for the current process.
12
+ `leafrelay serve` reads `.overleaf/settings.json` from the local project and connects to the machine-wide LeafRelay daemon. VS Code windows and other CLI processes use that same daemon, request scheduler, project connection, and local watcher.
13
+
14
+ Login sessions are stored per server in `~/.leafrelay/config.json`. Use `leafrelay daemon status|stop|restart` to control the daemon. `LEAFRELAY_HOME` relocates all LeafRelay user state, `LEAFRELAY_CONFIG` selects another login file, and `LEAFRELAY_COOKIE` overrides the saved cookie for the current process.
13
15
 
14
16
  For the VS Code extension, architecture, and project documentation, see the [LeafRelay repository](https://github.com/yunhaoli24/leafrelay).
package/README.zh-CN.md CHANGED
@@ -9,6 +9,8 @@ cd /path/to/local-replica
9
9
  leafrelay serve
10
10
  ```
11
11
 
12
- `leafrelay serve` 会读取项目中的 `.overleaf/settings.json`。登录会话按服务器保存到 `~/.leafrelay/config.json`;可以用 `LEAFRELAY_CONFIG` 指定其他配置文件,或用 `LEAFRELAY_COOKIE` 仅覆盖当前进程使用的 cookie。
12
+ `leafrelay serve` 会读取项目中的 `.overleaf/settings.json`,然后连接这台电脑上唯一的 LeafRelay daemon。VS Code 窗口和其他 CLI 进程共用同一个后台、请求队列、项目连接和本地 watcher。
13
+
14
+ 登录会话按服务器保存到 `~/.leafrelay/config.json`。使用 `leafrelay daemon status|stop|restart` 控制后台进程。`LEAFRELAY_HOME` 可以移动全部 LeafRelay 用户状态,`LEAFRELAY_CONFIG` 只指定其他登录文件,`LEAFRELAY_COOKIE` 则仅覆盖当前进程使用的 cookie。
13
15
 
14
16
  VS Code 扩展、架构和完整文档见 [LeafRelay 仓库](https://github.com/yunhaoli24/leafrelay)。
@@ -220,7 +220,7 @@ var OverleafRealtimeSocket = class {
220
220
  handshakeUrl.searchParams.set(name, value);
221
221
  }
222
222
  handshakeUrl.searchParams.set("t", String(Date.now()));
223
- const response = await fetch(handshakeUrl, {
223
+ const response = await (this.options.fetch ?? fetch)(handshakeUrl, {
224
224
  headers: { origin: this.origin, cookie: this.cookie },
225
225
  redirect: "manual"
226
226
  });
@@ -373,11 +373,7 @@ var OverleafRealtimeSocket = class {
373
373
  }, delay);
374
374
  }
375
375
  };
376
- var overleafSocketProtocol = { encodePacket, decodePacket, decodePayload };
377
376
  var sink;
378
- function setLogSink(nextSink) {
379
- sink = nextSink;
380
- }
381
377
  function write(level, args) {
382
378
  if (sink !== void 0) {
383
379
  sink(level, args);
@@ -389,57 +385,75 @@ function write(level, args) {
389
385
  function log(...args) {
390
386
  write("info", args);
391
387
  }
392
- function warn(...args) {
393
- write("warn", args);
394
- }
395
388
  function error(...args) {
396
389
  write("error", args);
397
390
  }
398
- var globalRequestChain = Promise.resolve();
399
- var globalNextRequestAt = 0;
400
- var globalRateLimitedUntil = 0;
401
- var GLOBAL_MIN_REQUEST_INTERVAL_MS = 300;
402
- function retryAfterHeaderMs(response) {
403
- const retryAfter = response.headers?.get?.("retry-after");
404
- if (retryAfter !== void 0 && retryAfter !== null) {
391
+ function retryAfterMilliseconds(response, now) {
392
+ const retryAfter = response.headers.get("retry-after");
393
+ if (retryAfter) {
405
394
  const seconds = Number(retryAfter);
406
395
  if (Number.isFinite(seconds)) {
407
396
  return Math.max(1e3, seconds * 1e3);
408
397
  }
409
398
  const retryAt = Date.parse(retryAfter);
410
399
  if (Number.isFinite(retryAt)) {
411
- return Math.max(1e3, retryAt - Date.now());
400
+ return Math.max(1e3, retryAt - now);
412
401
  }
413
402
  }
414
403
  return 5e3;
415
404
  }
416
- async function queuedFetch(url, init) {
417
- const previous = globalRequestChain;
418
- let release;
419
- globalRequestChain = new Promise((resolve6) => {
420
- release = resolve6;
421
- });
422
- await previous;
423
- try {
424
- const delayMs = Math.max(
425
- 0,
426
- globalNextRequestAt - Date.now(),
427
- globalRateLimitedUntil - Date.now()
428
- );
429
- if (delayMs > 0) {
430
- await new Promise((resolve6) => setTimeout(resolve6, delayMs));
431
- }
432
- globalNextRequestAt = Date.now() + GLOBAL_MIN_REQUEST_INTERVAL_MS;
433
- const response = await fetch(url, init);
434
- if (response.status === 429) {
435
- globalRateLimitedUntil = Math.max(globalRateLimitedUntil, Date.now() + retryAfterHeaderMs(response));
436
- log(`Global HTTP request queue entered rate-limit cooldown until ${new Date(globalRateLimitedUntil).toISOString()}.`);
437
- }
438
- return response;
439
- } finally {
440
- release();
405
+ var HttpRequestScheduler = class {
406
+ constructor(options = {}) {
407
+ this.options = options;
408
+ this.minimumIntervalMs = options.minimumIntervalMs ?? 300;
409
+ this.historyIntervalMs = options.historyIntervalMs ?? 3e3;
410
+ this.fetchImplementation = options.fetch ?? fetch;
411
+ this.now = options.now ?? Date.now;
412
+ this.wait = options.wait ?? ((milliseconds) => new Promise((resolveWait) => setTimeout(resolveWait, milliseconds)));
441
413
  }
442
- }
414
+ options;
415
+ chain = Promise.resolve();
416
+ nextRequestAt = 0;
417
+ historyNextRequestAt = 0;
418
+ rateLimitedUntil = 0;
419
+ minimumIntervalMs;
420
+ historyIntervalMs;
421
+ fetchImplementation;
422
+ now;
423
+ wait;
424
+ fetch(input, init) {
425
+ const url = input instanceof Request ? input.url : String(input);
426
+ const history = url.includes("/updates?") || url.includes("/filetree/diff?") || url.includes("/diff?");
427
+ return this.enqueue(async () => {
428
+ const now = this.now();
429
+ const readyAt = Math.max(this.nextRequestAt, this.rateLimitedUntil, history ? this.historyNextRequestAt : 0);
430
+ if (readyAt > now) {
431
+ await this.wait(readyAt - now);
432
+ }
433
+ const requestAt = this.now();
434
+ this.nextRequestAt = requestAt + this.minimumIntervalMs;
435
+ if (history) {
436
+ this.historyNextRequestAt = requestAt + this.historyIntervalMs;
437
+ }
438
+ const response = await this.fetchImplementation(input, init);
439
+ if (response.status === 429) {
440
+ this.rateLimitedUntil = Math.max(
441
+ this.rateLimitedUntil,
442
+ this.now() + retryAfterMilliseconds(response, this.now())
443
+ );
444
+ this.options.onRateLimit?.(this.rateLimitedUntil);
445
+ log(`HTTP request scheduler entered rate-limit cooldown until ${new Date(this.rateLimitedUntil).toISOString()}.`);
446
+ }
447
+ return response;
448
+ });
449
+ }
450
+ enqueue(operation) {
451
+ const result = this.chain.then(operation);
452
+ this.chain = result.then(() => void 0, () => void 0);
453
+ return result;
454
+ }
455
+ };
456
+ var sharedHttpRequestScheduler = new HttpRequestScheduler();
443
457
  function getSetCookie(res) {
444
458
  if (typeof res.headers?.getSetCookie === "function") {
445
459
  return res.headers.getSetCookie();
@@ -451,31 +465,13 @@ function getSetCookie(res) {
451
465
  return [];
452
466
  }
453
467
  var BaseAPI = class {
468
+ constructor(url, scheduler = sharedHttpRequestScheduler) {
469
+ this.scheduler = scheduler;
470
+ this.url = url;
471
+ }
472
+ scheduler;
454
473
  url;
455
474
  identity;
456
- historyRequestChain = Promise.resolve();
457
- lastHistoryRequestAt = 0;
458
- isHistoryRoute(route) {
459
- return route.includes("/updates?") || route.includes("/filetree/diff?") || route.includes("/diff?");
460
- }
461
- async waitForHistoryRequest() {
462
- const previous = this.historyRequestChain;
463
- let release;
464
- this.historyRequestChain = new Promise((resolve6) => {
465
- release = resolve6;
466
- });
467
- await previous;
468
- try {
469
- const minimumIntervalMs = 3e3;
470
- const delayMs = Math.max(0, minimumIntervalMs - (Date.now() - this.lastHistoryRequestAt));
471
- if (delayMs > 0) {
472
- await new Promise((resolve6) => setTimeout(resolve6, delayMs));
473
- }
474
- this.lastHistoryRequestAt = Date.now();
475
- } finally {
476
- release();
477
- }
478
- }
479
475
  retryAfterMs(response, attempt) {
480
476
  const retryAfter = response.headers?.get?.("retry-after");
481
477
  if (retryAfter !== void 0 && retryAfter !== null) {
@@ -490,11 +486,8 @@ var BaseAPI = class {
490
486
  }
491
487
  return 5e3 * Math.pow(2, attempt);
492
488
  }
493
- constructor(url) {
494
- this.url = url;
495
- }
496
489
  async getCsrfToken() {
497
- const res = await queuedFetch(this.url + "login", {
490
+ const res = await this.scheduler.fetch(this.url + "login", {
498
491
  method: "GET",
499
492
  redirect: "manual"
500
493
  });
@@ -509,7 +502,7 @@ var BaseAPI = class {
509
502
  }
510
503
  }
511
504
  async getUserId(cookies) {
512
- const res = await queuedFetch(this.url + "project", {
505
+ const res = await this.scheduler.fetch(this.url + "project", {
513
506
  method: "GET",
514
507
  redirect: "manual",
515
508
  headers: {
@@ -539,12 +532,13 @@ var BaseAPI = class {
539
532
  reconnect: true,
540
533
  reconnectionDelay: 1e3,
541
534
  reconnectionLimit: 16e3,
542
- maxReconnectionAttempts: 10
535
+ maxReconnectionAttempts: 10,
536
+ fetch: (input, init) => this.scheduler.fetch(input, init)
543
537
  });
544
538
  }
545
539
  async passportLogin(email, password) {
546
540
  const identity = await this.getCsrfToken();
547
- const res = await queuedFetch(this.url + "login", {
541
+ const res = await this.scheduler.fetch(this.url + "login", {
548
542
  method: "POST",
549
543
  redirect: "manual",
550
544
  headers: {
@@ -603,7 +597,7 @@ var BaseAPI = class {
603
597
  }
604
598
  }
605
599
  async updateCookies(identity) {
606
- const res = await queuedFetch(this.url + "socket.io/socket.io.js", {
600
+ const res = await this.scheduler.fetch(this.url + "socket.io/socket.io.js", {
607
601
  method: "GET",
608
602
  redirect: "manual",
609
603
  headers: {
@@ -645,13 +639,10 @@ var BaseAPI = class {
645
639
  let lastError = {};
646
640
  for (let attempt = 0; attempt <= MAX_HTTP_RETRIES; attempt++) {
647
641
  try {
648
- if (this.isHistoryRoute(route)) {
649
- await this.waitForHistoryRequest();
650
- }
651
642
  let res = void 0;
652
643
  switch (type) {
653
644
  case "GET":
654
- res = await queuedFetch(this.url + route, {
645
+ res = await this.scheduler.fetch(this.url + route, {
655
646
  method: "GET",
656
647
  redirect: "manual",
657
648
  headers: {
@@ -667,7 +658,7 @@ var BaseAPI = class {
667
658
  _csrf: this.identity.csrfToken,
668
659
  ...body
669
660
  });
670
- res = await queuedFetch(this.url + route, {
661
+ res = await this.scheduler.fetch(this.url + route, {
671
662
  method: "POST",
672
663
  redirect: "manual",
673
664
  headers: {
@@ -682,7 +673,7 @@ var BaseAPI = class {
682
673
  case "PUT":
683
674
  break;
684
675
  case "DELETE":
685
- res = await queuedFetch(this.url + route, {
676
+ res = await this.scheduler.fetch(this.url + route, {
686
677
  method: "DELETE",
687
678
  redirect: "manual",
688
679
  headers: {
@@ -749,7 +740,7 @@ var BaseAPI = class {
749
740
  }
750
741
  let content = [];
751
742
  while (true) {
752
- const res = await queuedFetch(this.url + route, {
743
+ const res = await this.scheduler.fetch(this.url + route, {
753
744
  method: "GET",
754
745
  redirect: "manual",
755
746
  headers: {
@@ -1030,7 +1021,7 @@ var BaseAPI = class {
1030
1021
  }
1031
1022
  let content = [];
1032
1023
  while (true) {
1033
- const res = await queuedFetch(absoluteUrl, {
1024
+ const res = await this.scheduler.fetch(absoluteUrl, {
1034
1025
  method: "GET",
1035
1026
  redirect: "manual",
1036
1027
  headers
@@ -1517,14 +1508,8 @@ var SocketIOAPI = class _SocketIOAPI {
1517
1508
  });
1518
1509
  }
1519
1510
  };
1520
- var FolderKeys = {
1521
- folder: "folders",
1522
- doc: "docs",
1523
- file: "fileRefs",
1524
- outputs: "outputs"
1525
- };
1526
1511
  function configPath(environment = process.env) {
1527
- return environment.LEAFRELAY_CONFIG ? resolve(environment.LEAFRELAY_CONFIG) : join(homedir(), ".leafrelay", "config.json");
1512
+ return environment.LEAFRELAY_CONFIG ? resolve(environment.LEAFRELAY_CONFIG) : join(environment.LEAFRELAY_HOME ? resolve(environment.LEAFRELAY_HOME) : join(homedir(), ".leafrelay"), "config.json");
1528
1513
  }
1529
1514
  function normalizeServerUrl(value) {
1530
1515
  const url = new URL(value.includes("://") ? value : `https://${value}`);
@@ -1545,7 +1530,7 @@ async function readConfig(path = configPath()) {
1545
1530
  return parsed;
1546
1531
  } catch (error2) {
1547
1532
  if (error2.code === "ENOENT") {
1548
- return { version: 1, servers: {} };
1533
+ return { version: 1, servers: {}, knownServers: {} };
1549
1534
  }
1550
1535
  throw error2;
1551
1536
  }
@@ -1558,7 +1543,29 @@ async function writeConfig(config, path = configPath()) {
1558
1543
  }
1559
1544
  async function saveServerSession(session, path = configPath()) {
1560
1545
  const config = await readConfig(path);
1561
- config.servers[serverKey(session.url)] = session;
1546
+ const key = serverKey(session.url);
1547
+ config.servers[key] = session;
1548
+ config.knownServers ??= {};
1549
+ config.knownServers[key] ??= { name: key, url: session.url };
1550
+ await writeConfig(config, path);
1551
+ }
1552
+ async function saveKnownServer(name, url, path = configPath()) {
1553
+ const config = await readConfig(path);
1554
+ const key = serverKey(url);
1555
+ config.knownServers ??= {};
1556
+ config.knownServers[key] = { name, url: normalizeServerUrl(url) };
1557
+ await writeConfig(config, path);
1558
+ }
1559
+ async function removeKnownServer(server, path = configPath()) {
1560
+ const config = await readConfig(path);
1561
+ const key = serverKey(server);
1562
+ delete config.servers[key];
1563
+ delete config.knownServers?.[key];
1564
+ await writeConfig(config, path);
1565
+ }
1566
+ async function removeServerSession(server, path = configPath()) {
1567
+ const config = await readConfig(path);
1568
+ delete config.servers[serverKey(server)];
1562
1569
  await writeConfig(config, path);
1563
1570
  }
1564
1571
  async function getServerSession(server, environment = process.env, path = configPath(environment)) {
@@ -1744,11 +1751,12 @@ function textContent(content) {
1744
1751
  }
1745
1752
  }
1746
1753
  var RemoteProject = class {
1747
- constructor(url, projectId, cookies) {
1754
+ constructor(url, projectId, cookies, transport) {
1748
1755
  this.url = url;
1749
1756
  this.projectId = projectId;
1750
1757
  this.cookies = cookies;
1751
- this.api = new BaseAPI(url);
1758
+ this.externalTransport = transport;
1759
+ this.api = transport?.api ?? new BaseAPI(url);
1752
1760
  }
1753
1761
  url;
1754
1762
  projectId;
@@ -1760,19 +1768,27 @@ var RemoteProject = class {
1760
1768
  entries = /* @__PURE__ */ new Map();
1761
1769
  idToPath = /* @__PURE__ */ new Map();
1762
1770
  changeHandler;
1771
+ externalTransport;
1763
1772
  async connect() {
1764
- const login = await this.api.cookiesLogin(this.cookies);
1765
- if (login.type !== "success" || !login.identity) {
1766
- throw new Error(login.message || `Authentication to ${this.url} failed.`);
1773
+ if (this.externalTransport) {
1774
+ this.identity = this.externalTransport.identity;
1775
+ this.socket = this.externalTransport.socket;
1776
+ } else {
1777
+ const login = await this.api.cookiesLogin(this.cookies);
1778
+ if (login.type !== "success" || !login.identity) {
1779
+ throw new Error(login.message || `Authentication to ${this.url} failed.`);
1780
+ }
1781
+ this.identity = login.identity;
1782
+ this.socket = new SocketIOAPI(this.url, this.api, this.identity, this.projectId);
1767
1783
  }
1768
- this.identity = login.identity;
1769
- this.socket = new SocketIOAPI(this.url, this.api, this.identity, this.projectId);
1770
1784
  this.project = await this.socket.joinProject(this.projectId);
1771
1785
  this.reindex();
1772
1786
  this.registerEvents();
1773
1787
  }
1774
1788
  disconnect() {
1775
- this.socket?.disconnect();
1789
+ if (!this.externalTransport) {
1790
+ this.socket?.disconnect();
1791
+ }
1776
1792
  }
1777
1793
  onChange(handler) {
1778
1794
  this.changeHandler = handler;
@@ -2205,6 +2221,49 @@ var SyncEngine = class {
2205
2221
  await this.stopLocalWatch?.();
2206
2222
  await this.queue;
2207
2223
  }
2224
+ getConflicts() {
2225
+ return [...this.conflicts].sort();
2226
+ }
2227
+ async resolveConflict(path, winner) {
2228
+ const normalized = normalizePath2(path);
2229
+ if (!this.conflicts.has(normalized)) {
2230
+ throw new Error(`No unresolved conflict exists for ${normalized}.`);
2231
+ }
2232
+ await this.enqueue(async () => {
2233
+ const remoteEntry = this.options.remote.entry(normalized);
2234
+ if (winner === "local") {
2235
+ const localContent = await this.options.local.read(normalized);
2236
+ if (localContent) {
2237
+ await this.options.remote.write(normalized, localContent);
2238
+ const synchronized = await this.options.remote.read(normalized);
2239
+ if (sha256(synchronized) !== sha256(localContent)) {
2240
+ await this.options.local.write(normalized, synchronized);
2241
+ }
2242
+ updateSyncCheckpoint(this.state, normalized, synchronized);
2243
+ } else {
2244
+ if (remoteEntry) {
2245
+ await this.options.remote.remove(normalized);
2246
+ }
2247
+ removeSyncCheckpoint(this.state, normalized);
2248
+ }
2249
+ } else if (isRemoteFile(remoteEntry)) {
2250
+ const remoteContent = await this.options.remote.read(normalized);
2251
+ await this.options.local.write(normalized, remoteContent);
2252
+ updateSyncCheckpoint(this.state, normalized, remoteContent);
2253
+ } else {
2254
+ await this.options.local.remove(normalized);
2255
+ removeSyncCheckpoint(this.state, normalized);
2256
+ }
2257
+ await this.options.stateStore.save(this.state);
2258
+ this.conflicts.delete(normalized);
2259
+ this.options.log(`Resolved ${normalized} using ${winner === "local" ? "local" : "Overleaf"} content.`);
2260
+ });
2261
+ }
2262
+ async retry(path) {
2263
+ const normalized = normalizePath2(path);
2264
+ this.conflicts.delete(normalized);
2265
+ await this.enqueue(() => this.reconcilePath(normalized, "local"));
2266
+ }
2208
2267
  async startupReconcile(currentVersion, localFiles, remoteFiles) {
2209
2268
  const state = this.state;
2210
2269
  if (state.remoteVersion > currentVersion) {
@@ -2256,11 +2315,14 @@ var SyncEngine = class {
2256
2315
  this.options.log(`Failed to synchronize ${path}: ${error2 instanceof Error ? error2.message : String(error2)}`);
2257
2316
  }
2258
2317
  }
2259
- if (failed || this.conflicts.size !== 0) {
2260
- throw new Error(`Synchronization paused for ${failed ? "failed" : "conflicting"} paths; successful paths were checkpointed.`);
2318
+ if (failed) {
2319
+ throw new Error("Synchronization paused for failed paths; successful paths were checkpointed.");
2261
2320
  }
2262
2321
  state.remoteVersion = currentVersion;
2263
2322
  await this.options.stateStore.save(state);
2323
+ if (this.conflicts.size !== 0) {
2324
+ this.options.log(`Synchronization remains active; ${this.conflicts.size} conflicting path(s) are paused.`);
2325
+ }
2264
2326
  }
2265
2327
  schedule(path, source) {
2266
2328
  const normalized = normalizePath2(path);
@@ -2278,6 +2340,11 @@ var SyncEngine = class {
2278
2340
  });
2279
2341
  }, 250));
2280
2342
  }
2343
+ enqueue(operation) {
2344
+ const result = this.queue.then(operation);
2345
+ this.queue = result.then(() => void 0, () => void 0);
2346
+ return result;
2347
+ }
2281
2348
  async reconcilePath(path, source) {
2282
2349
  const normalized = normalizePath2(path);
2283
2350
  if (this.shouldIgnore(normalized) || this.conflicts.has(normalized)) {
@@ -2312,7 +2379,7 @@ var SyncEngine = class {
2312
2379
  await this.options.remote.write(normalized, merged.content);
2313
2380
  updateSyncCheckpoint(this.state, normalized, merged.content);
2314
2381
  await this.options.stateStore.save(this.state);
2315
- this.options.log(`Merged non-overlapping local and Overleaf changes in ${normalized}.`);
2382
+ this.logChange("merge", "update", normalized);
2316
2383
  return;
2317
2384
  }
2318
2385
  }
@@ -2336,6 +2403,11 @@ var SyncEngine = class {
2336
2403
  updateSyncCheckpoint(this.state, normalized, synchronized);
2337
2404
  }
2338
2405
  await this.options.stateStore.save(this.state);
2406
+ if (!localContent && remoteEntry) {
2407
+ this.logChange("push", "delete", normalized);
2408
+ } else if (localContent) {
2409
+ this.logChange("push", remoteEntry ? "update" : "add", normalized);
2410
+ }
2339
2411
  return;
2340
2412
  }
2341
2413
  if (!remoteContent) {
@@ -2348,6 +2420,14 @@ var SyncEngine = class {
2348
2420
  updateSyncCheckpoint(this.state, normalized, remoteContent);
2349
2421
  }
2350
2422
  await this.options.stateStore.save(this.state);
2423
+ if (!remoteContent && localContent) {
2424
+ this.logChange("pull", "delete", normalized);
2425
+ } else if (remoteContent) {
2426
+ this.logChange("pull", localContent ? "update" : "add", normalized);
2427
+ }
2428
+ }
2429
+ logChange(direction, operation, path) {
2430
+ this.options.log(`[${direction}] ${operation} ${JSON.stringify(path)}`);
2351
2431
  }
2352
2432
  remoteFilePaths() {
2353
2433
  return this.options.remote.listEntries().filter((entry) => isRemoteFile(entry) && !this.shouldIgnore(entry.path)).map((entry) => entry.path);
@@ -2391,15 +2471,15 @@ var FileSyncStateStore = class {
2391
2471
  this.serialized = serialized;
2392
2472
  }
2393
2473
  };
2394
- async function startServe(directory = process.cwd()) {
2474
+ async function startServe(directory = process.cwd(), options = {}) {
2395
2475
  const root = resolve5(directory);
2396
2476
  const settings = await readProjectSettings(root);
2397
2477
  const session = await getServerSession(settings.serverName);
2398
2478
  if (!session) {
2399
2479
  throw new Error(`No login is stored for ${settings.serverName}. Run: leafrelay login ${settings.serverName}`);
2400
2480
  }
2401
- const cookie = process.env.LEAFRELAY_COOKIE || session.identity.cookies;
2402
- const remote = new RemoteProject(session.url || settings.serverUrl, settings.projectId, cookie);
2481
+ const cookie = options.cookie || process.env.LEAFRELAY_COOKIE || session.identity.cookies;
2482
+ const remote = options.remote ?? new RemoteProject(session.url || settings.serverUrl, settings.projectId, cookie);
2403
2483
  await remote.connect();
2404
2484
  const configuredPatterns = settings.localReplica.settings["ignore-patterns"];
2405
2485
  const ignore = createIgnoreMatcher(configuredPatterns);
@@ -2410,10 +2490,10 @@ async function startServe(directory = process.cwd()) {
2410
2490
  remote,
2411
2491
  stateStore: new FileSyncStateStore(root, settings.uri),
2412
2492
  ignore,
2413
- log,
2414
- onConflict: (path, reason) => {
2415
- log(`Conflict paused for ${path}: ${reason}`);
2416
- }
2493
+ log: options.log ?? log,
2494
+ onConflict: options.onConflict ?? ((path, reason) => {
2495
+ (options.log ?? log)(`Conflict paused for ${path}: ${reason}`);
2496
+ })
2417
2497
  });
2418
2498
  try {
2419
2499
  await engine.start();
@@ -2421,8 +2501,15 @@ async function startServe(directory = process.cwd()) {
2421
2501
  remote.disconnect();
2422
2502
  throw error2;
2423
2503
  }
2424
- log(`LeafRelay is serving ${root} (${settings.projectName}).`);
2504
+ (options.log ?? log)(`LeafRelay is serving ${root} (${settings.projectName}).`);
2425
2505
  return {
2506
+ root,
2507
+ serverName: settings.serverName,
2508
+ projectId: settings.projectId,
2509
+ projectName: settings.projectName,
2510
+ conflicts: () => engine.getConflicts(),
2511
+ resolveConflict: (path, winner) => engine.resolveConflict(path, winner),
2512
+ retry: (path) => engine.retry(path),
2426
2513
  stop: async () => {
2427
2514
  await engine.stop();
2428
2515
  remote.disconnect();
@@ -2431,39 +2518,20 @@ async function startServe(directory = process.cwd()) {
2431
2518
  }
2432
2519
 
2433
2520
  export {
2434
- OverleafRealtimeSocket,
2435
- overleafSocketProtocol,
2436
- setLogSink,
2437
- log,
2438
- warn,
2439
- error,
2440
- BaseAPI,
2521
+ HttpRequestScheduler,
2441
2522
  ExtendedBaseAPI,
2442
2523
  SocketIOAPI,
2443
- FolderKeys,
2444
2524
  configPath,
2445
2525
  normalizeServerUrl,
2446
2526
  serverKey,
2447
2527
  readConfig,
2448
- writeConfig,
2449
2528
  saveServerSession,
2529
+ saveKnownServer,
2530
+ removeKnownServer,
2531
+ removeServerSession,
2450
2532
  getServerSession,
2451
- NodeReplicaFileSystem,
2452
2533
  readProjectSettings,
2453
- DEFAULT_IGNORE_PATTERNS,
2454
- createIgnoreMatcher,
2455
2534
  RemoteProject,
2456
- encodeTextBase,
2457
- mergeText,
2458
- SYNC_STATE_SCHEMA_VERSION,
2459
- sha256,
2460
- parseSyncState,
2461
- createSyncState,
2462
- updateSyncCheckpoint,
2463
- removeSyncCheckpoint,
2464
- readTextBase,
2465
- SyncEngine,
2466
- FileSyncStateStore,
2467
2535
  startServe
2468
2536
  };
2469
- //# sourceMappingURL=chunk-MNOPLM7M.js.map
2537
+ //# sourceMappingURL=chunk-Q3R5N4YE.js.map