@onehat/data 1.22.38 → 1.22.39

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@onehat/data",
3
- "version": "1.22.38",
3
+ "version": "1.22.39",
4
4
  "description": "JS data modeling package with adapters for many storage mediums.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -3,6 +3,7 @@
3
3
  import OfflineRepository from '@onehat/data/src/Repository/Offline.js';
4
4
  import store from 'store2'; // see: https://github.com/nbubna/store#readme
5
5
  import _ from 'lodash';
6
+ import { CROSS_TAB_CHANNEL_NAME, CROSS_TAB_MESSAGE_TYPE, CROSS_TAB_EVENT_NAME } from './crossTabConstants.js';
6
7
 
7
8
  /**
8
9
  * Repository representing a browser's LocalStorage implementation
@@ -17,9 +18,183 @@ class LocalStorageRepository extends OfflineRepository {
17
18
 
18
19
  this._store = store.namespace(this.schema.name);
19
20
 
21
+ // crossTabSync defaults
22
+ this._crossTabSyncEnabled = config.crossTabSync === true;
23
+ this._crossTabChannelName = config.crossTabChannelName || CROSS_TAB_CHANNEL_NAME;
24
+ this._crossTabChannel = null;
25
+ this._onCrossTabStorageEvent = null;
26
+ this._onCrossTabMessageEvent = null;
27
+
28
+ this.registerEvent(CROSS_TAB_EVENT_NAME);
29
+
20
30
  if (this._store.isFake()) {
21
31
  throw new Error('store2 error: persistent storage not established.');
22
32
  }
33
+
34
+ this._setupCrossTabSync();
35
+ }
36
+
37
+ /**
38
+ * _setupCrossTabSync
39
+ *
40
+ * Initialises cross-tab change notification.
41
+ * Prefers BroadcastChannel (direct messaging, all modern browsers) and falls back to
42
+ * the native window 'storage' event, which browsers fire in every *other* tab whenever
43
+ * localStorage is mutated.
44
+ */
45
+ _setupCrossTabSync() {
46
+ if (!this._crossTabSyncEnabled || typeof window === 'undefined') {
47
+ return;
48
+ }
49
+
50
+ if (typeof BroadcastChannel !== 'undefined') {
51
+ this._crossTabChannel = new BroadcastChannel(this._crossTabChannelName);
52
+ this._onCrossTabMessageEvent = this._handleBroadcastMessage.bind(this);
53
+ this._crossTabChannel.addEventListener('message', this._onCrossTabMessageEvent);
54
+ return;
55
+ }
56
+
57
+ if (_.isFunction(window.addEventListener)) {
58
+ this._onCrossTabStorageEvent = this._handleStorageEvent.bind(this);
59
+ window.addEventListener('storage', this._onCrossTabStorageEvent);
60
+ }
61
+ }
62
+
63
+ /**
64
+ * _teardownCrossTabSync
65
+ *
66
+ * Removes all cross-tab listeners and closes the BroadcastChannel.
67
+ * Called from destroy() to prevent memory leaks and stale event handlers after
68
+ * the repository is no longer in use.
69
+ */
70
+ _teardownCrossTabSync() {
71
+ if (typeof window !== 'undefined' && _.isFunction(window.removeEventListener) && this._onCrossTabStorageEvent) {
72
+ window.removeEventListener('storage', this._onCrossTabStorageEvent);
73
+ this._onCrossTabStorageEvent = null;
74
+ }
75
+
76
+ if (this._crossTabChannel) {
77
+ if (this._onCrossTabMessageEvent) {
78
+ this._crossTabChannel.removeEventListener('message', this._onCrossTabMessageEvent);
79
+ this._onCrossTabMessageEvent = null;
80
+ }
81
+ this._crossTabChannel.close();
82
+ this._crossTabChannel = null;
83
+ }
84
+ }
85
+
86
+ /**
87
+ * _getNamespacedKey
88
+ *
89
+ *
90
+ * Returns the fully-qualified storage key that store2 uses for a given entry name.
91
+ * store2 namespaces keys with a prefix derived from the schema name, so the raw
92
+ * key and the actual localStorage key differ. Broadcasting the namespaced key lets
93
+ * recipients correlate messages with what they see in raw storage APIs.
94
+ */
95
+ _getNamespacedKey(name) {
96
+ if (!this._store || !_.isFunction(this._store._in)) {
97
+ return name;
98
+ }
99
+ return this._store._in(name);
100
+ }
101
+
102
+ /**
103
+ * _emitCrossTabStorageChange
104
+ *
105
+ * Emits the crossTabStorageChange event on this repository instance.
106
+ * Attaches standard repository identity fields so listeners always know which
107
+ * repository triggered the change, regardless of how many are registered.
108
+ */
109
+ _emitCrossTabStorageChange(data = {}) {
110
+ this.emit(CROSS_TAB_EVENT_NAME, {
111
+ repositoryId: this.id,
112
+ repositoryName: this.name,
113
+ repositoryType: this.type,
114
+ ...data,
115
+ });
116
+ }
117
+
118
+ /**
119
+ * _handleBroadcastMessage
120
+ *
121
+ * Handles an incoming BroadcastChannel message from another tab.
122
+ * Filters out messages that are not storage-change notifications, messages
123
+ * originating from this same repository instance (echo prevention), and messages
124
+ * intended for a different repository, then re-emits the change locally.
125
+ */
126
+ _handleBroadcastMessage(event) {
127
+ const data = event?.data;
128
+ if (!data || data.type !== CROSS_TAB_MESSAGE_TYPE) {
129
+ return;
130
+ }
131
+ if (data.repositoryId === this.id) {
132
+ return;
133
+ }
134
+ if (data.repositoryName !== this.name) {
135
+ return;
136
+ }
137
+
138
+ this._emitCrossTabStorageChange({
139
+ source: 'broadcast',
140
+ operation: data.operation,
141
+ key: data.key,
142
+ namespacedKey: data.namespacedKey,
143
+ timestamp: data.timestamp,
144
+ });
145
+ }
146
+
147
+ /**
148
+ * _handleStorageEvent
149
+ *
150
+ * Handles the native window 'storage' event, which fires in every tab *except*
151
+ * the one that made the change. Used as a fallback when BroadcastChannel is
152
+ * unavailable. Filters to keys belonging to this repository's store2 namespace,
153
+ * then derives the operation from whether the new value is null (delete) or not.
154
+ */
155
+ _handleStorageEvent(event) {
156
+ if (!event || !event.key) {
157
+ return;
158
+ }
159
+ const namespace = this._store?._ns;
160
+ if (!namespace || !event.key.startsWith(namespace)) {
161
+ return;
162
+ }
163
+
164
+ const key = event.key.replace(namespace, '');
165
+ if (!key) {
166
+ return;
167
+ }
168
+
169
+ this._emitCrossTabStorageChange({
170
+ source: 'storage',
171
+ operation: _.isNil(event.newValue) ? 'delete' : 'set',
172
+ key,
173
+ namespacedKey: event.key,
174
+ timestamp: Date.now(),
175
+ });
176
+ }
177
+
178
+ /**
179
+ * Posts a structured message to the BroadcastChannel so other tabs can react
180
+ * to a storage mutation made in this tab. Only called when BroadcastChannel is
181
+ * available; the storage-event fallback path does not require an explicit broadcast
182
+ * because the browser fires the 'storage' event automatically.
183
+ */
184
+ _broadcastStorageChange(name, operation) {
185
+ if (!this._crossTabSyncEnabled || !this._crossTabChannel) {
186
+ return;
187
+ }
188
+
189
+ this._crossTabChannel.postMessage({
190
+ type: CROSS_TAB_MESSAGE_TYPE,
191
+ repositoryId: this.id,
192
+ repositoryName: this.name,
193
+ operation,
194
+ key: name || null,
195
+ namespacedKey: _.isNil(name) ? null : this._getNamespacedKey(name),
196
+ timestamp: Date.now(),
197
+ });
23
198
  }
24
199
 
25
200
  _storageGetValue(name){
@@ -62,7 +237,9 @@ class LocalStorageRepository extends OfflineRepository {
62
237
  value = JSON.stringify(value);
63
238
  }
64
239
 
65
- return this._store(name, value);
240
+ const result = this._store(name, value);
241
+ this._broadcastStorageChange(name, 'set');
242
+ return result;
66
243
 
67
244
  } catch (error) {
68
245
  if (this.debugMode) {
@@ -82,7 +259,9 @@ class LocalStorageRepository extends OfflineRepository {
82
259
  console.log(this.name, 'LocalStorage.delete', name);
83
260
  }
84
261
 
85
- return this._store.remove(name);
262
+ const result = this._store.remove(name);
263
+ this._broadcastStorageChange(name, 'delete');
264
+ return result;
86
265
 
87
266
  } catch (error) {
88
267
  if (this.debugMode) {
@@ -93,7 +272,19 @@ class LocalStorageRepository extends OfflineRepository {
93
272
  }
94
273
 
95
274
  clearAll() {
96
- return this._store.clearAll();
275
+ const result = this._store.clearAll();
276
+ this._broadcastStorageChange(null, 'clearAll');
277
+ return result;
278
+ }
279
+
280
+ /**
281
+ * Cleans up cross-tab sync resources before delegating to the parent destroy.
282
+ * Ensures the BroadcastChannel is closed and event listeners are removed so the
283
+ * repository does not continue to react to storage events after disposal.
284
+ */
285
+ destroy() {
286
+ this._teardownCrossTabSync();
287
+ super.destroy();
97
288
  }
98
289
 
99
290
  };
@@ -69,7 +69,9 @@ class SecureLocalStorageRepository extends LocalStorageRepository {
69
69
 
70
70
  value = AES.encrypt(value, this.passphrase).toString(); // MOD
71
71
 
72
- return this._store(name, value);
72
+ const result = this._store(name, value);
73
+ this._broadcastStorageChange(name, 'set');
74
+ return result;
73
75
 
74
76
  } catch (error) {
75
77
  if (this.debugMode) {
@@ -47,8 +47,10 @@ class SecureSessionStorageRepository extends SessionStorageRepository {
47
47
  }
48
48
 
49
49
  value = AES.encrypt(value, this.passphrase).toString(); // MOD
50
-
51
- return this._store.session(name, value);
50
+
51
+ const result = this._store.session(name, value);
52
+ this._broadcastStorageChange(name, 'set');
53
+ return result;
52
54
  }
53
55
 
54
56
  };
@@ -3,6 +3,7 @@
3
3
  import OfflineRepository from '@onehat/data/src/Repository/Offline.js';
4
4
  import store from 'store2'; // see: https://github.com/nbubna/store#readme
5
5
  import _ from 'lodash';
6
+ import { CROSS_TAB_CHANNEL_NAME, CROSS_TAB_MESSAGE_TYPE, CROSS_TAB_EVENT_NAME } from './crossTabConstants.js';
6
7
 
7
8
  /**
8
9
  * Repository representing a browser's SessionStorage implementation
@@ -18,10 +19,136 @@ class SessionStorageRepository extends OfflineRepository {
18
19
  _.merge(this, config);
19
20
 
20
21
  this._store = store.namespace(this.schema.name);
21
-
22
+
23
+ // crossTabSync defaults
24
+ this._crossTabSyncEnabled = config.crossTabSync === true;
25
+ this._crossTabChannelName = config.crossTabChannelName || CROSS_TAB_CHANNEL_NAME;
26
+ this._crossTabChannel = null;
27
+ this._onCrossTabMessageEvent = null;
28
+
29
+ this.registerEvent(CROSS_TAB_EVENT_NAME);
30
+
22
31
  if (this._store.isFake()) {
23
32
  throw new Error('store2 error: persistent storage not established.');
24
33
  }
34
+
35
+ this._setupCrossTabSync();
36
+ }
37
+
38
+ /**
39
+ * _setupCrossTabSync
40
+ *
41
+ * Initialises cross-tab change notification via BroadcastChannel.
42
+ * Unlike LocalStorage, there is no window 'storage' event fallback here because
43
+ * browsers never fire that event for sessionStorage mutations. If BroadcastChannel
44
+ * is unavailable, cross-tab sync simply does nothing.
45
+ */
46
+ _setupCrossTabSync() {
47
+ if (!this._crossTabSyncEnabled || typeof window === 'undefined') {
48
+ return;
49
+ }
50
+ if (typeof BroadcastChannel === 'undefined') {
51
+ return;
52
+ }
53
+ this._crossTabChannel = new BroadcastChannel(this._crossTabChannelName);
54
+ this._onCrossTabMessageEvent = this._handleBroadcastMessage.bind(this);
55
+ this._crossTabChannel.addEventListener('message', this._onCrossTabMessageEvent);
56
+ }
57
+
58
+ /**
59
+ * _teardownCrossTabSync
60
+ *
61
+ * Removes the BroadcastChannel message listener and closes the channel.
62
+ * Called from destroy() to prevent memory leaks and stale handlers.
63
+ */
64
+ _teardownCrossTabSync() {
65
+ if (this._crossTabChannel) {
66
+ if (this._onCrossTabMessageEvent) {
67
+ this._crossTabChannel.removeEventListener('message', this._onCrossTabMessageEvent);
68
+ this._onCrossTabMessageEvent = null;
69
+ }
70
+ this._crossTabChannel.close();
71
+ this._crossTabChannel = null;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * _getNamespacedKey
77
+ *
78
+ * Returns the fully-qualified storage key that store2 uses for a given entry name.
79
+ * store2 namespaces keys with a prefix derived from the schema name, so the raw
80
+ * key and the actual sessionStorage key differ. Broadcasting the namespaced key lets
81
+ * recipients correlate messages with what they see in raw storage APIs.
82
+ */
83
+ _getNamespacedKey(name) {
84
+ if (!this._store || !_.isFunction(this._store._in)) {
85
+ return name;
86
+ }
87
+ return this._store._in(name);
88
+ }
89
+
90
+ /**
91
+ * _emitCrossTabStorageChange
92
+ *
93
+ * Emits the crossTabStorageChange event on this repository instance.
94
+ * Attaches standard repository identity fields so listeners always know which
95
+ * repository triggered the change, regardless of how many are registered.
96
+ */
97
+ _emitCrossTabStorageChange(data = {}) {
98
+ this.emit(CROSS_TAB_EVENT_NAME, {
99
+ repositoryId: this.id,
100
+ repositoryName: this.name,
101
+ repositoryType: this.type,
102
+ ...data,
103
+ });
104
+ }
105
+
106
+ /**
107
+ * _handleBroadcastMessage
108
+ *
109
+ * Handles an incoming BroadcastChannel message from another tab.
110
+ * Filters out non-storage-change messages, echoes from this same instance,
111
+ * and messages for unrelated repositories, then re-emits the change locally.
112
+ */
113
+ _handleBroadcastMessage(event) {
114
+ const data = event?.data;
115
+ if (!data || data.type !== CROSS_TAB_MESSAGE_TYPE) {
116
+ return;
117
+ }
118
+ if (data.repositoryId === this.id) {
119
+ return;
120
+ }
121
+ if (data.repositoryName !== this.name) {
122
+ return;
123
+ }
124
+ this._emitCrossTabStorageChange({
125
+ source: 'broadcast',
126
+ operation: data.operation,
127
+ key: data.key,
128
+ namespacedKey: data.namespacedKey,
129
+ timestamp: data.timestamp,
130
+ });
131
+ }
132
+
133
+ /**
134
+ * _broadcastStorageChange
135
+ *
136
+ * Posts a structured message to the BroadcastChannel so other tabs can react
137
+ * to a sessionStorage mutation made in this tab.
138
+ */
139
+ _broadcastStorageChange(name, operation) {
140
+ if (!this._crossTabSyncEnabled || !this._crossTabChannel) {
141
+ return;
142
+ }
143
+ this._crossTabChannel.postMessage({
144
+ type: CROSS_TAB_MESSAGE_TYPE,
145
+ repositoryId: this.id,
146
+ repositoryName: this.name,
147
+ operation,
148
+ key: name || null,
149
+ namespacedKey: _.isNil(name) ? null : this._getNamespacedKey(name),
150
+ timestamp: Date.now(),
151
+ });
25
152
  }
26
153
 
27
154
  _storageGetValue(name) {
@@ -40,15 +167,31 @@ class SessionStorageRepository extends OfflineRepository {
40
167
  if (!_.isString(value)) {
41
168
  value = JSON.stringify(value);
42
169
  }
43
- return this._store.session(name, value);
170
+ const result = this._store.session(name, value);
171
+ this._broadcastStorageChange(name, 'set');
172
+ return result;
44
173
  }
45
174
 
46
175
  _storageDeleteValue(name) {
47
- return this._store.session.remove(name);
176
+ const result = this._store.session.remove(name);
177
+ this._broadcastStorageChange(name, 'delete');
178
+ return result;
48
179
  }
49
180
 
50
181
  clearAll() {
51
- return this._store.session.clearAll();
182
+ const result = this._store.session.clearAll();
183
+ this._broadcastStorageChange(null, 'clearAll');
184
+ return result;
185
+ }
186
+
187
+ /**
188
+ * Cleans up cross-tab sync resources before delegating to the parent destroy.
189
+ * Ensures the BroadcastChannel is closed and event listeners are removed so the
190
+ * repository does not continue to react to storage events after disposal.
191
+ */
192
+ destroy() {
193
+ this._teardownCrossTabSync();
194
+ super.destroy();
52
195
  }
53
196
 
54
197
  };
@@ -0,0 +1,5 @@
1
+ /** @module Repository */
2
+
3
+ export const CROSS_TAB_CHANNEL_NAME = '__onehat_data_cross_tab__';
4
+ export const CROSS_TAB_MESSAGE_TYPE = 'repositoryStorageChanged';
5
+ export const CROSS_TAB_EVENT_NAME = 'crossTabStorageChange';
package/src/OneHatData.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  v4 as uuid,
17
17
  } from 'uuid';
18
18
  import _ from 'lodash';
19
+ import { CROSS_TAB_EVENT_NAME } from './Integration/Browser/Repository/crossTabConstants.js';
19
20
 
20
21
  /**
21
22
  * OneHatData represents a collection of Repositories.
@@ -258,6 +259,9 @@ export class OneHatData extends EventEmitter {
258
259
  if (repository.isRegisteredEvent('logout')) { // OneBuild repository emits this
259
260
  this.relayEventsFrom(repository, ['logout']);
260
261
  }
262
+ if (repository.isRegisteredEvent(CROSS_TAB_EVENT_NAME)) {
263
+ this.relayEventsFrom(repository, [CROSS_TAB_EVENT_NAME]);
264
+ }
261
265
 
262
266
  this.emit('createRepository', repository);
263
267
  return repository;