loro-repo 0.5.3 → 0.7.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.
@@ -1,8 +1,6 @@
1
1
  const require_chunk = require('../chunk.cjs');
2
2
  let loro_adaptors_loro = require("loro-adaptors/loro");
3
3
  loro_adaptors_loro = require_chunk.__toESM(loro_adaptors_loro);
4
- let loro_protocol = require("loro-protocol");
5
- loro_protocol = require_chunk.__toESM(loro_protocol);
6
4
  let loro_websocket = require("loro-websocket");
7
5
  loro_websocket = require_chunk.__toESM(loro_websocket);
8
6
  let loro_adaptors_flock = require("loro-adaptors/flock");
@@ -63,14 +61,28 @@ function withTimeout(promise, timeoutMs) {
63
61
  });
64
62
  });
65
63
  }
66
- function normalizeRoomId(roomId, fallback) {
67
- if (typeof roomId === "string" && roomId.length > 0) return roomId;
68
- if (roomId instanceof Uint8Array && roomId.length > 0) try {
69
- return (0, loro_protocol.bytesToHex)(roomId);
70
- } catch {
71
- return fallback;
64
+ function createStatusEmitter(initial = "connecting") {
65
+ return {
66
+ status: initial,
67
+ listeners: /* @__PURE__ */ new Set()
68
+ };
69
+ }
70
+ function emitStatus(emitter, status) {
71
+ if (!emitter) return;
72
+ emitter.status = status;
73
+ const listeners = Array.from(emitter.listeners);
74
+ for (const cb of listeners) try {
75
+ cb(status);
76
+ } catch (error) {
77
+ debug("status listener error", error);
78
+ }
79
+ }
80
+ function mapClientStatusToRoom(status) {
81
+ switch (status) {
82
+ case "connected": return "joined";
83
+ case "connecting": return "reconnecting";
84
+ default: return "disconnected";
72
85
  }
73
- return fallback;
74
86
  }
75
87
  function bytesEqual(a, b) {
76
88
  if (a === b) return true;
@@ -86,24 +98,85 @@ function bytesEqual(a, b) {
86
98
  var WebSocketTransportAdapter = class {
87
99
  options;
88
100
  client;
101
+ clientListeners = [];
89
102
  metadataSession;
90
103
  docSessions = /* @__PURE__ */ new Map();
104
+ ephemeralSessions = /* @__PURE__ */ new Map();
91
105
  constructor(options) {
92
106
  this.options = options;
93
107
  }
94
- async connect(_options) {
108
+ trackClientListener(unsubscribe) {
109
+ this.clientListeners.push(unsubscribe);
110
+ }
111
+ propagateClientStatus(status) {
112
+ const mapped = mapClientStatusToRoom(status);
113
+ const meta = this.metadataSession;
114
+ if (meta) this.dispatchRoomStatus("meta", { roomId: meta.roomId }, mapped, meta.statusEmitter, meta.statusListener);
115
+ for (const session of this.docSessions.values()) this.dispatchRoomStatus("doc", {
116
+ roomId: session.roomId,
117
+ docId: session.docId
118
+ }, mapped, session.statusEmitter, session.statusListener);
119
+ for (const session of this.ephemeralSessions.values()) this.dispatchRoomStatus("ephemeral", { roomId: session.roomId }, mapped, session.statusEmitter, void 0);
120
+ }
121
+ dispatchRoomStatus(kind, payload, status, emitter, listener) {
122
+ emitStatus(emitter, status);
123
+ try {
124
+ listener?.(status);
125
+ } catch (error) {
126
+ debug("room listener error", error);
127
+ }
128
+ try {
129
+ this.options.onRoomStatusChange?.({
130
+ kind,
131
+ ...payload,
132
+ status
133
+ });
134
+ } catch (error) {
135
+ debug("global room status listener error", error);
136
+ }
137
+ }
138
+ cleanupClientListeners() {
139
+ while (this.clientListeners.length > 0) {
140
+ const unsubscribe = this.clientListeners.pop();
141
+ try {
142
+ unsubscribe?.();
143
+ } catch {}
144
+ }
145
+ }
146
+ async connect(options) {
95
147
  const client = this.ensureClient();
96
- debug("connect requested", { status: client.getStatus() });
148
+ debug("connect requested", {
149
+ status: client.getStatus(),
150
+ resetBackoff: Boolean(options?.resetBackoff)
151
+ });
97
152
  try {
98
- await client.connect();
153
+ await client.connect.call(client, options?.resetBackoff ? { resetBackoff: options.resetBackoff } : void 0);
99
154
  debug("client.connect resolved");
100
- await client.waitConnected();
155
+ await withTimeout(client.waitConnected(), options?.timeout);
101
156
  debug("client.waitConnected resolved", { status: client.getStatus() });
102
157
  } catch (error) {
103
158
  debug("connect failed", error);
104
159
  throw error;
105
160
  }
106
161
  }
162
+ async reconnect(options) {
163
+ const client = this.ensureClient();
164
+ debug("reconnect requested", {
165
+ status: client.getStatus(),
166
+ resetBackoff: Boolean(options?.resetBackoff)
167
+ });
168
+ try {
169
+ const connectFn = client.connect;
170
+ if (options?.resetBackoff) await connectFn.call(client, { resetBackoff: true });
171
+ else if (client.retryNow) await client.retryNow.call(client);
172
+ else await connectFn.call(client);
173
+ await withTimeout(client.waitConnected(), options?.timeout);
174
+ debug("reconnect completed", { status: client.getStatus() });
175
+ } catch (error) {
176
+ debug("reconnect failed", error);
177
+ throw error;
178
+ }
179
+ }
107
180
  async close() {
108
181
  debug("close requested", {
109
182
  docSessions: this.docSessions.size,
@@ -111,10 +184,13 @@ var WebSocketTransportAdapter = class {
111
184
  });
112
185
  for (const [docId] of this.docSessions) await this.leaveDocSession(docId).catch(() => {});
113
186
  this.docSessions.clear();
187
+ for (const [roomId] of this.ephemeralSessions) await this.leaveEphemeralSession(roomId).catch(() => {});
188
+ this.ephemeralSessions.clear();
114
189
  await this.teardownMetadataSession().catch(() => {});
115
190
  if (this.client) {
116
191
  const client = this.client;
117
192
  this.client = void 0;
193
+ this.cleanupClientListeners();
118
194
  client.destroy();
119
195
  debug("websocket client destroyed");
120
196
  }
@@ -123,6 +199,30 @@ var WebSocketTransportAdapter = class {
123
199
  isConnected() {
124
200
  return this.client?.getStatus() === "connected";
125
201
  }
202
+ getStatus() {
203
+ return this.ensureClient().getStatus();
204
+ }
205
+ getLatency() {
206
+ return this.ensureClient().getLatency?.();
207
+ }
208
+ onStatusChange(listener) {
209
+ const unsubscribe = this.ensureClient().onStatusChange(listener);
210
+ this.trackClientListener(unsubscribe);
211
+ return () => {
212
+ unsubscribe();
213
+ const idx = this.clientListeners.indexOf(unsubscribe);
214
+ if (idx >= 0) this.clientListeners.splice(idx, 1);
215
+ };
216
+ }
217
+ onLatency(listener) {
218
+ const unsubscribe = this.ensureClient().onLatency(listener);
219
+ this.trackClientListener(unsubscribe);
220
+ return () => {
221
+ unsubscribe();
222
+ const idx = this.clientListeners.indexOf(unsubscribe);
223
+ if (idx >= 0) this.clientListeners.splice(idx, 1);
224
+ };
225
+ }
126
226
  async syncMeta(flock, options) {
127
227
  debug("syncMeta requested", { roomId: this.options.metadataRoomId });
128
228
  try {
@@ -141,9 +241,8 @@ var WebSocketTransportAdapter = class {
141
241
  }
142
242
  }
143
243
  joinMetaRoom(flock, params) {
144
- const fallback = this.options.metadataRoomId ?? "";
145
- const roomId = normalizeRoomId(params?.roomId, fallback);
146
- if (!roomId) throw new Error("Metadata room id not configured");
244
+ const roomId = this.options.metadataRoomId ?? "repo:meta";
245
+ let statusEmitterRef;
147
246
  const session = (async () => {
148
247
  let auth;
149
248
  const authWay = params?.auth ?? this.options.metadataAuth;
@@ -153,10 +252,13 @@ var WebSocketTransportAdapter = class {
153
252
  roomId,
154
253
  hasAuth: Boolean(auth && auth.length)
155
254
  });
156
- return this.ensureMetadataSession(flock, {
255
+ const ensure = this.ensureMetadataSession(flock, {
157
256
  roomId,
158
- auth
257
+ auth,
258
+ onStatusChange: params?.onStatusChange
159
259
  });
260
+ statusEmitterRef = (await ensure).statusEmitter;
261
+ return ensure;
160
262
  })();
161
263
  const firstSynced = session.then((session$1) => session$1.firstSynced);
162
264
  const getConnected = () => this.isConnected();
@@ -177,6 +279,31 @@ var WebSocketTransportAdapter = class {
177
279
  firstSyncedWithRemote: firstSynced,
178
280
  get connected() {
179
281
  return getConnected();
282
+ },
283
+ get status() {
284
+ return statusEmitterRef?.status ?? "connecting";
285
+ },
286
+ onStatusChange: (listener) => {
287
+ const attach = (emitter) => {
288
+ emitter.listeners.add(listener);
289
+ try {
290
+ listener(emitter.status);
291
+ } catch (error) {
292
+ debug("metadata onStatusChange listener error", error);
293
+ }
294
+ return () => emitter.listeners.delete(listener);
295
+ };
296
+ if (statusEmitterRef) {
297
+ const cleanup = attach(statusEmitterRef);
298
+ return () => cleanup();
299
+ }
300
+ let unsubscribed = false;
301
+ const cleanupPromise = session.then((resolved) => attach(resolved.statusEmitter));
302
+ return () => {
303
+ if (unsubscribed) return;
304
+ unsubscribed = true;
305
+ cleanupPromise.then((cleanup) => cleanup()).catch(() => {});
306
+ };
180
307
  }
181
308
  };
182
309
  session.then((session$1) => {
@@ -209,10 +336,13 @@ var WebSocketTransportAdapter = class {
209
336
  joinDocRoom(docId, doc, params) {
210
337
  debug("joinDocRoom requested", {
211
338
  docId,
212
- roomParamType: params?.roomId ? typeof params.roomId === "string" ? "string" : "uint8array" : void 0,
213
339
  hasAuthOverride: Boolean(params?.auth && params.auth.length)
214
340
  });
215
- const ensure = this.ensureDocSession(docId, doc, params ?? {});
341
+ let statusEmitterRef;
342
+ const ensure = this.ensureDocSession(docId, doc, params ?? {}).then((session) => {
343
+ statusEmitterRef = session.statusEmitter;
344
+ return session;
345
+ });
216
346
  const firstSynced = ensure.then((session) => session.firstSynced);
217
347
  const getConnected = () => this.isConnected();
218
348
  const subscription = {
@@ -230,6 +360,31 @@ var WebSocketTransportAdapter = class {
230
360
  firstSyncedWithRemote: firstSynced,
231
361
  get connected() {
232
362
  return getConnected();
363
+ },
364
+ get status() {
365
+ return statusEmitterRef?.status ?? "connecting";
366
+ },
367
+ onStatusChange: (listener) => {
368
+ const attach = (emitter) => {
369
+ emitter.listeners.add(listener);
370
+ try {
371
+ listener(emitter.status);
372
+ } catch (error) {
373
+ debug("doc onStatusChange listener error", error);
374
+ }
375
+ return () => emitter.listeners.delete(listener);
376
+ };
377
+ if (statusEmitterRef) {
378
+ const cleanup = attach(statusEmitterRef);
379
+ return () => cleanup();
380
+ }
381
+ let unsubscribed = false;
382
+ const cleanupPromise = ensure.then((session) => attach(session.statusEmitter));
383
+ return () => {
384
+ if (unsubscribed) return;
385
+ unsubscribed = true;
386
+ cleanupPromise.then((cleanup) => cleanup()).catch(() => {});
387
+ };
233
388
  }
234
389
  };
235
390
  ensure.then((session) => {
@@ -242,6 +397,69 @@ var WebSocketTransportAdapter = class {
242
397
  });
243
398
  return subscription;
244
399
  }
400
+ joinEphemeralRoom(roomId) {
401
+ debug("joinEphemeralRoom requested", { roomId });
402
+ let statusEmitterRef;
403
+ const ensure = this.ensureEphemeralSession(roomId).then((session) => {
404
+ statusEmitterRef = session.statusEmitter;
405
+ return session;
406
+ });
407
+ const store = this.ephemeralSessions.get(roomId)?.store;
408
+ if (!store) throw new Error("Failed to initialize ephemeral session");
409
+ const firstSynced = ensure.then((session) => session.firstSynced);
410
+ const getConnected = () => this.isConnected();
411
+ const subscription = {
412
+ store,
413
+ unsubscribe: () => {
414
+ ensure.then((session) => {
415
+ session.refCount = Math.max(0, session.refCount - 1);
416
+ debug("ephemeral session refCount decremented", {
417
+ roomId,
418
+ refCount: session.refCount
419
+ });
420
+ if (session.refCount === 0) this.leaveEphemeralSession(roomId).catch(() => {});
421
+ });
422
+ },
423
+ firstSyncedWithRemote: firstSynced,
424
+ get connected() {
425
+ return getConnected();
426
+ },
427
+ get status() {
428
+ return statusEmitterRef?.status ?? "connecting";
429
+ },
430
+ onStatusChange: (listener) => {
431
+ const attach = (emitter) => {
432
+ emitter.listeners.add(listener);
433
+ try {
434
+ listener(emitter.status);
435
+ } catch (error) {
436
+ debug("ephemeral onStatusChange listener error", error);
437
+ }
438
+ return () => emitter.listeners.delete(listener);
439
+ };
440
+ if (statusEmitterRef) {
441
+ const cleanup = attach(statusEmitterRef);
442
+ return () => cleanup();
443
+ }
444
+ let unsubscribed = false;
445
+ const cleanupPromise = ensure.then((session) => attach(session.statusEmitter));
446
+ return () => {
447
+ if (unsubscribed) return;
448
+ unsubscribed = true;
449
+ cleanupPromise.then((cleanup) => cleanup()).catch(() => {});
450
+ };
451
+ }
452
+ };
453
+ ensure.then((session) => {
454
+ subscription.store = session.store;
455
+ session.refCount += 1;
456
+ debug("ephemeral session refCount incremented", {
457
+ roomId,
458
+ refCount: session.refCount
459
+ });
460
+ });
461
+ return subscription;
462
+ }
245
463
  ensureClient() {
246
464
  if (this.client) {
247
465
  debug("reusing websocket client", { status: this.client.getStatus() });
@@ -256,9 +474,18 @@ var WebSocketTransportAdapter = class {
256
474
  url,
257
475
  ...clientOptions
258
476
  });
477
+ this.trackClientListener(client.onStatusChange((status) => {
478
+ this.propagateClientStatus(status);
479
+ this.options.onStatusChange?.(status);
480
+ }));
481
+ if (this.options.onLatency) this.trackClientListener(client.onLatency(this.options.onLatency));
259
482
  this.client = client;
483
+ this.propagateClientStatus(client.getStatus());
260
484
  return client;
261
485
  }
486
+ get websocketClient() {
487
+ return this.ensureClient();
488
+ }
262
489
  async ensureMetadataSession(flock, params) {
263
490
  debug("ensureMetadataSession invoked", {
264
491
  roomId: params.roomId,
@@ -279,6 +506,9 @@ var WebSocketTransportAdapter = class {
279
506
  await this.teardownMetadataSession(this.metadataSession).catch(() => {});
280
507
  }
281
508
  const adaptor = new loro_adaptors_flock.FlockAdaptor(flock, this.options.metadataAdaptorConfig);
509
+ const statusEmitter = createStatusEmitter("connecting");
510
+ if (params.onStatusChange) statusEmitter.listeners.add(params.onStatusChange);
511
+ this.dispatchRoomStatus("meta", { roomId: params.roomId }, "connecting", statusEmitter, params.onStatusChange);
282
512
  debug("joining metadata room", {
283
513
  roomId: params.roomId,
284
514
  hasAuth: Boolean(params.auth && params.auth.length)
@@ -288,6 +518,7 @@ var WebSocketTransportAdapter = class {
288
518
  crdtAdaptor: adaptor,
289
519
  auth: params.auth
290
520
  });
521
+ this.dispatchRoomStatus("meta", { roomId: params.roomId }, "joined", statusEmitter, params.onStatusChange);
291
522
  const firstSynced = room.waitForReachingServerVersion();
292
523
  firstSynced.then(() => {
293
524
  debug("metadata session firstSynced resolved", { roomId: params.roomId });
@@ -304,7 +535,9 @@ var WebSocketTransportAdapter = class {
304
535
  flock,
305
536
  roomId: params.roomId,
306
537
  auth: params.auth,
307
- refCount: 0
538
+ refCount: 0,
539
+ statusEmitter,
540
+ statusListener: params.onStatusChange
308
541
  };
309
542
  this.metadataSession = session;
310
543
  return session;
@@ -326,6 +559,7 @@ var WebSocketTransportAdapter = class {
326
559
  await room.destroy().catch(() => {});
327
560
  }
328
561
  adaptor.destroy();
562
+ this.dispatchRoomStatus("meta", { roomId: target.roomId }, "disconnected", target.statusEmitter, target.statusListener);
329
563
  debug("metadata session destroyed", { roomId: target.roomId });
330
564
  }
331
565
  async ensureDocSession(docId, doc, params) {
@@ -337,8 +571,7 @@ var WebSocketTransportAdapter = class {
337
571
  status: client.getStatus()
338
572
  });
339
573
  const existing = this.docSessions.get(docId);
340
- const derivedRoomId = this.options.docRoomId?.(docId) ?? docId;
341
- const roomId = normalizeRoomId(params.roomId, derivedRoomId);
574
+ const roomId = this.options.docRoomId?.(docId) ?? docId;
342
575
  let auth;
343
576
  auth = await (params.auth ?? this.options.docAuth?.(docId));
344
577
  debug("doc session params resolved", {
@@ -363,6 +596,12 @@ var WebSocketTransportAdapter = class {
363
596
  await this.leaveDocSession(docId).catch(() => {});
364
597
  }
365
598
  const adaptor = new loro_adaptors_loro.LoroAdaptor(doc);
599
+ const statusEmitter = createStatusEmitter("connecting");
600
+ if (params.onStatusChange) statusEmitter.listeners.add(params.onStatusChange);
601
+ this.dispatchRoomStatus("doc", {
602
+ roomId,
603
+ docId
604
+ }, "connecting", statusEmitter, params.onStatusChange);
366
605
  debug("joining doc room", {
367
606
  docId,
368
607
  roomId,
@@ -373,6 +612,10 @@ var WebSocketTransportAdapter = class {
373
612
  crdtAdaptor: adaptor,
374
613
  auth
375
614
  });
615
+ this.dispatchRoomStatus("doc", {
616
+ roomId,
617
+ docId
618
+ }, "joined", statusEmitter, params.onStatusChange);
376
619
  const firstSynced = room.waitForReachingServerVersion();
377
620
  firstSynced.then(() => {
378
621
  debug("doc session firstSynced resolved", {
@@ -392,11 +635,66 @@ var WebSocketTransportAdapter = class {
392
635
  firstSynced,
393
636
  doc,
394
637
  roomId,
395
- refCount: 0
638
+ docId,
639
+ refCount: 0,
640
+ statusEmitter,
641
+ statusListener: params.onStatusChange
396
642
  };
397
643
  this.docSessions.set(docId, session);
398
644
  return session;
399
645
  }
646
+ async ensureEphemeralSession(roomId) {
647
+ debug("ensureEphemeralSession invoked", { roomId });
648
+ const existing = this.ephemeralSessions.get(roomId);
649
+ if (existing) {
650
+ debug("reusing ephemeral session", {
651
+ roomId,
652
+ refCount: existing.refCount
653
+ });
654
+ return existing;
655
+ }
656
+ const adaptor = new loro_adaptors_loro.LoroEphemeralAdaptor();
657
+ const statusEmitter = createStatusEmitter("connecting");
658
+ this.dispatchRoomStatus("ephemeral", { roomId }, "connecting", statusEmitter, void 0);
659
+ const session = {
660
+ adaptor,
661
+ store: adaptor.getStore(),
662
+ roomId,
663
+ firstSynced: Promise.resolve(),
664
+ refCount: 0,
665
+ statusEmitter
666
+ };
667
+ this.ephemeralSessions.set(roomId, session);
668
+ const client = this.ensureClient();
669
+ await client.waitConnected();
670
+ debug("websocket client ready for ephemeral session", {
671
+ roomId,
672
+ status: client.getStatus()
673
+ });
674
+ try {
675
+ const room = await client.join({
676
+ roomId,
677
+ crdtAdaptor: adaptor
678
+ });
679
+ this.dispatchRoomStatus("ephemeral", { roomId }, "joined", statusEmitter, void 0);
680
+ const firstSynced = room.waitForReachingServerVersion();
681
+ firstSynced.then(() => {
682
+ debug("ephemeral session firstSynced resolved", { roomId });
683
+ }, (error) => {
684
+ debug("ephemeral session firstSynced rejected", {
685
+ roomId,
686
+ error
687
+ });
688
+ });
689
+ session.room = room;
690
+ session.firstSynced = firstSynced;
691
+ return session;
692
+ } catch (error) {
693
+ this.ephemeralSessions.delete(roomId);
694
+ adaptor.destroy();
695
+ throw error;
696
+ }
697
+ }
400
698
  async leaveDocSession(docId) {
401
699
  const session = this.docSessions.get(docId);
402
700
  if (!session) {
@@ -423,11 +721,37 @@ var WebSocketTransportAdapter = class {
423
721
  await session.room.destroy().catch(() => {});
424
722
  }
425
723
  session.adaptor.destroy();
724
+ this.dispatchRoomStatus("doc", {
725
+ roomId: session.roomId,
726
+ docId: session.docId
727
+ }, "disconnected", session.statusEmitter, session.statusListener);
426
728
  debug("doc session destroyed", {
427
729
  docId,
428
730
  roomId: session.roomId
429
731
  });
430
732
  }
733
+ async leaveEphemeralSession(roomId) {
734
+ const session = this.ephemeralSessions.get(roomId);
735
+ if (!session) {
736
+ debug("leaveEphemeralSession invoked but no session found", { roomId });
737
+ return;
738
+ }
739
+ this.ephemeralSessions.delete(roomId);
740
+ debug("leaving ephemeral session", { roomId });
741
+ try {
742
+ await session.room?.leave();
743
+ debug("ephemeral room left", { roomId });
744
+ } catch (error) {
745
+ debug("ephemeral room leave failed; destroying", {
746
+ roomId,
747
+ error
748
+ });
749
+ await session.room?.destroy().catch(() => {});
750
+ }
751
+ session.adaptor.destroy();
752
+ this.dispatchRoomStatus("ephemeral", { roomId: session.roomId }, "disconnected", session.statusEmitter, void 0);
753
+ debug("ephemeral session destroyed", { roomId });
754
+ }
431
755
  };
432
756
 
433
757
  //#endregion