@webqit/webflo 0.20.23 → 0.20.25

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
@@ -12,7 +12,7 @@
12
12
  "vanila-javascript"
13
13
  ],
14
14
  "homepage": "https://webqit.io/tooling/webflo",
15
- "version": "0.20.23",
15
+ "version": "0.20.25",
16
16
  "license": "MIT",
17
17
  "repository": {
18
18
  "type": "git",
@@ -156,6 +156,17 @@ export class WebfloRuntime {
156
156
  for (const storage of [httpEvent.user, httpEvent.session, httpEvent.cookies]) {
157
157
  await storage?.commit?.(response, FLAGS['dev']);
158
158
  }
159
+
160
+ // End-of-life cleanup
161
+ const cleanup = async () => {
162
+ for (const storage of [httpEvent.user, httpEvent.session, httpEvent.cookies]) {
163
+ storage.cleanup();
164
+ }
165
+ if (!httpEvent.thread.extended) {
166
+ await httpEvent.thread.clear();
167
+ }
168
+ };
169
+
159
170
  // Wait for any whileLive promises to resolve
160
171
  if (LiveResponse.test(response) === 'LiveResponse' && response.whileLive()) {
161
172
  httpEvent.waitUntil(response.whileLive(true));
@@ -194,11 +205,9 @@ export class WebfloRuntime {
194
205
  // Close httpEvent.client
195
206
  httpEvent.lifeCycleComplete(true).then(async () => {
196
207
  httpEvent.client.close();
197
- if (!httpEvent.thread.extended) {
198
- await httpEvent.thread.clear();
199
- }
208
+ cleanup();
200
209
  });
201
- }
210
+ } else cleanup();
202
211
 
203
212
  if (!this.isClientSide && LiveResponse.test(response) === 'LiveResponse') {
204
213
  // Must convert to Response on the server-side before returning
@@ -69,25 +69,42 @@ export class HttpState {
69
69
  }
70
70
 
71
71
  #listeners = new Set;
72
- observe(attr, handler) {
73
- const args = { attr, handler };
74
- this.#listeners.add(args);
75
- return () => {
76
- this.#listeners.delete(args);
77
- }
78
- }
79
-
80
72
  async emit(attr, value) {
81
73
  const returnValues = [];
82
- for (const { attr: $attr, handler } of this.#listeners) {
74
+ for (const subscription of this.#listeners) {
75
+ const { attr: $attr, handler, options } = subscription;
83
76
  if (arguments.length && $attr !== attr) continue;
84
77
  if (arguments.length > 1) {
85
78
  returnValues.push(handler(value));
86
79
  } else {
87
80
  returnValues.push(handler());
88
81
  }
82
+ if (options.once) this.#listeners.delete(subscription);
83
+ }
84
+ await Promise.all(returnValues);
85
+ }
86
+
87
+ observe(attr, handler, options = {}) {
88
+ if (typeof this.#store.observe === 'function') {
89
+ return this.#store.observe(attr, handler, options);
90
+ }
91
+ const subscription = { attr, handler, options };
92
+ this.#listeners.add(subscription);
93
+ if (options.signal) {
94
+ options.signal.addEventListener('abort', () => {
95
+ this.#listeners.delete(subscription);
96
+ });
97
+ }
98
+ return () => {
99
+ this.#listeners.delete(subscription);
100
+ }
101
+ }
102
+
103
+ cleanup() {
104
+ if (typeof this.#store.cleanup === 'function') {
105
+ this.#store.cleanup();
89
106
  }
90
- return Promise.all(returnValues);
107
+ this.#listeners.clear();
91
108
  }
92
109
 
93
110
  #handlers = new Map;
@@ -20,6 +20,10 @@ export class HttpThread {
20
20
  this.#realm = realm;
21
21
  }
22
22
 
23
+ get extended() { return this.#extended; }
24
+
25
+ extend(set = true) { this.#extended = !!set; }
26
+
23
27
  spawn(_threadID = null) {
24
28
  return this.constructor.create({
25
29
  store: this.#store,
@@ -48,6 +52,20 @@ export class HttpThread {
48
52
  return this;
49
53
  }
50
54
 
55
+ async get(key, filter = null) {
56
+ const thread = await this.#store.get(this.#threadID) || {};
57
+ const values = [].concat(thread[key] ?? []);
58
+
59
+ let value;
60
+ if (filter === true) {
61
+ value = values;
62
+ } else if (filter) {
63
+ value = values.find(filter);
64
+ } else { value = values[values.length - 1]; }
65
+
66
+ return value;
67
+ }
68
+
51
69
  async consume(key, filter = null) {
52
70
  const thread = await this.#store.get(this.#threadID) || {};
53
71
  const values = [].concat(thread[key] ?? []);
@@ -79,8 +97,4 @@ export class HttpThread {
79
97
  await this.#store.delete(this.#threadID);
80
98
  return this;
81
99
  }
82
-
83
- get extended() { return this.#extended; }
84
-
85
- extend(set = true) { this.#extended = !!set; }
86
100
  }
@@ -17,8 +17,15 @@ export class HttpUser extends HttpState {
17
17
  this.#client = client;
18
18
  }
19
19
 
20
- async isSignedIn() {
21
- return await this.has('id');
20
+ async isSignedIn(callback = null, options = {}) {
21
+ const isSignedIn = await this.get('id');
22
+ if (callback) {
23
+ await callback(isSignedIn);
24
+ return options.once
25
+ ? undefined
26
+ : this.observe('id', callback, options);
27
+ }
28
+ return !!isSignedIn;
22
29
  }
23
30
 
24
31
  async signIn(...args) {
@@ -152,28 +152,165 @@ export class WebfloServer extends WebfloRuntime {
152
152
  || !this.bootstrap.init.redis) {
153
153
  return super.initCreateStorage();
154
154
  }
155
+
156
+ // ns -> field -> { value, subscriptions: Set<fn> }
157
+ const local = new Map();
158
+ const fire = (state, value, key, namespace, scopeReference = false) => {
159
+ const returnValues = [];
160
+ for (const subscription of state.subscriptions) {
161
+ const { callback, options } = subscription;
162
+
163
+ const scope = subscription.scopeReference === scopeReference ? 0 : scopeReference ? 1 : 2;
164
+ // For options.scope === 0, only include same-request cycle mutations
165
+ // For options.scope === 1, only include local mutations
166
+ // For options.scope === 2, include remote mutations
167
+ if ((options.scope || 0) !== scope) continue;
168
+
169
+ returnValues.push(callback(value, scope));
170
+ if (options.once) state.subscriptions.delete(subscription);
171
+ const ns = local.get(namespace);
172
+ if (!state.subscriptions.size) ns.delete(key);
173
+ if (!ns.size) local.delete(namespace);
174
+ }
175
+ return Promise.all(returnValues);
176
+ };
177
+
178
+ // Initialize storage
155
179
  const redis = this.bootstrap.init.redis;
156
- this.bootstrap.init.createStorage = (namespace, ttl = null) => ({
157
- async has(key) { return await redis.hexists(namespace, key); },
158
- async get(key) {
159
- const value = await redis.hget(namespace, key);
160
- return typeof value === 'undefined' ? value : JSON.parse(value);
161
- },
162
- async set(key, value) {
163
- const returnValue = await redis.hset(namespace, key, JSON.stringify(value));
164
- if (!this.ttlApplied && ttl) {
165
- await redis.expire(namespace, ttl);
166
- this.ttlApplied = true;
180
+ this.bootstrap.init.createStorage = (namespace, ttl = null, scopeReference = {}) => {
181
+ const cleanups = [];
182
+ return {
183
+ async has(key) { return await redis.hexists(namespace, key); },
184
+ async get(key) {
185
+ const jsonValue = await redis.hget(namespace, key);
186
+ return jsonValue === null ? undefined : JSON.parse(jsonValue);
187
+ },
188
+ async set(key, value) {
189
+ const jsonValue = JSON.stringify(value);
190
+ const returnValue = await redis.hset(namespace, key, jsonValue);
191
+ if (!this.ttlApplied && ttl) {
192
+ await redis.expire(namespace, ttl);
193
+ this.ttlApplied = true;
194
+ }
195
+ const state = local.get(namespace)?.get(key);
196
+ if (state) {
197
+ state.jsonValue = jsonValue;
198
+ state.initialized = true;
199
+ await fire(state, value, key, namespace, scopeReference);
200
+ }
201
+ return returnValue;
202
+ },
203
+ async delete(key) {
204
+ const returnValue = await redis.hdel(namespace, key);
205
+ const state = local.get(namespace)?.get(key);
206
+ if (state) {
207
+ state.jsonValue = null;
208
+ state.initialized = true;
209
+ await fire(state, undefined, key, namespace, scopeReference);
210
+ }
211
+ return returnValue;
212
+ },
213
+ async clear() {
214
+ const returnValue = await redis.del(namespace);
215
+ const nsLocal = local.get(namespace);
216
+ if (nsLocal) {
217
+ for (const [key, state] of nsLocal.entries()) {
218
+ state.jsonValue = null;
219
+ state.initialized = true;
220
+ await fire(state, undefined, key, namespace, scopeReference);
221
+ }
222
+ }
223
+ return returnValue;
224
+ },
225
+ async keys() { return await redis.hkeys(namespace); },
226
+ async values() { return (await redis.hvals(namespace) || []).map((value) => value === null ? undefined : JSON.parse(value)); },
227
+ async entries() { return Object.entries(await redis.hgetall(namespace) || {}).map(([key, value]) => [key, value === null ? undefined : JSON.parse(value)]); },
228
+ get size() { return redis.hlen(namespace); },
229
+ observe(key, callback, options = {}) {
230
+ // Prepare local data structure
231
+ let ns = local.get(namespace);
232
+ if (!ns) {
233
+ ns = new Map();
234
+ local.set(namespace, ns);
235
+ }
236
+
237
+ let state = ns.get(key);
238
+ if (!state) {
239
+ state = { jsonValue: null, initialized: false, subscriptions: new Set() };
240
+ ns.set(key, state);
241
+ // Prime initial value only once
242
+ redis.hget(namespace, key).then((jsonValue) => {
243
+ if (state.initialized) return;
244
+ state.jsonValue = jsonValue;
245
+ state.initialized = true;
246
+ });
247
+ }
248
+
249
+ const subscription = { callback, options, scopeReference };
250
+ state.subscriptions.add(subscription);
251
+ if (options.signal) {
252
+ options.signal.addEventListener('abort', () => {
253
+ state.subscriptions.delete(subscription);
254
+ if (state.subscriptions.size === 0) ns.delete(key);
255
+ if (ns.size === 0) local.delete(namespace);
256
+ });
257
+ }
258
+
259
+ // Unsubscribe logic
260
+ const cleanup = () => {
261
+ state.subscriptions.delete(subscription);
262
+ if (state.subscriptions.size === 0) ns.delete(key);
263
+ if (ns.size === 0) local.delete(namespace);
264
+ };
265
+ cleanups.push(cleanup);
266
+ return cleanup;
267
+ },
268
+ cleanup() {
269
+ for (const cleanup of cleanups) cleanup();
270
+ cleanups.length = 0;
271
+ },
272
+ };
273
+ };
274
+
275
+ // Watch for changes
276
+ const redisWatch = this.bootstrap.init.redisWatch;
277
+ if (redisWatch) {
278
+ // Subscribe to all events
279
+ redisWatch.psubscribe('__keyevent@0__:*');
280
+ redisWatch.on('pmessage', async (pattern, channel, redisKey) => {
281
+ const [, , event] = channel.split(':'); // -> "hset", "hdel", "expire", "del"
282
+ const namespace = redisKey;
283
+
284
+ const nsLocal = local.get(namespace);
285
+ if (!nsLocal) return;
286
+
287
+ const emitDiff = async () => {
288
+ for (const [field, state] of nsLocal) {
289
+ const jsonValue = await redis.hget(namespace, field);
290
+ if (jsonValue !== state.jsonValue) {
291
+ state.jsonValue = jsonValue;
292
+ const value = jsonValue === null ? undefined : JSON.parse(jsonValue);
293
+ fire(state, value, field, namespace);
294
+ }
295
+ }
167
296
  }
168
- return returnValue;
169
- },
170
- async delete(key) { return await redis.hdel(namespace, key); },
171
- async clear() { return await redis.del(namespace); },
172
- async keys() { return await redis.hkeys(namespace); },
173
- async values() { return (await redis.hvals(namespace) || []).map((value) => typeof value === 'undefined' ? value : JSON.parse(value)); },
174
- async entries() { return Object.entries(await redis.hgetall(namespace) || {}).map(([key, value]) => [key, typeof value === 'undefined' ? value : JSON.parse(value)]); },
175
- get size() { return redis.hlen(namespace); },
176
- });
297
+
298
+ // Field updates
299
+ if (event === 'hset') await emitDiff();
300
+ if (event === 'hdel') await emitDiff();
301
+
302
+ // Namespace removal (expire or del)
303
+ if (event === 'expire' || event === 'del') {
304
+ for (const [key, state] of nsLocal) {
305
+ if (state.jsonValue !== null) {
306
+ state.jsonValue = null;
307
+ fire(state, undefined, key, namespace);
308
+ }
309
+ }
310
+ local.delete(namespace);
311
+ }
312
+ });
313
+ }
177
314
  }
178
315
 
179
316
  control() {
@@ -17,11 +17,11 @@ export class Client extends WQStarPort {
17
17
 
18
18
  createRequestRealtime(portID, url = null) {
19
19
  const requestPort = new ClientRequestRealtime(portID, url);
20
- const cleanup = this.addPort(requestPort);
20
+ this.addPort(requestPort);
21
21
  setTimeout(() => {
22
22
  if (requestPort.length || !this.findPort((port) => port === requestPort)) return;
23
- cleanup();
24
- }, 30000/*30sec*/);
23
+ requestPort.close(true);
24
+ }, 15000/*15sec*/);
25
25
  return requestPort;
26
26
  }
27
27
  }