@jcbuisson/express-x 2.1.12 → 2.1.14

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": "@jcbuisson/express-x",
3
- "version": "2.1.12",
3
+ "version": "2.1.14",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
@@ -1,6 +1,8 @@
1
1
 
2
2
  import bcrypt from 'bcryptjs'
3
3
 
4
+ import { EXError } from './server.mjs'
5
+
4
6
  /*
5
7
  * Add a timestamp property of name `field` with current time as value
6
8
  */
@@ -34,13 +36,6 @@ export function protect(field) {
34
36
  }
35
37
  }
36
38
 
37
- class NotAuthenticatedError extends Error {
38
- constructor(message) {
39
- super(message)
40
- this.code = 'not-authenticated'
41
- }
42
- }
43
-
44
39
  export const isNotExpired = async (context) => {
45
40
  // do nothing if it's not a client call from a ws connexion
46
41
  if (!context.socket) return
@@ -61,10 +56,10 @@ export const isNotExpired = async (context) => {
61
56
  // send an event to the client (typical client handling: logout)
62
57
  context.socket.emit('expired')
63
58
  // throw exception
64
- throw new NotAuthenticatedError("Session expired")
59
+ throw new EXError('not-authenticated', "Session expired")
65
60
  }
66
61
  } else {
67
- throw new NotAuthenticatedError("No expiresAt in socket.data")
62
+ throw new EXError('not-authenticated', "No expiresAt in socket.data")
68
63
  }
69
64
  }
70
65
 
@@ -74,7 +69,7 @@ export const isNotExpired = async (context) => {
74
69
  export const isAuthenticated = async (context) => {
75
70
  // do nothing if it's not a client call from a ws connexion
76
71
  if (!context.socket) return
77
- if (!context.socket.data.user) throw new NotAuthenticatedError('no user in socket.data')
72
+ if (!context.socket.data.user) throw new EXError('not-authenticated', 'no user in socket.data')
78
73
  }
79
74
 
80
75
  /*
@@ -83,4 +78,4 @@ export const isAuthenticated = async (context) => {
83
78
  export const extendExpiration = (duration) => async (context) => {
84
79
  const now = new Date()
85
80
  context.socket.data.expiresAt = new Date(now.getTime() + duration)
86
- }
81
+ }
package/src/index.mjs CHANGED
@@ -1,14 +1,398 @@
1
1
 
2
- import { expressX } from './server.mjs'
3
- import { addTimestamp, hashPassword, protect, isAuthenticated, isNotExpired, extendExpiration } from './common-hooks.mjs'
4
-
5
- export {
6
- expressX,
7
-
8
- addTimestamp,
9
- hashPassword,
10
- protect,
11
- isAuthenticated,
12
- isNotExpired,
13
- extendExpiration,
2
+ // import { expressX } from './server.mjs'
3
+ // import { addTimestamp, hashPassword, protect, isAuthenticated, isNotExpired, extendExpiration } from './common-hooks.mjs'
4
+
5
+ // export {
6
+ // expressX,
7
+
8
+ // addTimestamp,
9
+ // hashPassword,
10
+ // protect,
11
+ // isAuthenticated,
12
+ // isNotExpired,
13
+ // extendExpiration,
14
+ // }
15
+
16
+
17
+ import express from "express"
18
+ import { createServer } from "http"
19
+ import { Server } from "socket.io"
20
+ import bcrypt from 'bcryptjs'
21
+
22
+
23
+
24
+ // UTILISER L'ACKNOWLEDGEMENT : https://socket.io/docs/v4/#acknowledgements
25
+
26
+
27
+ export function expressX(config) {
28
+
29
+ const services = {}
30
+ const socketConnectListeners = []
31
+ const socketDisconnectingListeners = []
32
+ const socketDisconnectListeners = []
33
+
34
+
35
+ function addConnectListener(func) {
36
+ socketConnectListeners.push(func)
37
+ }
38
+
39
+ function addDisconnectingListener(func) {
40
+ socketDisconnectingListeners.push(func)
41
+ }
42
+
43
+ function addDisconnectListener(func) {
44
+ socketDisconnectListeners.push(func)
45
+ }
46
+
47
+ const app = express()
48
+ const httpServer = createServer(app)
49
+
50
+ // so that config can be accessed anywhere with app.get('config')
51
+ app.set('config', config)
52
+
53
+ const io = new Server(httpServer, {
54
+ path: config?.WS_PATH || '/socket.io/',
55
+ connectionStateRecovery: {
56
+ // the backup duration of the sessions and the packets
57
+ maxDisconnectionDuration: 2 * 60 * 1000,
58
+ // whether to skip middlewares upon successful recovery
59
+ skipMiddlewares: true,
60
+ }
61
+ })
62
+
63
+ // so that io server is accessible to hooks & services
64
+ app.set('io', io)
65
+
66
+ // logging function - a winston logger must be configured first
67
+ app.log = (severity, message) => {
68
+ const logger = app.get('logger')
69
+ if (logger) logger.log(severity, message); else console.log(`[${severity}]`, message)
70
+ }
71
+
72
+ io.on('connection', async function(socket) {
73
+ if (socket.recovered) {
74
+ // recovery was successful: socket.id, socket.rooms and socket.data were restored
75
+ // (network/Wifi disconnections)
76
+ console.log('reconnection!!!', socket.id, socket.data)
77
+ } else {
78
+ // new or unrecoverable connection
79
+ // (page open, page refresh/reload)
80
+ }
81
+
82
+ app.log('verbose', `Client connected ${socket.id}`)
83
+
84
+ // // emit 'connection' event for app (expressjs extends EventEmitter)
85
+ // app.emit('connection', socket)
86
+
87
+ socketConnectListeners.forEach(listener => listener(socket))
88
+
89
+ // // send 'connected' event to client
90
+ // socket.emit('connected', socket.id)
91
+
92
+ socket.on('disconnecting', (reason) => {
93
+ app.log('verbose', `Client disconnecting ${socket.id}, ${reason}`)
94
+ socketDisconnectingListeners.forEach(listener => listener(socket, reason))
95
+ })
96
+
97
+ socket.on('disconnect', (reason) => {
98
+ app.log('verbose', `Client disconnect ${socket.id}, ${reason}`)
99
+ socketDisconnectListeners.forEach(listener => listener(socket, reason))
100
+ })
101
+
102
+ /*
103
+ * Handle websocket client request
104
+ * Emit in return a 'client-response' message
105
+ */
106
+ socket.on('client-request', async ({ uid, name, action, args }) => {
107
+ const trimmedArgs = args ? JSON.stringify(args).slice(0, 300) : ''
108
+ app.log('verbose', `client-request ${uid} ${name} ${action} ${trimmedArgs}`)
109
+ if (name in services) {
110
+ const service = services[name]
111
+ try {
112
+ const serviceMethod = service['__' + action]
113
+ if (serviceMethod) {
114
+ const context = {
115
+ app,
116
+ caller: 'client',
117
+ transport: 'ws',
118
+ socket,
119
+ // connectionId,
120
+ serviceName: name,
121
+ methodName: action,
122
+ args,
123
+ }
124
+
125
+ try {
126
+ // call method with context
127
+ const result = await serviceMethod(context, ...args)
128
+
129
+ const trimmedResult = result ? JSON.stringify(result).slice(0, 300) : ''
130
+ app.log('verbose', `client-response ${uid} ${trimmedResult}`)
131
+ socket.emit('client-response', {
132
+ uid,
133
+ result,
134
+ })
135
+ } catch(err) {
136
+ console.log('!!!!!!error', err.code, err.message)
137
+ app.log('verbose', err.stack)
138
+ socket.emit('client-response', {
139
+ uid,
140
+ error: {
141
+ code: err.code || 'unknown-error',
142
+ message: err.message,
143
+ stack: err.stack,
144
+ }
145
+ })
146
+ }
147
+ } else {
148
+ socket.emit('client-response', {
149
+ uid,
150
+ error: {
151
+ code: 'missing-method',
152
+ message: `there is no method named '${action}' for service '${name}'`,
153
+ }
154
+ })
155
+ }
156
+ } catch(err) {
157
+ console.log('err', err)
158
+ app.log('verbose', err.stack)
159
+ socket.emit('client-response', {
160
+ uid,
161
+ error: {
162
+ code: err.code || 'unknown-error',
163
+ message: err.message,
164
+ stack: err.stack,
165
+ }
166
+ })
167
+ }
168
+ } else {
169
+ socket.emit('client-response', {
170
+ uid,
171
+ error: {
172
+ code: 'missing-service',
173
+ message: `there is no service named '${name}'`,
174
+ }
175
+ })
176
+ }
177
+ })
178
+
179
+ })
180
+
181
+ /*
182
+ * create a service `name` with given `methods`
183
+ */
184
+ function createService(name, methods) {
185
+ const service = {}
186
+
187
+ for (const methodName in methods) {
188
+ const method = methods[methodName]
189
+ if (! method instanceof Function) continue
190
+
191
+ // `context` is the context of execution (transport type, connection, app)
192
+ // `args` is the list of arguments of the method
193
+ service['__' + methodName] = async (context, ...args) => {
194
+ // put args into context
195
+ context.args = args
196
+
197
+ // if a hook or the method throws an error, it will be caught by `socket.on('client-request'`
198
+ // and the client will get a rejected promise
199
+
200
+ // call 'before' hooks, possibly modifying `context`
201
+ const beforeMethodHooks = service?._hooks?.before && service._hooks.before[methodName] || []
202
+ const beforeAllHooks = service?._hooks?.before?.all || []
203
+ for (const hook of [...beforeAllHooks, ...beforeMethodHooks]) {
204
+ await hook(context)
205
+ }
206
+
207
+ // call method
208
+ const result = await method(...args)
209
+ // put result into context
210
+ context.result = result
211
+
212
+ // call 'after' hooks, possibly modifying `context`
213
+ const afterMethodHooks = service?._hooks?.after && service._hooks.after[methodName] || []
214
+ const afterAllHooks = service?._hooks?.after?.all || []
215
+ for (const hook of [...afterMethodHooks, ...afterAllHooks]) {
216
+ await hook(context)
217
+ }
218
+
219
+ // publish 'service-event' event associated to service/method
220
+ if (service.publishFunction) {
221
+ // collect channel names to socket is member of
222
+ const channelNames = await service.publishFunction(context)
223
+ app.log('verbose', `publish channels ${name} ${methodName} ${channelNames}`)
224
+ // send event on all these channels
225
+ if (channelNames.length > 0) {
226
+ let sender = io.to(channelNames[0])
227
+ for (let i = 1; i < channelNames.length; i++) {
228
+ sender = sender.to(channelNames[i])
229
+ }
230
+ sender.emit('service-event', {
231
+ name,
232
+ action: methodName,
233
+ result,
234
+ })
235
+ }
236
+ }
237
+
238
+ return context.result
239
+ }
240
+
241
+ // hooked version of method to be used server-side
242
+ service[methodName] = (...args) => {
243
+ const context = {
244
+ app,
245
+ caller: 'server',
246
+ serviceName: service._name,
247
+ methodName,
248
+ args,
249
+ }
250
+ const hookedMethod = service['__' + methodName]
251
+ return hookedMethod(context, ...args)
252
+ }
253
+ }
254
+
255
+ // attach pub/sub publish callback
256
+ service.publish = async (func) => {
257
+ service.publishFunction = func
258
+ },
259
+
260
+ // attach hooks
261
+ service.hooks = (hooks) => {
262
+ service._hooks = hooks
263
+ }
264
+
265
+ // cache service in `services`
266
+ services[name] = service
267
+ return service
268
+ }
269
+
270
+ function configure(callback) {
271
+ callback(app)
272
+ }
273
+
274
+ // `app.service(name)` starts here!
275
+ function service(name) {
276
+ // get service from `services` cache
277
+ if (name in services) return services[name]
278
+ app.log('error', `there is no service named '${name}'`, 'missing-service')
279
+ }
280
+
281
+ function joinChannel(channelName, socket) {
282
+ app.log('verbose', `joining ${channelName}, ${JSON.stringify(socket.data)}`)
283
+ socket.join(channelName)
284
+ }
285
+
286
+ function leaveChannel(channelName, socket) {
287
+ app.log('verbose', `leaving ${channelName}, ${JSON.stringify(socket.data)}`)
288
+ socket.leave(channelName)
289
+ }
290
+
291
+ // There is a need for events sent outside any service method call, for example on unsolicited-by-frontend backend state change
292
+ async function sendAppEvent(channelName, type, value) {
293
+ console.log('sendAppEvent', channelName, type, value)
294
+ io.to(channelName).emit('app-event', { type, value })
295
+ }
296
+
297
+ // enhance `app` with objects and methods
298
+ return Object.assign(app, {
299
+ io,
300
+ httpServer,
301
+ createService,
302
+ service,
303
+ configure,
304
+ joinChannel,
305
+ leaveChannel,
306
+ sendAppEvent,
307
+ addConnectListener,
308
+ addDisconnectingListener,
309
+ addDisconnectListener,
310
+ })
311
+ }
312
+
313
+ export default class EXError extends Error {
314
+ constructor(code, message) {
315
+ super(message)
316
+ this.code = code
317
+ }
318
+ }
319
+
320
+
321
+
322
+
323
+ /*
324
+ * Add a timestamp property of name `field` with current time as value
325
+ */
326
+ export const addTimestamp = (field) => async (context) => {
327
+ context.result[field] = (new Date()).toISOString()
328
+ return context
329
+ }
330
+
331
+ /*
332
+ * Hash password of the property `field`
333
+ */
334
+ export const hashPassword = (passwordField) => async (context) => {
335
+ const user = context.result
336
+ user[passwordField] = await bcrypt.hash(user[passwordField], 5)
337
+ return context
338
+ }
339
+
340
+ /*
341
+ * Remove `field` from `context.result`
342
+ */
343
+ export function protect(field) {
344
+ return async (context) => {
345
+ if (Array.isArray(context.result)) {
346
+ for (const value of context.result) {
347
+ delete value[field]
348
+ }
349
+ } else {
350
+ delete context.result[field]
351
+ }
352
+ return (context)
353
+ }
354
+ }
355
+
356
+ export const isNotExpired = async (context) => {
357
+ // do nothing if it's not a client call from a ws connexion
358
+ if (!context.socket) return
359
+ const expiresAt = context.socket.data.expiresAt
360
+ if (expiresAt) {
361
+ const expiresAtDate = new Date(expiresAt)
362
+ const now = new Date()
363
+ if (now > expiresAtDate) {
364
+ // expiration date is met
365
+ // clear socket.data
366
+ context.socket.data = {}
367
+ // leave all rooms except socket#id
368
+ const rooms = new Set(context.socket.rooms)
369
+ for (const room of rooms) {
370
+ if (room === context.socket.id) continue
371
+ context.socket.leave(room)
372
+ }
373
+ // send an event to the client (typical client handling: logout)
374
+ context.socket.emit('expired')
375
+ // throw exception
376
+ throw new EXError('not-authenticated', "Session expired")
377
+ }
378
+ } else {
379
+ throw new EXError('not-authenticated', "No expiresAt in socket.data")
380
+ }
381
+ }
382
+
383
+ /*
384
+ * Throw an error for a client service method call when socket.data does not contain user
385
+ */
386
+ export const isAuthenticated = async (context) => {
387
+ // do nothing if it's not a client call from a ws connexion
388
+ if (!context.socket) return
389
+ if (!context.socket.data.user) throw new EXError('not-authenticated', 'no user in socket.data')
390
+ }
391
+
392
+ /*
393
+ * Extend value of socket.data.expiresAt of `duration` milliseconds
394
+ */
395
+ export const extendExpiration = (duration) => async (context) => {
396
+ const now = new Date()
397
+ context.socket.data.expiresAt = new Date(now.getTime() + duration)
14
398
  }
package/src/server.mjs CHANGED
@@ -5,6 +5,13 @@ import { Server } from "socket.io"
5
5
 
6
6
  // UTILISER L'ACKNOWLEDGEMENT : https://socket.io/docs/v4/#acknowledgements
7
7
 
8
+ export default class EXError extends Error {
9
+ constructor(code, message) {
10
+ super(message)
11
+ this.code = code
12
+ }
13
+ }
14
+
8
15
  export function expressX(config) {
9
16
 
10
17
  const services = {}
@@ -162,7 +169,7 @@ export function expressX(config) {
162
169
  /*
163
170
  * create a service `name` with given `methods`
164
171
  */
165
- function createService(serviceName, methods) {
172
+ function createService(name, methods) {
166
173
  const service = {}
167
174
 
168
175
  for (const methodName in methods) {
@@ -201,7 +208,7 @@ export function expressX(config) {
201
208
  if (service.publishFunction) {
202
209
  // collect channel names to socket is member of
203
210
  const channelNames = await service.publishFunction(context)
204
- app.log('verbose', `publish channels ${serviceName} ${methodName} ${channelNames}`)
211
+ app.log('verbose', `publish channels ${name} ${methodName} ${channelNames}`)
205
212
  // send event on all these channels
206
213
  if (channelNames.length > 0) {
207
214
  let sender = io.to(channelNames[0])
@@ -209,7 +216,7 @@ export function expressX(config) {
209
216
  sender = sender.to(channelNames[i])
210
217
  }
211
218
  sender.emit('service-event', {
212
- name: serviceName,
219
+ name,
213
220
  action: methodName,
214
221
  result,
215
222
  })
@@ -224,7 +231,7 @@ export function expressX(config) {
224
231
  const context = {
225
232
  app,
226
233
  caller: 'server',
227
- serviceName,
234
+ serviceName: service._name,
228
235
  methodName,
229
236
  args,
230
237
  }
@@ -244,7 +251,7 @@ export function expressX(config) {
244
251
  }
245
252
 
246
253
  // cache service in `services`
247
- services[serviceName] = service
254
+ services[name] = service
248
255
  return service
249
256
  }
250
257