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