leafrelay 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.
@@ -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)) {
@@ -2391,15 +2458,15 @@ var FileSyncStateStore = class {
2391
2458
  this.serialized = serialized;
2392
2459
  }
2393
2460
  };
2394
- async function startServe(directory = process.cwd()) {
2461
+ async function startServe(directory = process.cwd(), options = {}) {
2395
2462
  const root = resolve5(directory);
2396
2463
  const settings = await readProjectSettings(root);
2397
2464
  const session = await getServerSession(settings.serverName);
2398
2465
  if (!session) {
2399
2466
  throw new Error(`No login is stored for ${settings.serverName}. Run: leafrelay login ${settings.serverName}`);
2400
2467
  }
2401
- const cookie = process.env.LEAFRELAY_COOKIE || session.identity.cookies;
2402
- const remote = new RemoteProject(session.url || settings.serverUrl, settings.projectId, cookie);
2468
+ const cookie = options.cookie || process.env.LEAFRELAY_COOKIE || session.identity.cookies;
2469
+ const remote = options.remote ?? new RemoteProject(session.url || settings.serverUrl, settings.projectId, cookie);
2403
2470
  await remote.connect();
2404
2471
  const configuredPatterns = settings.localReplica.settings["ignore-patterns"];
2405
2472
  const ignore = createIgnoreMatcher(configuredPatterns);
@@ -2410,10 +2477,10 @@ async function startServe(directory = process.cwd()) {
2410
2477
  remote,
2411
2478
  stateStore: new FileSyncStateStore(root, settings.uri),
2412
2479
  ignore,
2413
- log,
2414
- onConflict: (path, reason) => {
2415
- log(`Conflict paused for ${path}: ${reason}`);
2416
- }
2480
+ log: options.log ?? log,
2481
+ onConflict: options.onConflict ?? ((path, reason) => {
2482
+ (options.log ?? log)(`Conflict paused for ${path}: ${reason}`);
2483
+ })
2417
2484
  });
2418
2485
  try {
2419
2486
  await engine.start();
@@ -2421,8 +2488,15 @@ async function startServe(directory = process.cwd()) {
2421
2488
  remote.disconnect();
2422
2489
  throw error2;
2423
2490
  }
2424
- log(`LeafRelay is serving ${root} (${settings.projectName}).`);
2491
+ (options.log ?? log)(`LeafRelay is serving ${root} (${settings.projectName}).`);
2425
2492
  return {
2493
+ root,
2494
+ serverName: settings.serverName,
2495
+ projectId: settings.projectId,
2496
+ projectName: settings.projectName,
2497
+ conflicts: () => engine.getConflicts(),
2498
+ resolveConflict: (path, winner) => engine.resolveConflict(path, winner),
2499
+ retry: (path) => engine.retry(path),
2426
2500
  stop: async () => {
2427
2501
  await engine.stop();
2428
2502
  remote.disconnect();
@@ -2431,39 +2505,20 @@ async function startServe(directory = process.cwd()) {
2431
2505
  }
2432
2506
 
2433
2507
  export {
2434
- OverleafRealtimeSocket,
2435
- overleafSocketProtocol,
2436
- setLogSink,
2437
- log,
2438
- warn,
2439
- error,
2440
- BaseAPI,
2508
+ HttpRequestScheduler,
2441
2509
  ExtendedBaseAPI,
2442
2510
  SocketIOAPI,
2443
- FolderKeys,
2444
2511
  configPath,
2445
2512
  normalizeServerUrl,
2446
2513
  serverKey,
2447
2514
  readConfig,
2448
- writeConfig,
2449
2515
  saveServerSession,
2516
+ saveKnownServer,
2517
+ removeKnownServer,
2518
+ removeServerSession,
2450
2519
  getServerSession,
2451
- NodeReplicaFileSystem,
2452
2520
  readProjectSettings,
2453
- DEFAULT_IGNORE_PATTERNS,
2454
- createIgnoreMatcher,
2455
2521
  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
2522
  startServe
2468
2523
  };
2469
- //# sourceMappingURL=chunk-MNOPLM7M.js.map
2524
+ //# sourceMappingURL=chunk-PRWBFOB2.js.map