@hitchy/plugin-odem-socket.io 0.3.0 → 0.5.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,8 +1,7 @@
1
- "use strict";
1
+ import { promisify } from "node:util";
2
2
 
3
- const { promisify } = require( "node:util" );
4
-
5
- module.exports = function() {
3
+ /** */
4
+ export default function() {
6
5
  const api = this;
7
6
 
8
7
  const logDebug = api.log( "hitchy:plugin:odem:socket.io:debug" );
@@ -32,7 +31,7 @@ module.exports = function() {
32
31
  const txPerModel = {};
33
32
 
34
33
  for ( const [ modelName, model ] of Object.entries( api.models ) ) {
35
- if ( api.service.OdemSchema.mayBeExposed( { user: undefined }, model ) ) {
34
+ if ( api.service.OdemSchema.mayBeExposed( undefined, model ) ) {
36
35
  txPerModel[modelName] = this.broadcastModelNotifications( notifications, namespace, model );
37
36
  }
38
37
  }
@@ -85,10 +84,10 @@ module.exports = function() {
85
84
  const handlers = {
86
85
  created: ( uuid, newRecord, asyncGeneratorFn ) => {
87
86
  asyncGeneratorFn()
88
- .then( item => this.namespaceEmit( namespace, Model, {
87
+ .then( item => this.namespaceEmit( namespace, item, {
89
88
  change: "created",
90
89
  model: Model.name,
91
- properties: item.toObject( { serialized: true } ),
90
+ properties: this.serializeItem( item, false ),
92
91
  }, true ) )
93
92
  .catch( cause => {
94
93
  logError( "broadcasting notification on having created item %s of %s has failed:", uuid, Model.name, cause.stack );
@@ -96,21 +95,22 @@ module.exports = function() {
96
95
  },
97
96
  changed: ( uuid, newRecord, oldRecord, asyncGeneratorFn ) => {
98
97
  asyncGeneratorFn()
99
- .then( item => this.namespaceEmit( namespace, Model, {
100
- change: "updated",
98
+ .then( item => this.namespaceEmit( namespace, item, {
99
+ change: "changed",
101
100
  model: Model.name,
102
- properties: item.toObject( { serialized: true } ),
101
+ properties: this.serializeItem( item, false ),
103
102
  }, true ) )
104
103
  .catch( cause => {
105
104
  logError( "broadcasting notification on having updated item %s of %s has failed:", uuid, Model.name, cause.stack );
106
105
  } );
107
106
  },
108
- removed: uuid => {
109
- this.namespaceEmit( namespace, Model, {
110
- change: "deleted",
111
- model: Model.name,
112
- properties: { uuid: Model.formatUUID( uuid ) },
113
- }, false )
107
+ removed: ( uuid, lastRecord, asyncGeneratorFn ) => {
108
+ asyncGeneratorFn()
109
+ .then( item => this.namespaceEmit( namespace, item, {
110
+ change: "removed",
111
+ model: Model.name,
112
+ properties: { uuid: String( uuid ) },
113
+ }, false ) )
114
114
  .catch( cause => {
115
115
  logError( "broadcasting notification on having removed item %s of %s has failed:", uuid, Model.name, cause.stack );
116
116
  } );
@@ -181,6 +181,8 @@ module.exports = function() {
181
181
  }
182
182
  }
183
183
 
184
+ // TODO track the whole request instead of just its user to enable the plugin to assign $context to processed model instances
185
+
184
186
  // pick user eventually injected into mocked request
185
187
  return request.user || undefined;
186
188
  }
@@ -198,14 +200,16 @@ module.exports = function() {
198
200
  * accessing the changed model and/or its properties.
199
201
  *
200
202
  * @param {Server} namespace namespace managing a pool of sockets that have joined the namespace
201
- * @param {class<Hitchy.Plugin.Odem.Model>} Model model of changed item
202
- * @param {{change: string, model: string, properties: Object}} payload raw payload of model-specific notification
203
+ * @param {Hitchy.Plugin.Odem.Model} item instance of model notification is related to
204
+ * @param {{change: "created"|"changed"|"removed", model: string, properties: Object}} payload raw payload of model-specific notification
203
205
  * @param {boolean} filterProperties if true, properties need to be filtered per peer based on its authorization
204
206
  * @returns {Promise<void>} promise resolved when all authorized peers have been notified
205
207
  */
206
- static async namespaceEmit( namespace, Model, payload, filterProperties ) {
208
+ static async namespaceEmit( namespace, item, payload, filterProperties ) {
207
209
  const hasPluginAuth = Boolean( api.plugins.authentication );
208
210
  const sockets = await namespace.fetchSockets();
211
+ const Model = item.constructor;
212
+ const userCache = {};
209
213
 
210
214
  logDebug( "forwarding notification to %d socket(s)", sockets.length );
211
215
 
@@ -220,46 +224,73 @@ module.exports = function() {
220
224
  return;
221
225
  }
222
226
 
223
- if ( !OdemSchema.mayBeExposed( { user }, Model ) ) {
224
- logDebug( `omit notification to user ${user?.uuid} (${user.name}) at peer ${socket.id} with access on ${Model.name} forbidden by model` );
225
- return;
226
- }
227
+ const userId = user?.uuid ?? "(null)";
228
+ let cached = userCache[userId];
229
+
230
+ if ( cached == null ) {
231
+ cached = userCache[userId] = false;
227
232
 
228
- let mayRead = OdemSchema.isAdmin( user );
229
- let scope = "read";
233
+ // check connected user's common authorization to access the
234
+ // notification's model or the instance properties included with
235
+ // the notification
236
+ if ( !OdemSchema.mayBeExposed( user, Model ) ) {
237
+ logDebug( `omit notification to user ${userId} (${user?.name}) at peer ${socket.id} with access on ${Model.name} forbidden by model` );
238
+ return;
239
+ }
230
240
 
231
- if ( !mayRead && hasPluginAuth ) {
232
- mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.read` );
241
+ let mayRead = OdemSchema.isAdmin( user );
242
+ let scope = "read";
233
243
 
234
- if ( !mayRead ) {
235
- mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.list` );
236
- scope = "list";
244
+ if ( !mayRead && hasPluginAuth ) {
245
+ mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.read` );
246
+
247
+ if ( !mayRead ) {
248
+ mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.list` );
249
+ scope = "list";
250
+ }
237
251
  }
238
- }
239
252
 
240
- const prefix = api.utility.case.pascalToKebab( Model.name );
253
+ // check model's life cycle hook individually controlling
254
+ // if current socket's user may receive the notification
255
+ const mayBeNotified = await item.beforeNotify( user, payload.change );
256
+
257
+ if ( !mayBeNotified ) {
258
+ logDebug( `notification to user ${userId} (${user?.name}) at peer ${socket.id} prevented by beforeNotify() of model ${Model.name}` );
259
+ return;
260
+ }
241
261
 
242
- if ( !mayRead ) {
243
- // user must not read items of this model -> do not provide any identifier, but basically notify on some change to the model
244
- socket.emit( `${prefix}:changed`, { ...payload, properties: {} } );
245
- socket.emit( "*:changed", { ...payload, properties: {} } );
246
- } else if ( filterProperties ) {
247
- const copy = { ...payload };
262
+ // eventually send the notification ...
263
+ if ( !mayRead ) {
264
+ // user must not read items of this model -> do not
265
+ // provide any identifier, but basically notify on some
266
+ // (unspecified) change to the model
267
+ cached = userCache[userId] = { ...payload, properties: {} };
268
+ } else if ( filterProperties ) {
269
+ // user may read items of this model, but access to some
270
+ // properties may be restricted by the model's schema
271
+ cached = userCache[userId] = {
272
+ ...payload,
273
+ properties: OdemSchema.filterItem( payload.properties, user, Model, scope ),
274
+ };
275
+ } else {
276
+ // user gets notification with all properties of item
277
+ cached = userCache[userId] = payload;
278
+ }
279
+ }
248
280
 
249
- copy.properties = OdemSchema.filterItem( copy.properties, { user }, Model, scope );
281
+ if ( cached ) {
282
+ // user may receive notification -> emit it eventually
283
+ const prefix = api.utility.case.pascalToKebab( Model.name );
250
284
 
251
- socket.emit( `${prefix}:changed`, copy );
252
- socket.emit( "*:changed", copy );
253
- } else {
254
- socket.emit( `${prefix}:changed`, payload );
255
- socket.emit( "*:changed", payload );
285
+ socket.emit( `${prefix}:changed`, cached );
286
+ socket.emit( "*:changed", cached );
256
287
  }
257
288
  };
258
289
 
259
290
  for ( const socket of sockets ) {
260
291
  notifyPeer( socket ).catch( cause => {
261
292
  logError(
262
- `notifying peer ${socket.id} on having ${payload.change} item of ${Model.name} has failed:`,
293
+ `notifying peer ${socket.id} on having ${payload.change} item ${item.uuid} of ${Model.name} has failed:`,
263
294
  cause.stack
264
295
  );
265
296
  } );
@@ -309,7 +340,7 @@ module.exports = function() {
309
340
  }[action];
310
341
 
311
342
  if ( !handler ) {
312
- logDebug( "ignoring socket-based request for actin %s on model %s due to lack of handler", action, Model.name );
343
+ logDebug( "ignoring socket-based request for action %s on model %s due to lack of handler", action, Model.name );
313
344
  return;
314
345
  }
315
346
 
@@ -344,7 +375,7 @@ module.exports = function() {
344
375
  }
345
376
  }
346
377
 
347
- if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
378
+ if ( !api.service.OdemSchema.mayBeExposed( user, Model ) ) {
348
379
  throw new Error( "access forbidden by model" );
349
380
  }
350
381
 
@@ -353,7 +384,7 @@ module.exports = function() {
353
384
  let filter;
354
385
 
355
386
  if ( loadRecords ) {
356
- filter = item => api.service.OdemSchema.filterItem( item.toObject( { serialized: true } ), { user }, Model, "list" );
387
+ filter = item => api.service.OdemSchema.filterItem( this.serializeItem( item, false ), user, Model, "list" );
357
388
  } else {
358
389
  filter = item => ( { uuid: item.uuid } );
359
390
  }
@@ -396,7 +427,7 @@ module.exports = function() {
396
427
  }
397
428
  }
398
429
 
399
- if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
430
+ if ( !api.service.OdemSchema.mayBeExposed( user, Model ) ) {
400
431
  throw new Error( "access forbidden by model" );
401
432
  }
402
433
 
@@ -405,7 +436,7 @@ module.exports = function() {
405
436
  let filter;
406
437
 
407
438
  if ( loadRecords ) {
408
- filter = item => api.service.OdemSchema.filterItem( item.toObject( { serialized: true } ), { user }, Model, "list" );
439
+ filter = item => api.service.OdemSchema.filterItem( this.serializeItem( item, false ), user, Model, "list" );
409
440
  } else {
410
441
  filter = item => ( { uuid: item.uuid } );
411
442
  }
@@ -434,7 +465,7 @@ module.exports = function() {
434
465
  * @returns {Promise<void>} promise settled on request handled
435
466
  */
436
467
  static async handleCreateRequest( socket, Model, properties, response ) {
437
- logDebug( "request for creating %s instance with %j", Model.name, properties );
468
+ logDebug( "request for creating %s instance with %j", Model.name, reduce( properties ) );
438
469
 
439
470
  const { Authorization, OdemSchema } = api.service;
440
471
 
@@ -445,17 +476,17 @@ module.exports = function() {
445
476
  throw new Error( "access forbidden" );
446
477
  }
447
478
 
448
- if ( !OdemSchema.mayBeExposed( { user }, Model ) ) {
479
+ if ( !OdemSchema.mayBeExposed( user, Model ) ) {
449
480
  throw new Error( "access forbidden by model" );
450
481
  }
451
482
 
452
483
  const schema = Model.schema;
453
- const filtered = OdemSchema.filterItem( properties, { user }, Model, "create" );
484
+ const filtered = OdemSchema.filterItem( properties, user, Model, "create" );
454
485
  const instance = new Model();
455
486
 
456
- for ( const [ propName, propValue ] of Object.entries( filtered ) ) {
487
+ for ( const propName of Object.keys( filtered ) ) {
457
488
  if ( schema.props.hasOwnProperty( propName ) || schema.computed.hasOwnProperty( propName ) ) {
458
- instance[propName] = propValue;
489
+ instance[propName] = filtered[propName];
459
490
  }
460
491
  }
461
492
 
@@ -463,7 +494,7 @@ module.exports = function() {
463
494
 
464
495
  response( {
465
496
  success: true,
466
- properties: OdemSchema.filterItem( instance.toObject( { serialized: true } ), { user }, Model, "read" ),
497
+ properties: OdemSchema.filterItem( this.serializeItem( instance, false ), user, Model, "read" ),
467
498
  } );
468
499
  } catch ( error ) {
469
500
  logError( "creating %s instance failed: %s", Model.name, error.stack );
@@ -495,7 +526,7 @@ module.exports = function() {
495
526
  }
496
527
  }
497
528
 
498
- if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
529
+ if ( !api.service.OdemSchema.mayBeExposed( user, Model ) ) {
499
530
  throw new Error( "access forbidden by model" );
500
531
  }
501
532
 
@@ -504,7 +535,7 @@ module.exports = function() {
504
535
 
505
536
  response( {
506
537
  success: true,
507
- properties: api.service.OdemSchema.filterItem( instance.toObject( { serialized: true } ), { user }, Model, "read" ),
538
+ properties: api.service.OdemSchema.filterItem( this.serializeItem( instance, true ), user, Model, "read" ),
508
539
  } );
509
540
  } catch ( error ) {
510
541
  logError( "reading %s instance failed: %s", Model.name, error.stack );
@@ -526,7 +557,7 @@ module.exports = function() {
526
557
  * @returns {Promise<void>} promise settled on request handled
527
558
  */
528
559
  static async handleUpdateRequest( socket, Model, uuid, properties, response ) {
529
- logDebug( "request for updating %s instance %s with %j", Model.name, uuid, properties );
560
+ logDebug( "request for updating %s instance %s with %j", Model.name, uuid, reduce( properties ) );
530
561
 
531
562
  try {
532
563
  const user = await this.getUserForSocket( socket );
@@ -537,12 +568,12 @@ module.exports = function() {
537
568
  }
538
569
  }
539
570
 
540
- if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
571
+ if ( !api.service.OdemSchema.mayBeExposed( user, Model ) ) {
541
572
  throw new Error( "access forbidden by model" );
542
573
  }
543
574
 
544
575
  const schema = Model.schema;
545
- const filtered = api.service.OdemSchema.filterItem( properties, { user }, Model, "write" );
576
+ const filtered = api.service.OdemSchema.filterItem( properties, user, Model, "write" );
546
577
  const instance = new Model( uuid );
547
578
  await instance.load();
548
579
 
@@ -556,7 +587,7 @@ module.exports = function() {
556
587
 
557
588
  response( {
558
589
  success: true,
559
- properties: api.service.OdemSchema.filterItem( instance.toObject( { serialized: true } ), { user }, Model, "read" ),
590
+ properties: api.service.OdemSchema.filterItem( this.serializeItem( instance, false ), user, Model, "read" ),
560
591
  } );
561
592
  } catch ( error ) {
562
593
  logError( "updating %s instance failed: %s", Model.name, error.stack );
@@ -588,7 +619,7 @@ module.exports = function() {
588
619
  }
589
620
  }
590
621
 
591
- if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
622
+ if ( !api.service.OdemSchema.mayBeExposed( user, Model ) ) {
592
623
  throw new Error( "access forbidden by model" );
593
624
  }
594
625
 
@@ -607,6 +638,47 @@ module.exports = function() {
607
638
  } );
608
639
  }
609
640
  }
641
+
642
+ /**
643
+ * Serializes provided instance of a model optionally including/excluding
644
+ * properties marked as _lazy_.
645
+ *
646
+ * This method is used to provide reduced sets of properties per item
647
+ * on listing/updating items while delivering all properties when
648
+ * reading individual items.
649
+ *
650
+ * @param {Hitchy.Plugin.Odem.Model} item instance of model
651
+ * @param {boolean} includeLazyProperties if true, properties marked as lazy in schema of model are included with resulting record
652
+ * @returns {object} record of provided item's serialized properties
653
+ */
654
+ static serializeItem( item, includeLazyProperties = false ) {
655
+ const record = item.toObject( { serialized: true } );
656
+
657
+ if ( includeLazyProperties ) {
658
+ return record;
659
+ }
660
+
661
+ const filtered = {};
662
+
663
+ for ( const name of Object.keys( record ) ) {
664
+ let definition = item.constructor.schema.props[name];
665
+
666
+ if ( definition == null ) {
667
+ definition = item.constructor.schema.computed[name];
668
+ }
669
+
670
+ const lazy = definition?.lazy;
671
+
672
+ if ( !lazy ||
673
+ ( typeof lazy === "string" && lazy !== "ws" && lazy !== "socket" && lazy !== "websocket" ) ||
674
+ ( typeof lazy === "object" && !lazy.ws && !lazy.socket && !lazy.websocket )
675
+ ) {
676
+ filtered[name] = record[name];
677
+ }
678
+ }
679
+
680
+ return filtered;
681
+ }
610
682
  }
611
683
 
612
684
  return OdemWebsocketProvider;
@@ -615,7 +687,7 @@ module.exports = function() {
615
687
  /**
616
688
  * Commonly responds to action requests in case of having disabled them.
617
689
  *
618
- * @param {function(response: any):void} respondFn callback to be invoked for responding to a peer event
690
+ * @param {function(any):void} respondFn callback to be invoked for responding to a peer event
619
691
  * @returns {void}
620
692
  */
621
693
  function reject( respondFn ) {
@@ -623,3 +695,42 @@ function reject( respondFn ) {
623
695
  error: "requested action is not available due to runtime configuration",
624
696
  } );
625
697
  }
698
+
699
+ /**
700
+ * Recursively trims values of provided data.
701
+ *
702
+ * @param {*} data data to be trimmed
703
+ * @param {number} limit number of characters to keep in strings when trimming
704
+ * @returns {*} provided data trimmed as necessary
705
+ */
706
+ function reduce( data, limit = 200 ) {
707
+ if ( Array.isArray( data ) ) {
708
+ return data.map( item => reduce( item, Math.max( 10, limit / 5 ) ) );
709
+ }
710
+
711
+ if ( data && typeof data === "object" ) {
712
+ const copy = {};
713
+
714
+ for ( const name of Object.keys( data ) ) {
715
+ copy[name] = reduce( data[name], Math.max( 10, limit / 5 ) );
716
+ }
717
+
718
+ return copy;
719
+ }
720
+
721
+ switch ( typeof data ) {
722
+ case "function" :
723
+ case "string" : {
724
+ const s = String( data );
725
+
726
+ if ( s.length > limit ) {
727
+ return s.substring( 0, limit ) + "…+" + String( limit - s.length );
728
+ }
729
+
730
+ return s;
731
+ }
732
+
733
+ default :
734
+ return data;
735
+ }
736
+ }
package/config/socket.js CHANGED
@@ -1,6 +1,4 @@
1
- "use strict";
2
-
3
- module.exports = {
1
+ export default {
4
2
  socket: {
5
3
  odem: {
6
4
  /**
package/index.js CHANGED
@@ -1,6 +1,5 @@
1
- "use strict";
2
-
3
- module.exports = function() {
1
+ /** */
2
+ export default function() {
4
3
  const api = this;
5
4
 
6
5
  return {
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@hitchy/plugin-odem-socket.io",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "description": "exposing Hitchy's document-oriented database via websocket",
5
5
  "main": "index.js",
6
+ "type": "module",
6
7
  "scripts": {
7
8
  "lint": "eslint .",
8
- "test": "hitchy-pm odem socket.io auth --exec mocha --ui=tdd 'test/**/*.spec.js'",
9
- "coverage": "hitchy-pm odem socket.io auth --exec c8 mocha --ui=tdd 'test/**/*.spec.js'"
9
+ "test": "hitchy-pm odem socket.io auth --exec mocha --ui=tdd test/**/*.spec.js",
10
+ "coverage": "hitchy-pm odem socket.io auth --exec c8 mocha --ui=tdd test/**/*.spec.js"
10
11
  },
11
12
  "repository": {
12
13
  "type": "git",
@@ -29,18 +30,24 @@
29
30
  "index.js"
30
31
  ],
31
32
  "peerDependencies": {
32
- "@hitchy/core": ">=0.8.1",
33
- "@hitchy/plugin-odem": ">=0.9.1",
34
- "@hitchy/plugin-socket.io": ">=0.1.1"
33
+ "@hitchy/core": "^1.5.5",
34
+ "@hitchy/plugin-odem": "^0.14.0",
35
+ "@hitchy/plugin-socket.io": "^0.1.3"
35
36
  },
36
37
  "devDependencies": {
37
- "@hitchy/server-dev-tools": "^0.4.9",
38
- "c8": "^10.1.2",
39
- "eslint": "^8.57.0",
40
- "eslint-config-cepharum": "^1.0.14",
41
- "eslint-plugin-promise": "^7.1.0",
42
- "mocha": "^10.7.3",
38
+ "@hitchy/server-dev-tools": "^0.9.6",
39
+ "c8": "^10.1.3",
40
+ "eslint": "^9.39.2",
41
+ "eslint-config-cepharum": "^2.0.2",
42
+ "mocha": "^11.7.5",
43
43
  "should": "^13.2.3",
44
- "socket.io-client": "^4.7.5"
44
+ "socket.io-client": "^4.8.3"
45
+ },
46
+ "c8": {
47
+ "reporter": [
48
+ "text",
49
+ "html"
50
+ ],
51
+ "all": true
45
52
  }
46
53
  }
package/readme.md CHANGED
@@ -59,7 +59,7 @@ socket.on( "connect", () => {
59
59
  } );
60
60
  ```
61
61
 
62
- > socket.io requires all arguments to be provided no matter what. Thus, you can't omit any of the arguments given in examples below but have to provide values assumed to be default, at least.
62
+ > socket.io requires all arguments to be provided no matter what. Thus, you can not omit any of the arguments given in examples below but have to provide values assumed to be default, at least.
63
63
 
64
64
  The following code excerpts are focusing on the inner part of emitting an action event and processing the response.
65
65
 
@@ -166,8 +166,12 @@ Last argument is a callback to be invoked with the resulting response from serve
166
166
  The type of change is
167
167
 
168
168
  * `created` on notifications about having created another instance of model,
169
- * `updated` on notifications about having adjusted one or more properties of an existing instance of model and
170
- * `deleted` on notifications about having removed an existing instance of model.
169
+ * `changed` on notifications about having adjusted one or more properties of an existing instance of model and
170
+ * `removed` on notifications about having removed an existing instance of model.
171
+
172
+ :::danger Breaking change
173
+ Notification types have changed in v0.5.0 from `updated` to `changed` and from `deleted` to `removed` to match names used on server side.
174
+ :::
171
175
 
172
176
  In last case the provided set of properties is limited to the instance's UUID. The common pattern on client-side for listening to server-side notifications looks like this:
173
177
 
@@ -175,7 +179,7 @@ In last case the provided set of properties is limited to the instance's UUID. T
175
179
  const socket = io( "/hitchy/odem" );
176
180
 
177
181
  socket.on( "<modelName>:changed", info => {
178
- // type of change is in `info.change`, e.g. "updated"
182
+ // type of change is in `info.change`, e.g. "created", "changed" or "removed"
179
183
  // process properties of changed instance in `info.properties`
180
184
  } );
181
185
  ```