@koolbase/core 12.10.0 → 12.11.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.
@@ -8,6 +8,13 @@ export interface CollectionQuery {
8
8
  orderDesc?: boolean;
9
9
  /** Page size; the server's default (20) when absent. */
10
10
  limit?: number;
11
+ /**
12
+ * Re-read page one, silently, when Koolbase realtime reports a record
13
+ * created, updated or deleted in this collection (debounced: a burst is one
14
+ * read). Realtime needs a signed-in user; until there is one, the list
15
+ * behaves as a normal list and goes live once the session exists.
16
+ */
17
+ live?: boolean;
11
18
  }
12
19
  export interface CollectionState {
13
20
  readonly status: CollectionStatus;
@@ -39,6 +46,8 @@ export declare class KoolbaseCollectionController {
39
46
  private laterPages;
40
47
  private nextOffset;
41
48
  private unregister;
49
+ private unlive;
50
+ private liveTimer;
42
51
  constructor(db: QueryReader, collection: string, query?: CollectionQuery);
43
52
  getState(): CollectionState;
44
53
  /** Called on every change. Returns the unsubscribe. */
@@ -53,6 +62,11 @@ export declare class KoolbaseCollectionController {
53
62
  * shown stays while it runs, and stays if it fails.
54
63
  */
55
64
  private refreshAfterWrite;
65
+ /**
66
+ * A change someone made, reported by realtime: page one again, silently, as
67
+ * after a write -- once per burst (250 ms), not once per event.
68
+ */
69
+ private scheduleLiveRefresh;
56
70
  /**
57
71
  * The next page, appended. Does nothing unless loaded with more to load.
58
72
  * If rows are added or removed on the server between pages, one can be
@@ -25,6 +25,7 @@
25
25
  Object.defineProperty(exports, "__esModule", { value: true });
26
26
  exports.KoolbaseCollectionController = exports.initialCollectionState = void 0;
27
27
  exports.collectionQueryKey = collectionQueryKey;
28
+ const live_source_js_1 = require("./live-source.js");
28
29
  const query_refresh_js_1 = require("./query-refresh.js");
29
30
  exports.initialCollectionState = Object.freeze({
30
31
  status: 'loading',
@@ -49,6 +50,8 @@ function collectionQueryKey(collection, query = {}) {
49
50
  query.orderBy ?? null,
50
51
  query.orderDesc === true,
51
52
  query.limit ?? null,
53
+ // Only when live: every other query keeps the key it always had.
54
+ ...(query.live === true ? ['live'] : []),
52
55
  ]);
53
56
  }
54
57
  /** Page one, then the later pages without any record already shown. */
@@ -76,6 +79,9 @@ class KoolbaseCollectionController {
76
79
  this.nextOffset = 0;
77
80
  // A write to this collection re-runs this query (query-refresh.ts).
78
81
  this.unregister = null;
82
+ // A live list's realtime subscription, and its pending debounced re-read.
83
+ this.unlive = null;
84
+ this.liveTimer = null;
79
85
  this.db = db;
80
86
  this.collection = collection;
81
87
  this.spec = query;
@@ -97,6 +103,9 @@ class KoolbaseCollectionController {
97
103
  if (this.disposed)
98
104
  return;
99
105
  this.unregister ?? (this.unregister = (0, query_refresh_js_1.registerQueryRefresher)(this.collection, () => this.refreshAfterWrite()));
106
+ if (this.spec.live === true) {
107
+ this.unlive ?? (this.unlive = (0, live_source_js_1.liveSource)()?.subscribe(this.collection, () => this.scheduleLiveRefresh()) ?? null);
108
+ }
100
109
  await this.firstPage('default');
101
110
  }
102
111
  /** Page one again, from the server. What is shown stays while it runs, and stays if it fails. */
@@ -118,6 +127,20 @@ class KoolbaseCollectionController {
118
127
  return;
119
128
  await this.firstPage('network-only');
120
129
  }
130
+ /**
131
+ * A change someone made, reported by realtime: page one again, silently, as
132
+ * after a write -- once per burst (250 ms), not once per event.
133
+ */
134
+ scheduleLiveRefresh() {
135
+ if (this.disposed || this.liveTimer)
136
+ return;
137
+ this.liveTimer = setTimeout(() => {
138
+ this.liveTimer = null;
139
+ void this.refreshAfterWrite();
140
+ }, 250);
141
+ // A pending re-read must not be what keeps a Node process alive.
142
+ this.liveTimer.unref?.();
143
+ }
121
144
  /**
122
145
  * The next page, appended. Does nothing unless loaded with more to load.
123
146
  * If rows are added or removed on the server between pages, one can be
@@ -153,6 +176,12 @@ class KoolbaseCollectionController {
153
176
  this.disposed = true;
154
177
  this.unregister?.();
155
178
  this.unregister = null;
179
+ this.unlive?.();
180
+ this.unlive = null;
181
+ if (this.liveTimer) {
182
+ clearTimeout(this.liveTimer);
183
+ this.liveTimer = null;
184
+ }
156
185
  this.listeners.clear();
157
186
  }
158
187
  async firstPage(cache) {
@@ -0,0 +1,6 @@
1
+ export interface LiveSource {
2
+ /** Calls back on every record event in the collection; returns the unsubscribe. */
3
+ subscribe(collection: string, callback: (event: unknown) => void): () => void;
4
+ }
5
+ export declare function setLiveSource(source: LiveSource | null): void;
6
+ export declare function liveSource(): LiveSource | null;
@@ -0,0 +1,16 @@
1
+ "use strict";
2
+ // The realtime client a live list subscribes through. KoolbaseRealtime
3
+ // registers itself when it is created, as query-refresh.ts registers the
4
+ // refreshers writes run: a list reaches realtime without the host packages
5
+ // passing it in. None registered (realtime never created): a live list is
6
+ // simply a list.
7
+ Object.defineProperty(exports, "__esModule", { value: true });
8
+ exports.setLiveSource = setLiveSource;
9
+ exports.liveSource = liveSource;
10
+ let active = null;
11
+ function setLiveSource(source) {
12
+ active = source;
13
+ }
14
+ function liveSource() {
15
+ return active;
16
+ }
@@ -9,6 +9,11 @@ export declare class KoolbaseRealtime {
9
9
  private reconnectAttempts;
10
10
  private reconnectTimer;
11
11
  private connecting;
12
+ private idleTimer;
13
+ /** How long an unused connection stays open, in ms: a screen that comes
14
+ * straight back (navigating back, React's development double start)
15
+ * reuses it instead of opening another. */
16
+ static idleGraceMs: number;
12
17
  /**
13
18
  * Identifies whose cache a seen record belongs in.
14
19
  *
@@ -39,6 +44,13 @@ export declare class KoolbaseRealtime {
39
44
  * does not accumulate delay.
40
45
  */
41
46
  private scheduleReconnect;
47
+ /**
48
+ * Closes the connection once nobody is subscribed -- after a moment, not at
49
+ * once: a screen that unmounts and mounts again keeps the connection it had.
50
+ * A subscribe within the moment cancels the close. Nothing reconnects after
51
+ * it: reconnecting needs a subscriber.
52
+ */
53
+ private scheduleIdleClose;
42
54
  disconnect(): void;
43
55
  }
44
56
  export {};
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.KoolbaseRealtime = void 0;
4
4
  const cache_store_js_1 = require("./cache-store.js");
5
5
  const record_js_1 = require("./record.js");
6
+ const live_source_js_1 = require("./live-source.js");
6
7
  const EVENT_TYPE_MAP = {
7
8
  'db.record.created': 'created',
8
9
  'db.record.updated': 'updated',
@@ -40,9 +41,14 @@ class KoolbaseRealtime {
40
41
  this.reconnectAttempts = 0;
41
42
  this.reconnectTimer = null;
42
43
  this.connecting = false;
44
+ // Pending close of an unused connection (scheduleIdleClose); null while
45
+ // anyone is subscribed.
46
+ this.idleTimer = null;
43
47
  this.config = config;
44
48
  this.getToken = getToken;
45
49
  this.getUserId = getUserId;
50
+ // Live lists (CollectionQuery.live) subscribe through this client.
51
+ (0, live_source_js_1.setLiveSource)(this);
46
52
  }
47
53
  /** Files a record seen over the socket, if we know whose it is. */
48
54
  async cacheSeenRecord(collection, record) {
@@ -58,6 +64,10 @@ class KoolbaseRealtime {
58
64
  await (0, cache_store_js_1.removeCachedRecord)(userId, recordId);
59
65
  }
60
66
  subscribe(collection, callback) {
67
+ if (this.idleTimer) {
68
+ clearTimeout(this.idleTimer);
69
+ this.idleTimer = null;
70
+ }
61
71
  if (!this.listeners.has(collection))
62
72
  this.listeners.set(collection, []);
63
73
  this.listeners.get(collection).push(callback);
@@ -68,13 +78,16 @@ class KoolbaseRealtime {
68
78
  void this.connect();
69
79
  }
70
80
  return () => {
71
- const callbacks = this.listeners.get(collection) ?? [];
72
- const i = callbacks.indexOf(callback);
73
- if (i > -1)
74
- callbacks.splice(i, 1);
81
+ const callbacks = this.listeners.get(collection);
82
+ const i = callbacks ? callbacks.indexOf(callback) : -1;
83
+ if (!callbacks || i === -1)
84
+ return; // already unsubscribed: a second call does nothing
85
+ callbacks.splice(i, 1);
75
86
  if (callbacks.length === 0) {
76
87
  this.listeners.delete(collection);
77
88
  this.sendUnsubscribe(collection);
89
+ if (this.listeners.size === 0)
90
+ this.scheduleIdleClose();
78
91
  }
79
92
  };
80
93
  }
@@ -83,15 +96,39 @@ class KoolbaseRealtime {
83
96
  return;
84
97
  if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING))
85
98
  return;
86
- const token = await this.getToken();
99
+ // Claimed BEFORE the token is awaited. Claimed after it, every subscribe
100
+ // arriving meanwhile -- two live lists on one screen, React's development
101
+ // double start -- found nobody connecting and opened its own socket; only
102
+ // the last was remembered, and the others stayed open for good.
103
+ this.connecting = true;
104
+ let token;
105
+ try {
106
+ token = await this.getToken();
107
+ }
108
+ catch {
109
+ token = null;
110
+ }
111
+ if (this.listeners.size === 0) {
112
+ // Everyone left while the token was on its way: nothing to connect for.
113
+ this.connecting = false;
114
+ return;
115
+ }
87
116
  if (!token) {
117
+ this.connecting = false;
88
118
  this.scheduleReconnect(); // sign-in may be in flight
89
119
  return;
90
120
  }
91
121
  this.projectId = projectIdFromToken(token);
92
- this.connecting = true;
93
122
  const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
94
- const ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
123
+ let ws;
124
+ try {
125
+ ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
126
+ }
127
+ catch {
128
+ this.connecting = false;
129
+ this.scheduleReconnect();
130
+ return;
131
+ }
95
132
  this.ws = ws;
96
133
  ws.onopen = () => {
97
134
  this.connecting = false;
@@ -184,11 +221,47 @@ class KoolbaseRealtime {
184
221
  }, delay);
185
222
  this.reconnectTimer.unref?.();
186
223
  }
224
+ /**
225
+ * Closes the connection once nobody is subscribed -- after a moment, not at
226
+ * once: a screen that unmounts and mounts again keeps the connection it had.
227
+ * A subscribe within the moment cancels the close. Nothing reconnects after
228
+ * it: reconnecting needs a subscriber.
229
+ */
230
+ scheduleIdleClose() {
231
+ if (this.idleTimer)
232
+ clearTimeout(this.idleTimer);
233
+ this.idleTimer = setTimeout(() => {
234
+ this.idleTimer = null;
235
+ if (this.listeners.size > 0)
236
+ return;
237
+ if (this.reconnectTimer) {
238
+ clearTimeout(this.reconnectTimer);
239
+ this.reconnectTimer = null;
240
+ }
241
+ const ws = this.ws;
242
+ this.ws = null;
243
+ this.connecting = false;
244
+ if (ws) {
245
+ // Detached first: this close is ours, not a dropped connection.
246
+ ws.onopen = null;
247
+ ws.onmessage = null;
248
+ ws.onclose = null;
249
+ ws.onerror = null;
250
+ ws.close();
251
+ }
252
+ }, KoolbaseRealtime.idleGraceMs);
253
+ this.idleTimer.unref?.();
254
+ }
187
255
  disconnect() {
256
+ if (this.idleTimer) {
257
+ clearTimeout(this.idleTimer);
258
+ this.idleTimer = null;
259
+ }
188
260
  if (this.reconnectTimer) {
189
261
  clearTimeout(this.reconnectTimer);
190
262
  this.reconnectTimer = null;
191
263
  }
264
+ this.connecting = false;
192
265
  this.ws?.close();
193
266
  this.ws = null;
194
267
  this.projectId = null;
@@ -196,3 +269,7 @@ class KoolbaseRealtime {
196
269
  }
197
270
  }
198
271
  exports.KoolbaseRealtime = KoolbaseRealtime;
272
+ /** How long an unused connection stays open, in ms: a screen that comes
273
+ * straight back (navigating back, React's development double start)
274
+ * reuses it instead of opening another. */
275
+ KoolbaseRealtime.idleGraceMs = 1000;
@@ -8,6 +8,13 @@ export interface CollectionQuery {
8
8
  orderDesc?: boolean;
9
9
  /** Page size; the server's default (20) when absent. */
10
10
  limit?: number;
11
+ /**
12
+ * Re-read page one, silently, when Koolbase realtime reports a record
13
+ * created, updated or deleted in this collection (debounced: a burst is one
14
+ * read). Realtime needs a signed-in user; until there is one, the list
15
+ * behaves as a normal list and goes live once the session exists.
16
+ */
17
+ live?: boolean;
11
18
  }
12
19
  export interface CollectionState {
13
20
  readonly status: CollectionStatus;
@@ -39,6 +46,8 @@ export declare class KoolbaseCollectionController {
39
46
  private laterPages;
40
47
  private nextOffset;
41
48
  private unregister;
49
+ private unlive;
50
+ private liveTimer;
42
51
  constructor(db: QueryReader, collection: string, query?: CollectionQuery);
43
52
  getState(): CollectionState;
44
53
  /** Called on every change. Returns the unsubscribe. */
@@ -53,6 +62,11 @@ export declare class KoolbaseCollectionController {
53
62
  * shown stays while it runs, and stays if it fails.
54
63
  */
55
64
  private refreshAfterWrite;
65
+ /**
66
+ * A change someone made, reported by realtime: page one again, silently, as
67
+ * after a write -- once per burst (250 ms), not once per event.
68
+ */
69
+ private scheduleLiveRefresh;
56
70
  /**
57
71
  * The next page, appended. Does nothing unless loaded with more to load.
58
72
  * If rows are added or removed on the server between pages, one can be
@@ -21,6 +21,7 @@
21
21
  // Known limit: if rows are added or removed on the server between pages,
22
22
  // the pages shift and a row can be skipped until refresh(), which reloads
23
23
  // from page one. Duplicates are removed; a skipped row cannot be detected.
24
+ import { liveSource } from './live-source.js';
24
25
  import { registerQueryRefresher } from './query-refresh.js';
25
26
  export const initialCollectionState = Object.freeze({
26
27
  status: 'loading',
@@ -45,6 +46,8 @@ export function collectionQueryKey(collection, query = {}) {
45
46
  query.orderBy ?? null,
46
47
  query.orderDesc === true,
47
48
  query.limit ?? null,
49
+ // Only when live: every other query keeps the key it always had.
50
+ ...(query.live === true ? ['live'] : []),
48
51
  ]);
49
52
  }
50
53
  /** Page one, then the later pages without any record already shown. */
@@ -72,6 +75,9 @@ export class KoolbaseCollectionController {
72
75
  this.nextOffset = 0;
73
76
  // A write to this collection re-runs this query (query-refresh.ts).
74
77
  this.unregister = null;
78
+ // A live list's realtime subscription, and its pending debounced re-read.
79
+ this.unlive = null;
80
+ this.liveTimer = null;
75
81
  this.db = db;
76
82
  this.collection = collection;
77
83
  this.spec = query;
@@ -93,6 +99,9 @@ export class KoolbaseCollectionController {
93
99
  if (this.disposed)
94
100
  return;
95
101
  this.unregister ?? (this.unregister = registerQueryRefresher(this.collection, () => this.refreshAfterWrite()));
102
+ if (this.spec.live === true) {
103
+ this.unlive ?? (this.unlive = liveSource()?.subscribe(this.collection, () => this.scheduleLiveRefresh()) ?? null);
104
+ }
96
105
  await this.firstPage('default');
97
106
  }
98
107
  /** Page one again, from the server. What is shown stays while it runs, and stays if it fails. */
@@ -114,6 +123,20 @@ export class KoolbaseCollectionController {
114
123
  return;
115
124
  await this.firstPage('network-only');
116
125
  }
126
+ /**
127
+ * A change someone made, reported by realtime: page one again, silently, as
128
+ * after a write -- once per burst (250 ms), not once per event.
129
+ */
130
+ scheduleLiveRefresh() {
131
+ if (this.disposed || this.liveTimer)
132
+ return;
133
+ this.liveTimer = setTimeout(() => {
134
+ this.liveTimer = null;
135
+ void this.refreshAfterWrite();
136
+ }, 250);
137
+ // A pending re-read must not be what keeps a Node process alive.
138
+ this.liveTimer.unref?.();
139
+ }
117
140
  /**
118
141
  * The next page, appended. Does nothing unless loaded with more to load.
119
142
  * If rows are added or removed on the server between pages, one can be
@@ -149,6 +172,12 @@ export class KoolbaseCollectionController {
149
172
  this.disposed = true;
150
173
  this.unregister?.();
151
174
  this.unregister = null;
175
+ this.unlive?.();
176
+ this.unlive = null;
177
+ if (this.liveTimer) {
178
+ clearTimeout(this.liveTimer);
179
+ this.liveTimer = null;
180
+ }
152
181
  this.listeners.clear();
153
182
  }
154
183
  async firstPage(cache) {
@@ -0,0 +1,6 @@
1
+ export interface LiveSource {
2
+ /** Calls back on every record event in the collection; returns the unsubscribe. */
3
+ subscribe(collection: string, callback: (event: unknown) => void): () => void;
4
+ }
5
+ export declare function setLiveSource(source: LiveSource | null): void;
6
+ export declare function liveSource(): LiveSource | null;
@@ -0,0 +1,12 @@
1
+ // The realtime client a live list subscribes through. KoolbaseRealtime
2
+ // registers itself when it is created, as query-refresh.ts registers the
3
+ // refreshers writes run: a list reaches realtime without the host packages
4
+ // passing it in. None registered (realtime never created): a live list is
5
+ // simply a list.
6
+ let active = null;
7
+ export function setLiveSource(source) {
8
+ active = source;
9
+ }
10
+ export function liveSource() {
11
+ return active;
12
+ }
@@ -9,6 +9,11 @@ export declare class KoolbaseRealtime {
9
9
  private reconnectAttempts;
10
10
  private reconnectTimer;
11
11
  private connecting;
12
+ private idleTimer;
13
+ /** How long an unused connection stays open, in ms: a screen that comes
14
+ * straight back (navigating back, React's development double start)
15
+ * reuses it instead of opening another. */
16
+ static idleGraceMs: number;
12
17
  /**
13
18
  * Identifies whose cache a seen record belongs in.
14
19
  *
@@ -39,6 +44,13 @@ export declare class KoolbaseRealtime {
39
44
  * does not accumulate delay.
40
45
  */
41
46
  private scheduleReconnect;
47
+ /**
48
+ * Closes the connection once nobody is subscribed -- after a moment, not at
49
+ * once: a screen that unmounts and mounts again keeps the connection it had.
50
+ * A subscribe within the moment cancels the close. Nothing reconnects after
51
+ * it: reconnecting needs a subscriber.
52
+ */
53
+ private scheduleIdleClose;
42
54
  disconnect(): void;
43
55
  }
44
56
  export {};
@@ -1,5 +1,6 @@
1
1
  import { cacheRecord, removeCachedRecord } from './cache-store.js';
2
2
  import { recordFromWire } from './record.js';
3
+ import { setLiveSource } from './live-source.js';
3
4
  const EVENT_TYPE_MAP = {
4
5
  'db.record.created': 'created',
5
6
  'db.record.updated': 'updated',
@@ -37,9 +38,14 @@ export class KoolbaseRealtime {
37
38
  this.reconnectAttempts = 0;
38
39
  this.reconnectTimer = null;
39
40
  this.connecting = false;
41
+ // Pending close of an unused connection (scheduleIdleClose); null while
42
+ // anyone is subscribed.
43
+ this.idleTimer = null;
40
44
  this.config = config;
41
45
  this.getToken = getToken;
42
46
  this.getUserId = getUserId;
47
+ // Live lists (CollectionQuery.live) subscribe through this client.
48
+ setLiveSource(this);
43
49
  }
44
50
  /** Files a record seen over the socket, if we know whose it is. */
45
51
  async cacheSeenRecord(collection, record) {
@@ -55,6 +61,10 @@ export class KoolbaseRealtime {
55
61
  await removeCachedRecord(userId, recordId);
56
62
  }
57
63
  subscribe(collection, callback) {
64
+ if (this.idleTimer) {
65
+ clearTimeout(this.idleTimer);
66
+ this.idleTimer = null;
67
+ }
58
68
  if (!this.listeners.has(collection))
59
69
  this.listeners.set(collection, []);
60
70
  this.listeners.get(collection).push(callback);
@@ -65,13 +75,16 @@ export class KoolbaseRealtime {
65
75
  void this.connect();
66
76
  }
67
77
  return () => {
68
- const callbacks = this.listeners.get(collection) ?? [];
69
- const i = callbacks.indexOf(callback);
70
- if (i > -1)
71
- callbacks.splice(i, 1);
78
+ const callbacks = this.listeners.get(collection);
79
+ const i = callbacks ? callbacks.indexOf(callback) : -1;
80
+ if (!callbacks || i === -1)
81
+ return; // already unsubscribed: a second call does nothing
82
+ callbacks.splice(i, 1);
72
83
  if (callbacks.length === 0) {
73
84
  this.listeners.delete(collection);
74
85
  this.sendUnsubscribe(collection);
86
+ if (this.listeners.size === 0)
87
+ this.scheduleIdleClose();
75
88
  }
76
89
  };
77
90
  }
@@ -80,15 +93,39 @@ export class KoolbaseRealtime {
80
93
  return;
81
94
  if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING))
82
95
  return;
83
- const token = await this.getToken();
96
+ // Claimed BEFORE the token is awaited. Claimed after it, every subscribe
97
+ // arriving meanwhile -- two live lists on one screen, React's development
98
+ // double start -- found nobody connecting and opened its own socket; only
99
+ // the last was remembered, and the others stayed open for good.
100
+ this.connecting = true;
101
+ let token;
102
+ try {
103
+ token = await this.getToken();
104
+ }
105
+ catch {
106
+ token = null;
107
+ }
108
+ if (this.listeners.size === 0) {
109
+ // Everyone left while the token was on its way: nothing to connect for.
110
+ this.connecting = false;
111
+ return;
112
+ }
84
113
  if (!token) {
114
+ this.connecting = false;
85
115
  this.scheduleReconnect(); // sign-in may be in flight
86
116
  return;
87
117
  }
88
118
  this.projectId = projectIdFromToken(token);
89
- this.connecting = true;
90
119
  const wsUrl = this.config.baseUrl.replace('https://', 'wss://').replace('http://', 'ws://');
91
- const ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
120
+ let ws;
121
+ try {
122
+ ws = new WebSocket(`${wsUrl}/v1/realtime/ws?token=${encodeURIComponent(token)}`);
123
+ }
124
+ catch {
125
+ this.connecting = false;
126
+ this.scheduleReconnect();
127
+ return;
128
+ }
92
129
  this.ws = ws;
93
130
  ws.onopen = () => {
94
131
  this.connecting = false;
@@ -181,14 +218,54 @@ export class KoolbaseRealtime {
181
218
  }, delay);
182
219
  this.reconnectTimer.unref?.();
183
220
  }
221
+ /**
222
+ * Closes the connection once nobody is subscribed -- after a moment, not at
223
+ * once: a screen that unmounts and mounts again keeps the connection it had.
224
+ * A subscribe within the moment cancels the close. Nothing reconnects after
225
+ * it: reconnecting needs a subscriber.
226
+ */
227
+ scheduleIdleClose() {
228
+ if (this.idleTimer)
229
+ clearTimeout(this.idleTimer);
230
+ this.idleTimer = setTimeout(() => {
231
+ this.idleTimer = null;
232
+ if (this.listeners.size > 0)
233
+ return;
234
+ if (this.reconnectTimer) {
235
+ clearTimeout(this.reconnectTimer);
236
+ this.reconnectTimer = null;
237
+ }
238
+ const ws = this.ws;
239
+ this.ws = null;
240
+ this.connecting = false;
241
+ if (ws) {
242
+ // Detached first: this close is ours, not a dropped connection.
243
+ ws.onopen = null;
244
+ ws.onmessage = null;
245
+ ws.onclose = null;
246
+ ws.onerror = null;
247
+ ws.close();
248
+ }
249
+ }, KoolbaseRealtime.idleGraceMs);
250
+ this.idleTimer.unref?.();
251
+ }
184
252
  disconnect() {
253
+ if (this.idleTimer) {
254
+ clearTimeout(this.idleTimer);
255
+ this.idleTimer = null;
256
+ }
185
257
  if (this.reconnectTimer) {
186
258
  clearTimeout(this.reconnectTimer);
187
259
  this.reconnectTimer = null;
188
260
  }
261
+ this.connecting = false;
189
262
  this.ws?.close();
190
263
  this.ws = null;
191
264
  this.projectId = null;
192
265
  this.listeners.clear();
193
266
  }
194
267
  }
268
+ /** How long an unused connection stays open, in ms: a screen that comes
269
+ * straight back (navigating back, React's development double start)
270
+ * reuses it instead of opening another. */
271
+ KoolbaseRealtime.idleGraceMs = 1000;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@koolbase/core",
3
- "version": "12.10.0",
3
+ "version": "12.11.1",
4
4
  "description": "Koolbase SDK core \u2014 shared behaviour behind @koolbase/react-native and @koolbase/js. Install one of those, not this.",
5
5
  "main": "./dist/cjs/index.js",
6
6
  "types": "./dist/esm/index.d.ts",