@jcbuisson/express-x-client 2.2.2 → 3.0.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.
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x-client",
3
- "version": "2.2.2",
3
+ "version": "3.0.1",
4
4
  "type": "module",
5
5
  "description": "Client library for ExpressX framework",
6
- "main": "src/index.mjs",
6
+ "main": "src/client.mts",
7
7
  "test": "node --test",
8
8
  "scripts": {
9
9
  },
package/src/client.mts ADDED
@@ -0,0 +1,657 @@
1
+ import Dexie from "dexie";
2
+ import { from } from 'rxjs';
3
+ import { distinctUntilChanged } from 'rxjs/operators';
4
+ import { liveQuery } from "dexie";
5
+ // uuidv7 are monotonically increasing and much improve database performance amid B-tree indexes
6
+ import { v7 as uuidv7 } from 'uuid';
7
+ import { tryOnScopeDispose } from '@vueuse/core';
8
+ import { useSessionStorage } from '@vueuse/core'
9
+
10
+
11
+ ////////////////////////// UTILITIES //////////////////////////
12
+
13
+ function generateUID(length) {
14
+ const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
15
+ let uid = ''
16
+
17
+ for (let i = 0; i < length; i++) {
18
+ const randomIndex = Math.floor(Math.random() * characters.length)
19
+ uid += characters.charAt(randomIndex)
20
+ }
21
+ return uid
22
+ }
23
+
24
+
25
+ ////////////////////////// EXPRESSX //////////////////////////
26
+
27
+ export function createClient(socket, options={}) {
28
+ if (options.debug === undefined) options.debug = false
29
+
30
+ const waitingPromisesByUid = {}
31
+ const action2service2handlers = {}
32
+ const type2appHandler = {}
33
+ let connectListeners = []
34
+ let disconnectListeners = []
35
+ let errorListeners = []
36
+
37
+ function configure(callback, ...args) {
38
+ callback(app, ...args)
39
+ }
40
+
41
+ socket.on("connect", async () => {
42
+ if (options.debug) console.log("socket connected", socket.id)
43
+ for (const func of connectListeners) {
44
+ func(socket)
45
+ }
46
+ })
47
+
48
+ socket.on("connect_error", async (err) => {
49
+ if (options.debug) console.log("socket connection error", socket.id)
50
+ for (const func of errorListeners) {
51
+ func(socket)
52
+ }
53
+ })
54
+
55
+ socket.on("disconnect", async () => {
56
+ if (options.debug) console.log("socket disconnected", socket.id)
57
+ for (const func of disconnectListeners) {
58
+ func(socket)
59
+ }
60
+ })
61
+
62
+ function addConnectListener(func) {
63
+ connectListeners.push(func)
64
+ }
65
+ function removeConnectListener(func) {
66
+ connectListeners = connectListeners.filter(f !== func)
67
+ }
68
+
69
+ function addDisconnectListener(func) {
70
+ disconnectListeners.push(func)
71
+ }
72
+ function removeDisonnectListener(func) {
73
+ disconnectListeners = disconnectListeners.filter(f !== func)
74
+ }
75
+
76
+ function addErrorListener(func) {
77
+ errorListeners.push(func)
78
+ }
79
+ function removeErrorListener(func) {
80
+ errorListeners = errorListeners.filter(f !== func)
81
+ }
82
+
83
+ // on receiving response from service request
84
+ socket.on('client-response', ({ uid, error, result }) => {
85
+ if (options.debug) console.log('client-response', uid, error, result)
86
+ if (!waitingPromisesByUid[uid]) return // may not exist because a timeout removed it
87
+ const [resolve, reject] = waitingPromisesByUid[uid]
88
+ if (error) {
89
+ reject(error)
90
+ } else {
91
+ resolve(result)
92
+ }
93
+ delete waitingPromisesByUid[uid]
94
+ })
95
+
96
+ // on receiving service events from pub/sub
97
+ socket.on('service-event', ({ name, action, result }) => {
98
+ if (options.debug) console.log('service-event', name, action, result)
99
+ if (!action2service2handlers[action]) action2service2handlers[action] = {}
100
+ const serviceHandlers = action2service2handlers[action]
101
+ const handler = serviceHandlers[name]
102
+ if (handler) handler(result)
103
+ })
104
+
105
+ async function serviceMethodRequest(name, action, serviceOptions, ...args) {
106
+ // create a promise which will resolve or reject by an event 'client-response'
107
+ const uid = generateUID(20)
108
+ const promise = new Promise((resolve, reject) => {
109
+ waitingPromisesByUid[uid] = [resolve, reject]
110
+ // a timeout may also reject the promise
111
+ if (serviceOptions.timeout && !serviceOptions.volatile) {
112
+ setTimeout(() => {
113
+ delete waitingPromisesByUid[uid]
114
+ reject(`Error: timeout on service '${name}', action '${action}', args: ${JSON.stringify(args)}`)
115
+ }, serviceOptions.timeout)
116
+ }
117
+ })
118
+ // send request to server through websocket
119
+ if (options.debug) console.log('client-request', uid, name, action, args)
120
+ if (serviceOptions.volatile) {
121
+ // event is not sent if connection is not active
122
+ socket.volatile.emit('client-request', { uid, name, action, args, })
123
+ } else {
124
+ // event is buffered if connection is not active (default)
125
+ socket.emit('client-request', { uid, name, action, args, })
126
+ }
127
+ return promise
128
+ }
129
+
130
+ function service(name, serviceOptions={}) {
131
+ if (serviceOptions.timeout === undefined) serviceOptions.timeout = 20000
132
+ const service = {
133
+ // associate a handler to a pub/sub event for this service
134
+ on: (action, handler) => {
135
+ if (!action2service2handlers[action]) action2service2handlers[action] = {}
136
+ const serviceHandlers = action2service2handlers[action]
137
+ serviceHandlers[name] = handler
138
+ },
139
+ }
140
+ // use a Proxy to allow for any method name for a service
141
+ const handler = {
142
+ get(service, action) {
143
+ if (!(action in service)) {
144
+ // newly used property `action`: define it as a service method request function
145
+ service[action] = (...args) => serviceMethodRequest(name, action, serviceOptions, ...args)
146
+ }
147
+ return service[action]
148
+ }
149
+ }
150
+ return new Proxy(service, handler)
151
+ }
152
+
153
+ //-------------------- APPLICATION-LEVEL EVENTS --------------------
154
+
155
+ // There is a need for application-wide events sent outside any service method call, for example when backend state changes
156
+ // without front-end interactions
157
+ socket.on('app-event', ({ type, value }) => {
158
+ if (options.debug) console.log('app-event', type, value)
159
+ if (!type2appHandler[type]) type2appHandler[type] = {}
160
+ const handler = type2appHandler[type]
161
+ if (handler) handler(value)
162
+ })
163
+
164
+ // add a handler for application-wide events
165
+ function on(type, handler) {
166
+ type2appHandler[type] = handler
167
+ }
168
+
169
+ const app = {
170
+ configure,
171
+ addConnectListener,
172
+ removeConnectListener,
173
+ addDisconnectListener,
174
+ removeDisonnectListener,
175
+ addErrorListener,
176
+ removeErrorListener,
177
+
178
+ service,
179
+ on,
180
+ }
181
+
182
+ return app
183
+ }
184
+
185
+
186
+ ////////////////////////// RELOAD PLUGIN //////////////////////////
187
+ // enrich `app` with listeners handling socket data transfer on page reload
188
+
189
+ export async function reloadPlugin(app) {
190
+
191
+ const cnxid = useSessionStorage('cnxid', '')
192
+
193
+ app.addConnectListener(async (socket) => {
194
+ const socketId = socket.id
195
+ console.log('connect', socketId)
196
+ // handle reconnections & reloads
197
+ // look for a previously stored connection id
198
+ const prevSocketId = cnxid.value
199
+ if (prevSocketId) {
200
+ // it's a connection after a reload/refresh
201
+ // ask server to transfer all data from connection `prevSocketId` to connection `socketId`
202
+ console.log('cnx-transfer', prevSocketId, 'to', socketId)
203
+ await socket.emit('cnx-transfer', prevSocketId, socketId)
204
+ // update connection id
205
+ cnxid.value = socketId
206
+
207
+ } else {
208
+ // set connection id
209
+ cnxid.value = socketId
210
+ }
211
+
212
+ socket.on('cnx-transfer-ack', async (fromSocketId, toSocketId) => {
213
+ console.log('ACK ACK!!!', fromSocketId, toSocketId)
214
+ })
215
+
216
+ socket.on('cnx-transfer-error', async (fromSocketId, toSocketId) => {
217
+ console.log('ERR ERR!!!', fromSocketId, toSocketId)
218
+ // appState.value.unrecoverableError = true
219
+ })
220
+ })
221
+ }
222
+
223
+
224
+ ////////////////////////// OFFLINE PLUGIN //////////////////////////
225
+ // enrich `app` with methods, attributes and listeners to handle offline-first database access
226
+
227
+ export function offlinePlugin(app) {
228
+
229
+ function createOfflineModel(modelName, fields) {
230
+
231
+ const dbName = modelName;
232
+ const db = getOrCreateDB(dbName, fields);
233
+
234
+ db.open().then(() => {
235
+ // console.log('db ready', dbName, modelName)
236
+ });
237
+
238
+ db.values.hook("updating", (changes, primaryKey, previousValue) => {
239
+ // console.log("CHANGES", primaryKey, changes, previousValue);
240
+ });
241
+
242
+ const reset = async () => {
243
+ console.log('reset', modelName);
244
+ await db.whereList.clear();
245
+ await db.values.clear();
246
+ await db.metadata.clear();
247
+ };
248
+
249
+
250
+ ///////////// PUB / SUB /////////////
251
+
252
+ app.service(modelName).on('createWithMeta', async ([value, meta]) => {
253
+ console.log(`${modelName} EVENT createWithMeta`, value);
254
+ await db.values.put(value);
255
+ await db.metadata.put(meta);
256
+ });
257
+
258
+ app.service(modelName).on('updateWithMeta', async ([value, meta]) => {
259
+ console.log(`${modelName} EVENT updateWithMeta`, value);
260
+ await db.values.put(value);
261
+ await db.metadata.put(meta);
262
+ });
263
+
264
+ app.service(modelName).on('deleteWithMeta', async ([value, meta]) => {
265
+ console.log(`${modelName} EVENT deleteWithMeta`, value)
266
+ await db.values.delete(value.uid)
267
+ await db.metadata.put(meta)
268
+ });
269
+
270
+
271
+ ///////////// CREATE/UPDATE/REMOVE /////////////
272
+
273
+ async function create(data) {
274
+ const uid = uuidv7()
275
+ // optimistic update
276
+ const now = new Date()
277
+ await db.values.add({ uid, ...data })
278
+ await db.metadata.add({ uid, created_at: now })
279
+ // execute on server, asynchronously, if connection is active
280
+ if (app.isConnected) {
281
+ app.service(modelName).createWithMeta(uid, data, now)
282
+ .catch(async err => {
283
+ console.log(`*** err sync ${modelName} create`, err)
284
+ // rollback
285
+ await db.values.delete(uid)
286
+ })
287
+ }
288
+ return await db.values.get(uid)
289
+ }
290
+
291
+ const update = async (uid: string, data: object) => {
292
+ const previousValue = { ...(await db.values.get(uid)) }
293
+ const previousMetadata = { ...(await db.metadata.get(uid)) }
294
+ // optimistic update of cache
295
+ const now = new Date()
296
+ await db.values.update(uid, data)
297
+ await db.metadata.update(uid, { updated_at: now })
298
+ // execute on server, asynchronously, if connection is active
299
+ if (app.isConnected) {
300
+ app.service(modelName).updateWithMeta(uid, data, now)
301
+ .catch(async err => {
302
+ console.log(`*** err sync ${modelName} update`, err)
303
+ // rollback
304
+ delete previousValue.uid
305
+ await db.values.update(uid, previousValue)
306
+ delete previousMetadata.uid
307
+ await db.metadata.update(uid, previousMetadata)
308
+ })
309
+ }
310
+ return await db.values.get(uid)
311
+ }
312
+
313
+ const remove = async (uid: string) => {
314
+ const deleted_at = new Date()
315
+ // optimistic delete in cache
316
+ await db.values.update(uid, { __deleted__: true })
317
+ await db.metadata.update(uid, { deleted_at })
318
+ // and in database, if connected
319
+ if (app.isConnected) {
320
+ app.service(modelName).deleteWithMeta(uid, deleted_at)
321
+ .catch(async err => {
322
+ console.log(`*** err sync ${modelName} remove`, err)
323
+ // rollback
324
+ await db.values.update(uid, { __deleted__: null })
325
+ await db.metadata.update(uid, { deleted_at: null })
326
+ })
327
+ }
328
+ }
329
+
330
+ ///////////// DIRECT CACHE ACCESS /////////////
331
+
332
+ function findByUID(uid) {
333
+ return db.values.get(uid)
334
+ }
335
+
336
+ function findWhere(where = {}) {
337
+ const predicate = wherePredicate(where)
338
+ return db.values.filter(value => !value.__deleted__ && predicate(value)).toArray()
339
+ }
340
+
341
+ ///////////// REAL-TIME OBSERVABLE /////////////
342
+
343
+ function getObservable(where = {}) {
344
+ addSynchroWhere(where).then((isNew: boolean) => {
345
+ // console.log('getObservable addSynchroWhere', modelName, where, isNew);
346
+ if (isNew && app.isConnected) {
347
+ synchronize(modelName, db.values, db.metadata, where, app.disconnectedDate)
348
+ }
349
+ })
350
+ const predicate = wherePredicate(where)
351
+ return from(liveQuery(() => db.values.filter(value => !value.__deleted__ && predicate(value)).toArray())).pipe(
352
+ distinctUntilChanged((prev, curr) => {
353
+ // Deep equality check to prevent unnecessary emissions (in particular on database write)
354
+ return JSON.stringify(prev) === JSON.stringify(curr)
355
+ })
356
+ )
357
+ }
358
+
359
+ let count = 0;
360
+
361
+ function addSynchroWhere(where: object) {
362
+ const promise = addSynchroDBWhere(where, db.whereList)
363
+ promise.then(isNew => isNew && count++ && console.log(`addSynchroWhere (${count})`, dbName, modelName, where))
364
+ return promise
365
+ }
366
+
367
+ function removeSynchroWhere(where: object) {
368
+ console.log('removeSynchroWhere', dbName, modelName, where)
369
+ count -= 1
370
+ return removeSynchroDBWhere(where, db.whereList)
371
+ }
372
+
373
+ // async function synchronizeAll() {
374
+ // await synchronizeModelWhereList(app, modelName, db.values, db.metadata, app.getDisconnectedDate(), db.whereList)
375
+ // }
376
+
377
+ // Automatically clean up when the component using this composable unmounts
378
+ tryOnScopeDispose(async () => {
379
+ console.log('CLEANING', dbName, modelName)
380
+ const whereList = await db.whereList.toArray()
381
+ for (const where of whereList) {
382
+ removeSynchroWhere(JSON.parse(where.sortedjson))
383
+ }
384
+ })
385
+
386
+ return {
387
+ db, reset,
388
+ create, update, remove,
389
+ findByUID, findWhere,
390
+ getObservable,
391
+ // synchronizeAll,
392
+ addSynchroWhere,
393
+ }
394
+ }
395
+
396
+ app.addConnectListener(async (_socket) => {
397
+ app.connectedDate = new Date()
398
+ console.log('onConnect', app.connectedDate)
399
+ app.disconnectedDate = null
400
+ app.isConnected = true
401
+ })
402
+
403
+ app.addDisconnectListener(async (_socket) => {
404
+ app.connectedDate = null
405
+ app.disconnectedDate = new Date()
406
+ console.log('onDisconnect', app.disconnectedDate)
407
+ app.isConnected = false
408
+ })
409
+
410
+
411
+ const mutex = new Mutex()
412
+
413
+ // ex: where = { uid: 'azer' }
414
+ async function synchronize(app, modelName, idbValues, idbMetadata, where, cutoffDate) {
415
+ await mutex.acquire()
416
+ console.log('synchronize', modelName, where)
417
+
418
+ let toAdd = []
419
+ try {
420
+ const requestPredicate = wherePredicate(where)
421
+
422
+ // collect meta-data of local values
423
+ // NOTE: __delete__ on values allows to collect metadata from cache-deleted values
424
+ const valueList = await idbValues.filter(requestPredicate).toArray()
425
+ const clientMetadataDict = {}
426
+ for (const value of valueList) {
427
+ const metadata = await idbMetadata.get(value.uid)
428
+ if (metadata) {
429
+ clientMetadataDict[value.uid] = metadata
430
+ } else {
431
+ // should not happen
432
+ clientMetadataDict[value.uid] = {}
433
+ }
434
+ }
435
+
436
+ // call sync service on `where` perimeter
437
+ const syncResult = await app.service('sync').go(modelName, where, cutoffDate, clientMetadataDict)
438
+ toAdd = syncResult.toAdd
439
+ const { toUpdate, toDelete, addDatabase, updateDatabase } = syncResult
440
+ console.log('-> service.sync', modelName, where, toAdd, toUpdate, toDelete, addDatabase, updateDatabase)
441
+
442
+ // 1- add missing elements in indexedDB cache
443
+ // Use a single transaction for all adds to ensure atomicity
444
+ if (toAdd.length > 0) {
445
+ await idbValues.db.transaction('rw', [idbValues, idbMetadata], async () => {
446
+ for (const [value, metaData] of toAdd) {
447
+ await idbValues.add(value)
448
+ await idbMetadata.add(metaData)
449
+ }
450
+ })
451
+ }
452
+ // 2- delete elements from indexedDB cache
453
+ for (const [uid, deleted_at] of toDelete) {
454
+ await idbValues.delete(uid)
455
+ await idbMetadata.update(uid, { deleted_at })
456
+ }
457
+ // 3- update elements of cache
458
+ for (const elt of toUpdate) {
459
+ // get full value of element to update
460
+ const value = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
461
+ delete value.uid
462
+ delete value.__deleted__
463
+ await idbValues.update(elt.uid, value)
464
+ const metadata = await idbMetadata.get(elt.uid)
465
+ await idbMetadata.update(elt.uid, { updated_at: metadata.updated_at })
466
+ }
467
+
468
+ // 4- create elements of `addDatabase` with full data from cache
469
+ for (const elt of addDatabase) {
470
+ const fullValue = await idbValues.get(elt.uid)
471
+ const meta = await idbMetadata.get(elt.uid)
472
+ delete fullValue.uid
473
+ delete fullValue.__deleted__
474
+ try {
475
+ await app.service(modelName).createWithMeta(elt.uid, fullValue, meta.created_at)
476
+ } catch(err) {
477
+ console.log("*** err sync user addDatabase", err, elt.uid, fullValue, meta.created_at)
478
+ // rollback
479
+ await idbValues.delete(elt.uid)
480
+ await idbMetadata.delete(elt.uid)
481
+ }
482
+ }
483
+
484
+ // 5- update elements of `updateDatabase` with full data from cache
485
+ for (const elt of updateDatabase) {
486
+ const fullValue = await idbValues.get(elt.uid)
487
+ const meta = await idbMetadata.get(elt.uid)
488
+ delete fullValue.uid
489
+ delete fullValue.__deleted__
490
+ try {
491
+ await app.service(modelName).updateWithMeta(elt.uid, fullValue, meta.updated_at)
492
+ } catch(err) {
493
+ console.log("*** err sync user updateDatabase", err)
494
+ // rollback
495
+ const previousDatabaseValue = await app.service(modelName).findUnique({ where:{ uid: elt.uid }})
496
+ const previousDatabaseMetadata = await app.service('metadata').findUnique({ where:{ uid: elt.uid }})
497
+ await idbValues.update(elt.uid, previousDatabaseValue)
498
+ await idbMetadata.update(elt.uid, previousDatabaseMetadata)
499
+ }
500
+ }
501
+ } catch(err) {
502
+ console.log('err synchronize', modelName, where, err)
503
+ } finally {
504
+ mutex.release()
505
+ }
506
+ }
507
+
508
+ function wherePredicate(where) {
509
+ return (elt) => {
510
+ for (const [attr, value] of Object.entries(where)) {
511
+ const eltAttrValue = elt[attr]
512
+
513
+ if (typeof(value) === 'string' || typeof(value) === 'number') {
514
+ // 'attr = value' clause
515
+ if (eltAttrValue !== value) return false
516
+
517
+ } else if (typeof(value) === 'object') {
518
+ // 'attr = { lt/lte/gt/gte: value }' clause
519
+ if (value.lte) {
520
+ if (eltAttrValue > value.lte) return false
521
+ } else if (value.lt) {
522
+ if (eltAttrValue >= value.lt) return false
523
+ } else if (value.gte) {
524
+ if (eltAttrValue < value.gte) return false
525
+ } else if (value.gt) {
526
+ if (eltAttrValue <= value.gt) return false
527
+ }
528
+ }
529
+ }
530
+ return true
531
+ }
532
+ }
533
+
534
+ async function getWhereList(whereDb) {
535
+ const list = await whereDb.toArray()
536
+ return list.map(elt => JSON.parse(elt.sortedjson))
537
+ }
538
+
539
+ async function addSynchroDBWhere(where, whereDb) {
540
+ await mutex.acquire()
541
+ let modified = false
542
+ try {
543
+ const whereList = await getWhereList(whereDb)
544
+ if (!isSubsetAmong(where, whereList)) {
545
+ // sortedjson is used as a unique standardized representation of a 'where' object ; it is used both as key and value in 'wheredb' database
546
+ await whereDb.add({ sortedjson: stringifyWithSortedKeys(where) })
547
+ modified = true
548
+ }
549
+ } catch(err) {
550
+ console.log('err addSynchroDBWhere', where, err)
551
+ } finally {
552
+ mutex.release()
553
+ }
554
+ return modified
555
+ }
556
+
557
+ async function removeSynchroDBWhere(where, whereDb) {
558
+ await mutex.acquire()
559
+ try {
560
+ const swhere = stringifyWithSortedKeys(where)
561
+ await whereDb.filter(value => (value.sortedjson === swhere)).delete()
562
+ } catch(err) {
563
+ console.log('err removeSynchroDBWhere', err)
564
+ } finally {
565
+ mutex.release()
566
+ }
567
+ }
568
+
569
+ // async function synchronizeModelWhereList(modelName, idbValues, idbMetadata, cutoffDate, whereDb) {
570
+ // const whereList = await getWhereList(whereDb)
571
+ // for (const where of whereList) {
572
+ // await synchronize(modelName, idbValues, idbMetadata, where, cutoffDate)
573
+ // }
574
+ // }
575
+
576
+ // Singleton map to reuse Dexie instances per database name
577
+ const dbInstances = new Map();
578
+
579
+ function getOrCreateDB(dbName: string, fields: string[]) {
580
+ if (!dbInstances.has(dbName)) {
581
+ const db = new Dexie(dbName);
582
+ db.version(1).stores({
583
+ whereList: "sortedjson",
584
+ values: ['uid', '__deleted__', ...fields].join(','),
585
+ metadata: "uid, created_at, updated_at, deleted_at",
586
+ });
587
+ dbInstances.set(dbName, db);
588
+ }
589
+ return dbInstances.get(dbName);
590
+ }
591
+
592
+ // enrich `app` with new methods and attributes
593
+ return Object.assign(app, {
594
+ createOfflineModel,
595
+ })
596
+ }
597
+
598
+
599
+ function stringifyWithSortedKeys(obj, space = null) {
600
+ return JSON.stringify(obj, (key, value) => {
601
+ // If the value is a plain object (not an array, null, or other object type like Date)
602
+ if (value && typeof value === 'object' && !Array.isArray(value) && Object.prototype.toString.call(value) === '[object Object]') {
603
+ const sorted = {};
604
+ // Get all keys, sort them, and then re-add them to a new object
605
+ // This new object will maintain the sorted order when stringified
606
+ Object.keys(value).sort().forEach(k => {
607
+ sorted[k] = value[k];
608
+ });
609
+ return sorted;
610
+ }
611
+ // For all other types (primitives, arrays, null, etc.), return the value as is
612
+ return value;
613
+ }, space); // 'space' is optional for pretty-printing (e.g., 2 or 4)
614
+ }
615
+ // console.log('stringifyWithSortedKeys({ age: 30, name: "Alice", data: { city: "Paris", color: "red" }})', stringifyWithSortedKeys({ age: 30, name: "Alice", data: { city: "Paris", color: "red" } }))
616
+
617
+ export class Mutex {
618
+ constructor() {
619
+ this.locked = false;
620
+ this.queue = [];
621
+ }
622
+
623
+ async acquire() {
624
+ if (this.locked) {
625
+ return new Promise(resolve => this.queue.push(resolve));
626
+ }
627
+ this.locked = true;
628
+ }
629
+
630
+ release() {
631
+ if (this.queue.length > 0) {
632
+ const next = this.queue.shift();
633
+ next();
634
+ } else {
635
+ this.locked = false;
636
+ }
637
+ }
638
+ }
639
+
640
+ function isSubset(subset, fullObject) {
641
+ // return Object.entries(subset).some(([key, value]) => fullObject[key] === value)
642
+ for (const key in fullObject) {
643
+ if (fullObject[key] !== subset[key]) return false
644
+ }
645
+ return true
646
+ }
647
+ // console.log('isSubset({a: 1, b: 2}, {b: 2})=true', isSubset({a: 1, b: 2}, {b: 2}))
648
+ // console.log('isSubset({}, {})=true', isSubset({}, {}))
649
+ // console.log('isSubset({a: 1}, {})=true', isSubset({a: 1}, {}))
650
+ // console.log('isSubset({a: 1}, {b: 2})=false', isSubset({a: 1}, {b: 2}))
651
+ // console.log('isSubset({a: 1}, {a: 1})=true', isSubset({a: 1}, {a: 1}))
652
+
653
+ function isSubsetAmong(subset, fullObjectList) {
654
+ return fullObjectList.some(fullObject => isSubset(subset, fullObject));
655
+ }
656
+ // console.log('isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}])=true', isSubsetAmong({a: 1, b: 2}, [{c: 3}, {b: 2}]))
657
+
package/src/index.mjs DELETED
@@ -1,163 +0,0 @@
1
-
2
- function generateUID(length) {
3
- const characters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
4
- let uid = ''
5
-
6
- for (let i = 0; i < length; i++) {
7
- const randomIndex = Math.floor(Math.random() * characters.length)
8
- uid += characters.charAt(randomIndex)
9
- }
10
- return uid
11
- }
12
-
13
-
14
- export default function expressXClient(socket, options={}) {
15
- if (options.debug === undefined) options.debug = false
16
-
17
- const waitingPromisesByUid = {}
18
- const action2service2handlers = {}
19
- const type2appHandler = {}
20
- let connectListeners = []
21
- let disconnectListeners = []
22
- let errorListeners = []
23
-
24
- socket.on("connect", async () => {
25
- if (options.debug) console.log("socket connected", socket.id)
26
- for (const func of connectListeners) {
27
- func(socket)
28
- }
29
- })
30
-
31
- socket.on("connect_error", async (err) => {
32
- if (options.debug) console.log("socket connection error", socket.id)
33
- for (const func of errorListeners) {
34
- func(socket)
35
- }
36
- })
37
-
38
- socket.on("disconnect", async () => {
39
- if (options.debug) console.log("socket disconnected", socket.id)
40
- for (const func of disconnectListeners) {
41
- func(socket)
42
- }
43
- })
44
-
45
- function addConnectListener(func) {
46
- connectListeners.push(func)
47
- }
48
- function removeConnectListener(func) {
49
- connectListeners = connectListeners.filter(f !== func)
50
- }
51
-
52
- function addDisconnectListener(func) {
53
- disconnectListeners.push(func)
54
- }
55
- function removeDisonnectListener(func) {
56
- disconnectListeners = disconnectListeners.filter(f !== func)
57
- }
58
-
59
- function addErrorListener(func) {
60
- errorListeners.push(func)
61
- }
62
- function removeErrorListener(func) {
63
- errorListeners = errorListeners.filter(f !== func)
64
- }
65
-
66
- // on receiving response from service request
67
- socket.on('client-response', ({ uid, error, result }) => {
68
- if (options.debug) console.log('client-response', uid, error, result)
69
- if (!waitingPromisesByUid[uid]) return // may not exist because a timeout removed it
70
- const [resolve, reject] = waitingPromisesByUid[uid]
71
- if (error) {
72
- reject(error)
73
- } else {
74
- resolve(result)
75
- }
76
- delete waitingPromisesByUid[uid]
77
- })
78
-
79
- // on receiving service events from pub/sub
80
- socket.on('service-event', ({ name, action, result }) => {
81
- if (options.debug) console.log('service-event', name, action, result)
82
- if (!action2service2handlers[action]) action2service2handlers[action] = {}
83
- const serviceHandlers = action2service2handlers[action]
84
- const handler = serviceHandlers[name]
85
- if (handler) handler(result)
86
- })
87
-
88
- async function serviceMethodRequest(name, action, serviceOptions, ...args) {
89
- // create a promise which will resolve or reject by an event 'client-response'
90
- const uid = generateUID(20)
91
- const promise = new Promise((resolve, reject) => {
92
- waitingPromisesByUid[uid] = [resolve, reject]
93
- // a timeout may also reject the promise
94
- if (serviceOptions.timeout && !serviceOptions.volatile) {
95
- setTimeout(() => {
96
- delete waitingPromisesByUid[uid]
97
- reject(`Error: timeout on service '${name}', action '${action}', args: ${JSON.stringify(args)}`)
98
- }, serviceOptions.timeout)
99
- }
100
- })
101
- // send request to server through websocket
102
- if (options.debug) console.log('client-request', uid, name, action, args)
103
- if (serviceOptions.volatile) {
104
- // event is not sent if connection is not active
105
- socket.volatile.emit('client-request', { uid, name, action, args, })
106
- } else {
107
- // event is buffered if connection is not active (default)
108
- socket.emit('client-request', { uid, name, action, args, })
109
- }
110
- return promise
111
- }
112
-
113
- function service(name, serviceOptions={}) {
114
- if (serviceOptions.timeout === undefined) serviceOptions.timeout = 20000
115
- const service = {
116
- // associate a handler to a pub/sub event for this service
117
- on: (action, handler) => {
118
- if (!action2service2handlers[action]) action2service2handlers[action] = {}
119
- const serviceHandlers = action2service2handlers[action]
120
- serviceHandlers[name] = handler
121
- },
122
- }
123
- // use a Proxy to allow for any method name for a service
124
- const handler = {
125
- get(service, action) {
126
- if (!(action in service)) {
127
- // newly used property `action`: define it as a service method request function
128
- service[action] = (...args) => serviceMethodRequest(name, action, serviceOptions, ...args)
129
- }
130
- return service[action]
131
- }
132
- }
133
- return new Proxy(service, handler)
134
- }
135
-
136
- /////////////// APPLICATION-LEVEL EVENTS /////////////////
137
-
138
- // There is a need for application-wide events sent outside any service method call, for example when backend state changes
139
- // without front-end interactions
140
- socket.on('app-event', ({ type, value }) => {
141
- if (options.debug) console.log('app-event', type, value)
142
- if (!type2appHandler[type]) type2appHandler[type] = {}
143
- const handler = type2appHandler[type]
144
- if (handler) handler(value)
145
- })
146
-
147
- // add a handler for application-wide events
148
- function on(type, handler) {
149
- type2appHandler[type] = handler
150
- }
151
-
152
- return {
153
- addConnectListener,
154
- removeConnectListener,
155
- addDisconnectListener,
156
- removeDisonnectListener,
157
- addErrorListener,
158
- removeErrorListener,
159
-
160
- service,
161
- on,
162
- }
163
- }