@jcbuisson/express-x 1.1.3 → 1.2.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jcbuisson/express-x",
3
- "version": "1.1.3",
3
+ "version": "1.2.0",
4
4
  "description": "",
5
5
  "type": "module",
6
6
  "main": "src/index.mjs",
@@ -15,11 +15,13 @@
15
15
  "test": "mocha --require esm 'test/**/*.test.js'",
16
16
  "generate": "npx prisma generate",
17
17
  "migrate": "npx prisma migrate dev --name init"
18
- },
18
+ },
19
19
  "keywords": [],
20
20
  "dependencies": {
21
+ "@jcbuisson/express-x-client": "^1.0.10",
21
22
  "@prisma/client": "^4.10.1",
22
23
  "axios": "^1.4.0",
24
+ "bcrypt": "^5.1.0",
23
25
  "config": "^3.3.9",
24
26
  "esm": "^3.2.25",
25
27
  "express": "^4.18.2",
package/prisma/dev.db CHANGED
Binary file
package/src/client.mjs ADDED
@@ -0,0 +1,95 @@
1
+
2
+ import { v4 } from 'uuid'
3
+
4
+ export function expressXClient(socket, options={}) {
5
+ if (options.debug === undefined) options.debug = false
6
+
7
+ const waitingPromisesByUid = {}
8
+ const action2service2handlers = {}
9
+ let onConnectionCallback = null
10
+ let onDisconnectionCallback = null
11
+
12
+ const setConnectionCallback = (callback) => {
13
+ onConnectionCallback = callback
14
+ }
15
+
16
+ const setDisconnectionCallback = (callback) => {
17
+ onDisconnectionCallback = callback
18
+ }
19
+
20
+ // on connection
21
+ socket.on("connected", async (connectionId) => {
22
+ if (options.debug) console.log('connected', connectionId)
23
+ if (onConnectionCallback) onConnectionCallback(connectionId)
24
+ })
25
+
26
+ // on receiving response from service request
27
+ socket.on('client-response', ({ uid, error, result }) => {
28
+ if (options.debug) console.log('client-response', uid, error, result)
29
+ if (!waitingPromisesByUid[uid]) return // may not exist because a timeout removed it
30
+ const [resolve, reject] = waitingPromisesByUid[uid]
31
+ if (error) {
32
+ reject(error)
33
+ } else {
34
+ resolve(result)
35
+ }
36
+ delete waitingPromisesByUid[uid]
37
+ })
38
+
39
+ // on receiving events from pub/sub
40
+ socket.on('service-event', ({ name, action, result }) => {
41
+ if (!action2service2handlers[action]) action2service2handlers[action] = {}
42
+ const serviceHandlers = action2service2handlers[action]
43
+ const handler = serviceHandlers[name]
44
+ if (handler) handler(result)
45
+ })
46
+
47
+ async function serviceMethodRequest(name, action, ...args) {
48
+ const uid = v4()
49
+ // create a promise which will resolve or reject by an event 'client-response'
50
+ const promise = new Promise((resolve, reject) => {
51
+ waitingPromisesByUid[uid] = [resolve, reject]
52
+ // a 5s timeout may also reject the promise
53
+ setTimeout(() => {
54
+ delete waitingPromisesByUid[uid]
55
+ reject(`Error: timeout on service '${name}', action '${action}', args: ${args}`)
56
+ }, 5000)
57
+ })
58
+ // send request to server through websocket
59
+ socket.emit('client-request', {
60
+ uid,
61
+ name,
62
+ action,
63
+ args,
64
+ })
65
+ return promise
66
+ }
67
+
68
+ function service(name) {
69
+ const service = {
70
+ // associate a handler to a pub/sub event for this service
71
+ on: (action, handler) => {
72
+ if (!action2service2handlers[action]) action2service2handlers[action] = {}
73
+ const serviceHandlers = action2service2handlers[action]
74
+ serviceHandlers[name] = handler
75
+ },
76
+ }
77
+ // use a Proxy to allow for any method name for a service
78
+ const handler = {
79
+ get(service, action) {
80
+ if (!(action in service)) {
81
+ // newly used property `action`: define it as a service method request function
82
+ service[action] = (...args) => serviceMethodRequest(name, action, ...args)
83
+ }
84
+ return service[action]
85
+ }
86
+ }
87
+ return new Proxy(service, handler)
88
+ }
89
+
90
+ return {
91
+ setConnectionCallback,
92
+ setDisconnectionCallback,
93
+ service,
94
+ }
95
+ }
@@ -0,0 +1,53 @@
1
+
2
+ import config from 'config'
3
+ import bcrypt from 'bcrypt'
4
+
5
+
6
+ // hash password of user record
7
+ // name of the password field is found in `config.authentication.local.passwordField` (default: 'password')
8
+ export async function hashPassword(context) {
9
+ const passwordField = config.authentication.local.passwordField
10
+ context.args[0][passwordField] = await bcrypt.hash(context.args[0][passwordField], 5)
11
+ return context
12
+ }
13
+
14
+
15
+ export async function authenticate(context) {
16
+ console.log('authenticate hook', context.transport, context.name, context.action, context?.connection?.accessToken)
17
+ return context
18
+
19
+ if (context.transport === 'ws') {
20
+ // for WS transport, just check that connection.accessToken is set and valid
21
+ if (!context?.connection?.accessToken) throw new Error("WS connection is not secured by a JWT access token")
22
+ // TODO: check token validity
23
+
24
+ } else if (context.transport === 'http') {
25
+ // for HTTP transport, check that the associated request has a header with a valid JWT
26
+ // TODO: check headers for access token
27
+ }
28
+ return (context)
29
+ }
30
+
31
+
32
+ // remove `field` from `result`
33
+ export function protect(field) {
34
+ return async (context) => {
35
+ if (Array.isArray(context.result)) {
36
+ for (const value of context.result) {
37
+ delete value[field]
38
+ }
39
+ } else {
40
+ delete context.result[field]
41
+ }
42
+ return (context)
43
+ }
44
+ }
45
+
46
+
47
+ export async function setSessionJWT(context) {
48
+ if (context.transport === 'ws') {
49
+ // associate JWT token to WS connection to mark it as secure
50
+ context.connection.accessToken = context.result.accessToken
51
+ }
52
+ return context
53
+ }
package/src/index.mjs CHANGED
@@ -1,377 +1,13 @@
1
1
 
2
- import http from 'http'
3
- import { Server } from "socket.io"
4
- import { PrismaClient } from '@prisma/client'
2
+ import { expressXServer } from './server.mjs'
3
+ import { expressXClient } from './client.mjs'
4
+ import { hashPassword, protect, setSessionJWT, } from './common-hooks.mjs'
5
5
 
6
- /*
7
- * Enhance `app` express application with Feathers-like services
8
- */
9
- function expressX(app, options={}) {
6
+ export {
7
+ expressXServer,
8
+ expressXClient,
10
9
 
11
- if (options.debug === undefined) options.debug = false
12
- if (options.ws === undefined) options.ws = { ws_prefix: "expressx" }
13
-
14
- const services = {}
15
- const connections = {}
16
-
17
- let lastConnectionId = 1
18
-
19
- /*
20
- * create a service `name` based on Prisma table `entity`
21
- */
22
- function createDatabaseService(name, prismaOptions = { entity: name }) {
23
-
24
- let prisma = app.get('prisma')
25
- if (!prisma) {
26
- prisma = new PrismaClient()
27
- app.set('prisma', prisma)
28
- }
29
-
30
- // take all prisma methods on `entity` table
31
- const methods = prisma[prismaOptions.entity]
32
-
33
- const service = createService(name, methods)
34
-
35
- service.prisma = prisma
36
- service.entity = prismaOptions.entity
37
-
38
- if (options.debug) console.log(`created service '${name}' over table '${prismaOptions.entity}'`)
39
- return service
40
- }
41
-
42
- /*
43
- * create a service `name` with given `methods`
44
- */
45
- function createService(name, methods) {
46
- const service = { name }
47
-
48
- for (const methodName in methods) {
49
- const method = methods[methodName]
50
- if (! method instanceof Function) continue
51
-
52
- // `context` is the context of execution (transport type, connection, app)
53
- // `args` is the list of arguments of the method
54
- service['__' + methodName] = async (context, ...args) => {
55
- context.args = args
56
-
57
- // if a hook or the method throws an error, it will be caught by `socket.on('client-request'` (ws)
58
- // or by express (http) and the client will get a rejected promise
59
-
60
- // call 'before' hooks, modifying `context.args`
61
- const beforeMethodHooks = service?.hooks?.before && service.hooks.before[methodName] || []
62
- const beforeAllHooks = service?.hooks?.before?.all || []
63
- for (const hook of [...beforeMethodHooks, ...beforeAllHooks]) {
64
- context = await hook({ ...context, args })
65
- }
66
-
67
- // call method
68
- const result = await method(...context.args)
69
- // if (options.debug) console.log('result', result)
70
-
71
- // call 'after' hooks
72
- const afterMethodHooks = service?.hooks?.after && service.hooks.after[methodName] || []
73
- const afterAllHooks = service?.hooks?.after?.all || []
74
- for (const hook of [...afterMethodHooks, ...afterAllHooks]) {
75
- context = await hook({ ...context, result })
76
- }
77
- return result
78
- }
79
-
80
- // hooked version of method: `create`, etc., to be called from backend with no context
81
- service[methodName] = method
82
-
83
- // un-hooked version of method: `_create`, etc., to be called from backend with no context
84
- service['_' + methodName] = method
85
- }
86
-
87
- // attach pub/sub publish callback
88
- service.publish = async (func) => {
89
- service.publishCallback = func
90
- },
91
-
92
- // attach hooks
93
- service.hooks = (hooks) => {
94
- service.hooks = hooks
95
- }
96
-
97
- // cache service in `services`
98
- services[name] = service
99
- return service
100
- }
101
-
102
- // `app.service(name)` starts here!
103
- function service(name) {
104
- // get service from `services` cache
105
- if (name in services) return services[name]
106
- throw Error(`there is no service named '${name}'`)
107
- }
108
-
109
- function configure(callback) {
110
- callback(app)
111
- }
112
-
113
- /*
114
- * add an HTTP REST endpoint at `path`, based on `service`
115
- */
116
- async function addHttpRest(path, service) {
117
- const context = {
118
- app,
119
- http: { name: service.name }
120
- }
121
-
122
- // introspect table schema
123
- async function getFieldTypes() {
124
- const fieldTypes = {}
125
- if (service.prisma._activeProvider === 'sqlite') {
126
- const fieldInfo = await service.prisma.$queryRawUnsafe(`
127
- PRAGMA table_info(${service.entity})
128
- `)
129
- fieldInfo.forEach(column => {
130
- fieldTypes[column.name] = column.type.toLowerCase()
131
- })
132
- } else if (service.prisma._activeProvider === 'postgresql') {
133
- const fieldInfo = await service.prisma.$queryRawUnsafe(`
134
- SELECT column_name, data_type
135
- FROM information_schema.columns
136
- WHERE table_name = '${service.entity}';
137
- `)
138
- fieldInfo.forEach(column => {
139
- fieldTypes[column.column_name] = column.data_type
140
- })
141
- }
142
- return fieldTypes
143
- }
144
-
145
-
146
- app.post(path, async (req, res) => {
147
- context.http.req = req
148
- try {
149
- const value = await service.__create(context, { data: req.body })
150
- publish(service, 'create', value)
151
- res.json(value)
152
- } catch(err) {
153
- console.log('callErr', err)
154
- res.status(500).send(err.toString())
155
- }
156
- })
157
-
158
- app.get(path, async (req, res) => {
159
- context.http.req = req
160
- const query = { ...req.query }
161
- try {
162
- // the values in `req.query` are all strings, but Prisma need proper types
163
- // we need to introspect column types and do the proper transtyping
164
- for (const fieldName in query) {
165
- if (!service.fieldTypes) service.fieldTypes = await getFieldTypes()
166
- const fieldType = service.fieldTypes[fieldName]
167
-
168
- if (fieldType === 'integer') {
169
- query[fieldName] = parseInt(query[fieldName])
170
- } else if (fieldType === 'numeric') {
171
- query[fieldName] = parseFloat(query[fieldName])
172
- } else if (fieldType === 'boolean') {
173
- query[fieldName] = (query[fieldName] === 't') ? true : false
174
- } else if (fieldType === 'text' || fieldType === 'character varying') {
175
- query[fieldName] = query[fieldName]
176
- } else {
177
- // ?
178
- query[fieldName] = query[fieldName]
179
- }
180
- }
181
-
182
- const values = await service.__findMany(context, {
183
- where: query,
184
- })
185
- publish(service, 'findMany', values)
186
- res.json(values)
187
- } catch(err) {
188
- console.log('callErr', err)
189
- res.status(500).send(err.toString())
190
- }
191
- })
192
-
193
- app.get(`${path}/:id`, async (req, res) => {
194
- context.http.req = req
195
- try {
196
- const value = await service.__findUnique(context, {
197
- where: {
198
- id: parseInt(req.params.id)
199
- }
200
- })
201
- publish(service, 'findUnique', value)
202
- res.json(value)
203
- } catch(err) {
204
- console.log('callErr', err)
205
- res.status(500).send(err.toString())
206
- }
207
- })
208
-
209
- app.patch(`${path}/:id`, async (req, res) => {
210
- context.http.req = req
211
- try {
212
- const value = await service.__update(context, {
213
- where: {
214
- id: parseInt(req.params.id),
215
- },
216
- data: req.body,
217
- })
218
- publish(service, 'update', value)
219
- res.json(value)
220
- } catch(err) {
221
- console.log('callErr', err)
222
- res.status(500).send(err.toString())
223
- }
224
- })
225
-
226
- app.delete(`${path}/:id`, async (req, res) => {
227
- context.http.req = req
228
- try {
229
- const value = await service.__delete(context, {
230
- where: {
231
- id: parseInt(req.params.id)
232
- }
233
- })
234
- publish(service, 'delete', value)
235
- res.json(value)
236
- } catch(err) {
237
- console.log('callErr', err)
238
- res.status(500).send(err.toString())
239
- }
240
- })
241
-
242
- if (options.debug) console.log(`added HTTP endpoints for service '${service.name}' at path '${path}'`)
243
- }
244
-
245
- /*
246
- * Create HTTP server
247
- */
248
- const server = new http.Server(app)
249
-
250
- if (options.ws) {
251
- /*
252
- * Add websocket transport
253
- */
254
- const io = new Server(server)
255
-
256
- io.on('connection', function(socket) {
257
- if (options.debug) console.log('Client connected to the WebSocket')
258
- const connection = {
259
- id: lastConnectionId++,
260
- socket,
261
- channelNames: new Set(),
262
- }
263
- // store connection in cache
264
- connections[connection.id] = connection
265
- if (options.debug) console.log('active connections', Object.keys(connections))
266
-
267
- // emit 'connection' event for app (expressjs extends EventEmitter)
268
- app.emit('connection', connection)
269
-
270
- // send 'connected' event to client
271
- socket.emit('connected', connection.id)
272
-
273
- socket.on('disconnect', () => {
274
- if (options.debug) console.log('Client disconnected', connection.id)
275
- delete connections[connection.id]
276
- })
277
-
278
-
279
- /*
280
- * Handle websocket client request
281
- * Emit in return a 'client-response' message
282
- */
283
- socket.on('client-request', async ({ uid, name, action, args }) => {
284
- if (options.debug) console.log("client-request", uid, name, action, args)
285
- if (name in services) {
286
- const service = services[name]
287
- try {
288
- const serviceMethod = service['__' + action]
289
- if (serviceMethod) {
290
- const context = {
291
- app,
292
- ws: { connection, name, action, args },
293
- }
294
-
295
- try {
296
- const result = await serviceMethod(context, ...args)
297
- socket.emit('client-response', {
298
- uid,
299
- result,
300
- })
301
- // pub/sub: send event on associated channels
302
- publish(service, action, result)
303
- } catch(err) {
304
- console.log('callErr', err)
305
- io.emit('client-response', {
306
- uid,
307
- error: err.toString(),
308
- })
309
- }
310
- } else {
311
- io.emit('client-response', {
312
- uid,
313
- error: `there is no method named '${action}' for service '${name}'`,
314
- })
315
- }
316
- } catch(error) {
317
- io.emit('client-response', {
318
- uid,
319
- error,
320
- })
321
- }
322
- } else {
323
- io.emit('client-response', {
324
- uid,
325
- error: `there is no service named '${name}'`,
326
- })
327
- }
328
- })
329
- })
330
- }
331
-
332
- // publish event on associated channels
333
- async function publish(service, action, result) {
334
- console.log('PUB!')
335
- const publishFunc = service.publishCallback
336
- if (publishFunc) {
337
- const channelNames = await publishFunc(result, app)
338
- if (options.debug) console.log('publish channels', service.name, action, channelNames)
339
- for (const channelName of channelNames) {
340
- if (options.debug) console.log('service-event', service.name, action, channelName)
341
- const connectionList = Object.values(connections).filter(cnx => cnx.channelNames.has(channelName))
342
- for (const connection of connectionList) {
343
- if (options.debug) console.log('emit to', connection.id, service.name, action, result)
344
- connection.socket.emit('service-event', {
345
- name: service.name,
346
- action,
347
- result,
348
- })
349
- }
350
- }
351
- }
352
- }
353
-
354
- function joinChannel(channelName, connection) {
355
- connection.channelNames.add(channelName)
356
- }
357
-
358
- function leaveChannel(channelName, connection) {
359
- connection.channelNames.delete(channelName)
360
- }
361
-
362
- // enhance `app` with objects and methods
363
- Object.assign(app, {
364
- options,
365
- createDatabaseService,
366
- createService,
367
- service,
368
- configure,
369
- addHttpRest,
370
- server,
371
- joinChannel,
372
- leaveChannel,
373
- })
374
- return app
10
+ hashPassword,
11
+ protect,
12
+ setSessionJWT,
375
13
  }
376
-
377
- export default expressX
package/src/server.mjs ADDED
@@ -0,0 +1,378 @@
1
+
2
+ import http from 'http'
3
+ import { Server } from "socket.io"
4
+ import { PrismaClient } from '@prisma/client'
5
+
6
+ /*
7
+ * Enhance `app` express application with Feathers-like services
8
+ */
9
+ export function expressXServer(app, options={}) {
10
+
11
+ if (options.debug === undefined) options.debug = false
12
+ if (options.ws === undefined) options.ws = { ws_prefix: "expressx" }
13
+
14
+ const services = {}
15
+ const connections = {}
16
+
17
+ let lastConnectionId = 1
18
+
19
+ /*
20
+ * create a service `name` based on Prisma table `entity`
21
+ */
22
+ function createDatabaseService(name, prismaOptions = { entity: name }) {
23
+
24
+ let prisma = app.get('prisma')
25
+ if (!prisma) {
26
+ prisma = new PrismaClient()
27
+ app.set('prisma', prisma)
28
+ }
29
+
30
+ // take all prisma methods on `entity` table
31
+ const methods = prisma[prismaOptions.entity]
32
+
33
+ const service = createService(name, methods)
34
+
35
+ service.prisma = prisma
36
+ service.entity = prismaOptions.entity
37
+
38
+ if (options.debug) console.log(`created service '${name}' over table '${prismaOptions.entity}'`)
39
+ return service
40
+ }
41
+
42
+ /*
43
+ * create a service `name` with given `methods`
44
+ */
45
+ function createService(name, methods) {
46
+ const service = { name }
47
+
48
+ for (const methodName in methods) {
49
+ const method = methods[methodName]
50
+ if (! method instanceof Function) continue
51
+
52
+ // `context` is the context of execution (transport type, connection, app)
53
+ // `args` is the list of arguments of the method
54
+ service['__' + methodName] = async (context, ...args) => {
55
+ context.args = args
56
+
57
+ // if a hook or the method throws an error, it will be caught by `socket.on('client-request'` (ws)
58
+ // or by express (http) and the client will get a rejected promise
59
+
60
+ // call 'before' hooks, modifying `context.args`
61
+ const beforeMethodHooks = service?.hooks?.before && service.hooks.before[methodName] || []
62
+ const beforeAllHooks = service?.hooks?.before?.all || []
63
+ for (const hook of [...beforeMethodHooks, ...beforeAllHooks]) {
64
+ context = await hook({ ...context, args })
65
+ }
66
+
67
+ // call method
68
+ const result = await method(...context.args)
69
+ // if (options.debug) console.log('result', result)
70
+
71
+ // call 'after' hooks
72
+ const afterMethodHooks = service?.hooks?.after && service.hooks.after[methodName] || []
73
+ const afterAllHooks = service?.hooks?.after?.all || []
74
+ for (const hook of [...afterMethodHooks, ...afterAllHooks]) {
75
+ context = await hook({ ...context, result })
76
+ }
77
+ return result
78
+ }
79
+
80
+ // hooked version of method: `create`, etc., to be called from backend with no context
81
+ service[methodName] = method
82
+
83
+ // un-hooked version of method: `_create`, etc., to be called from backend with no context
84
+ service['_' + methodName] = method
85
+ }
86
+
87
+ // attach pub/sub publish callback
88
+ service.publish = async (func) => {
89
+ service.publishCallback = func
90
+ },
91
+
92
+ // attach hooks
93
+ service.hooks = (hooks) => {
94
+ service.hooks = hooks
95
+ }
96
+
97
+ // cache service in `services`
98
+ services[name] = service
99
+ return service
100
+ }
101
+
102
+ // `app.service(name)` starts here!
103
+ function service(name) {
104
+ // get service from `services` cache
105
+ if (name in services) return services[name]
106
+ throw Error(`there is no service named '${name}'`)
107
+ }
108
+
109
+ function configure(callback) {
110
+ callback(app)
111
+ }
112
+
113
+ /*
114
+ * add an HTTP REST endpoint at `path`, based on `service`
115
+ */
116
+ async function addHttpRest(path, service) {
117
+ const context = {
118
+ app,
119
+ http: { name: service.name }
120
+ }
121
+
122
+ // introspect table schema
123
+ async function getFieldTypes() {
124
+ const fieldTypes = {}
125
+ if (service.prisma._activeProvider === 'sqlite') {
126
+ const fieldInfo = await service.prisma.$queryRawUnsafe(`
127
+ PRAGMA table_info(${service.entity})
128
+ `)
129
+ fieldInfo.forEach(column => {
130
+ fieldTypes[column.name] = column.type.toLowerCase()
131
+ })
132
+ } else if (service.prisma._activeProvider === 'postgresql') {
133
+ const fieldInfo = await service.prisma.$queryRawUnsafe(`
134
+ SELECT column_name, data_type
135
+ FROM information_schema.columns
136
+ WHERE table_name = '${service.entity}';
137
+ `)
138
+ fieldInfo.forEach(column => {
139
+ fieldTypes[column.column_name] = column.data_type
140
+ })
141
+ }
142
+ return fieldTypes
143
+ }
144
+
145
+
146
+ app.post(path, async (req, res) => {
147
+ if (options.debug) console.log("http request POST", req)
148
+ context.http.req = req
149
+ try {
150
+ const value = await service.__create(context, { data: req.body })
151
+ publish(service, 'create', value)
152
+ res.json(value)
153
+ } catch(err) {
154
+ console.log('callErr', err)
155
+ res.status(500).send(err.toString())
156
+ }
157
+ })
158
+
159
+ app.get(path, async (req, res) => {
160
+ if (options.debug) console.log("http request GET", req)
161
+ context.http.req = req
162
+ const query = { ...req.query }
163
+ try {
164
+ // the values in `req.query` are all strings, but Prisma need proper types
165
+ // we need to introspect column types and do the proper transtyping
166
+ for (const fieldName in query) {
167
+ if (!service.fieldTypes) service.fieldTypes = await getFieldTypes()
168
+ const fieldType = service.fieldTypes[fieldName]
169
+
170
+ if (fieldType === 'integer') {
171
+ query[fieldName] = parseInt(query[fieldName])
172
+ } else if (fieldType === 'numeric') {
173
+ query[fieldName] = parseFloat(query[fieldName])
174
+ } else if (fieldType === 'boolean') {
175
+ query[fieldName] = (query[fieldName] === 't') ? true : false
176
+ } else if (fieldType === 'text' || fieldType === 'character varying') {
177
+ query[fieldName] = query[fieldName]
178
+ } else {
179
+ // ?
180
+ query[fieldName] = query[fieldName]
181
+ }
182
+ }
183
+
184
+ const values = await service.__findMany(context, {
185
+ where: query,
186
+ })
187
+ publish(service, 'findMany', values)
188
+ res.json(values)
189
+ } catch(err) {
190
+ console.log('callErr', err)
191
+ res.status(500).send(err.toString())
192
+ }
193
+ })
194
+
195
+ app.get(`${path}/:id`, async (req, res) => {
196
+ if (options.debug) console.log("http request GET", req)
197
+ context.http.req = req
198
+ try {
199
+ const value = await service.__findUnique(context, {
200
+ where: {
201
+ id: parseInt(req.params.id)
202
+ }
203
+ })
204
+ publish(service, 'findUnique', value)
205
+ res.json(value)
206
+ } catch(err) {
207
+ console.log('callErr', err)
208
+ res.status(500).send(err.toString())
209
+ }
210
+ })
211
+
212
+ app.patch(`${path}/:id`, async (req, res) => {
213
+ if (options.debug) console.log("http request PATCH", req)
214
+ context.http.req = req
215
+ try {
216
+ const value = await service.__update(context, {
217
+ where: {
218
+ id: parseInt(req.params.id),
219
+ },
220
+ data: req.body,
221
+ })
222
+ publish(service, 'update', value)
223
+ res.json(value)
224
+ } catch(err) {
225
+ console.log('callErr', err)
226
+ res.status(500).send(err.toString())
227
+ }
228
+ })
229
+
230
+ app.delete(`${path}/:id`, async (req, res) => {
231
+ if (options.debug) console.log("http request DELETE", req)
232
+ context.http.req = req
233
+ try {
234
+ const value = await service.__delete(context, {
235
+ where: {
236
+ id: parseInt(req.params.id)
237
+ }
238
+ })
239
+ publish(service, 'delete', value)
240
+ res.json(value)
241
+ } catch(err) {
242
+ console.log('callErr', err)
243
+ res.status(500).send(err.toString())
244
+ }
245
+ })
246
+
247
+ if (options.debug) console.log(`added HTTP endpoints for service '${service.name}' at path '${path}'`)
248
+ }
249
+
250
+ /*
251
+ * Create HTTP server
252
+ */
253
+ const server = new http.Server(app)
254
+
255
+ if (options.ws) {
256
+ /*
257
+ * Add websocket transport
258
+ */
259
+ const io = new Server(server)
260
+
261
+ io.on('connection', function(socket) {
262
+ if (options.debug) console.log('Client connected to the WebSocket')
263
+ const connection = {
264
+ id: lastConnectionId++,
265
+ socket,
266
+ channelNames: new Set(),
267
+ }
268
+ // store connection in cache
269
+ connections[connection.id] = connection
270
+ if (options.debug) console.log('active connections', Object.keys(connections))
271
+
272
+ // emit 'connection' event for app (expressjs extends EventEmitter)
273
+ app.emit('connection', connection)
274
+
275
+ // send 'connected' event to client
276
+ socket.emit('connected', connection.id)
277
+
278
+ socket.on('disconnect', () => {
279
+ if (options.debug) console.log('Client disconnected', connection.id)
280
+ delete connections[connection.id]
281
+ })
282
+
283
+
284
+ /*
285
+ * Handle websocket client request
286
+ * Emit in return a 'client-response' message
287
+ */
288
+ socket.on('client-request', async ({ uid, name, action, args }) => {
289
+ if (options.debug) console.log("client-request", uid, name, action, args)
290
+ if (name in services) {
291
+ const service = services[name]
292
+ try {
293
+ const serviceMethod = service['__' + action]
294
+ if (serviceMethod) {
295
+ const context = {
296
+ app,
297
+ ws: { connection, name, action, args },
298
+ }
299
+
300
+ try {
301
+ const result = await serviceMethod(context, ...args)
302
+ socket.emit('client-response', {
303
+ uid,
304
+ result,
305
+ })
306
+ // pub/sub: send event on associated channels
307
+ publish(service, action, result)
308
+ } catch(err) {
309
+ console.log('callErr', err)
310
+ io.emit('client-response', {
311
+ uid,
312
+ error: err.toString(),
313
+ })
314
+ }
315
+ } else {
316
+ io.emit('client-response', {
317
+ uid,
318
+ error: `there is no method named '${action}' for service '${name}'`,
319
+ })
320
+ }
321
+ } catch(error) {
322
+ io.emit('client-response', {
323
+ uid,
324
+ error,
325
+ })
326
+ }
327
+ } else {
328
+ io.emit('client-response', {
329
+ uid,
330
+ error: `there is no service named '${name}'`,
331
+ })
332
+ }
333
+ })
334
+ })
335
+ }
336
+
337
+ // publish event on associated channels
338
+ async function publish(service, action, result) {
339
+ const publishFunc = service.publishCallback
340
+ if (publishFunc) {
341
+ const channelNames = await publishFunc(result, app)
342
+ if (options.debug) console.log('publish channels', service.name, action, channelNames)
343
+ for (const channelName of channelNames) {
344
+ if (options.debug) console.log('service-event', service.name, action, channelName)
345
+ const connectionList = Object.values(connections).filter(cnx => cnx.channelNames.has(channelName))
346
+ for (const connection of connectionList) {
347
+ if (options.debug) console.log('emit to', connection.id, service.name, action, result)
348
+ connection.socket.emit('service-event', {
349
+ name: service.name,
350
+ action,
351
+ result,
352
+ })
353
+ }
354
+ }
355
+ }
356
+ }
357
+
358
+ function joinChannel(channelName, connection) {
359
+ connection.channelNames.add(channelName)
360
+ }
361
+
362
+ function leaveChannel(channelName, connection) {
363
+ connection.channelNames.delete(channelName)
364
+ }
365
+
366
+ // enhance `app` with objects and methods
367
+ return Object.assign(app, {
368
+ options,
369
+ createDatabaseService,
370
+ createService,
371
+ service,
372
+ configure,
373
+ addHttpRest,
374
+ server,
375
+ joinChannel,
376
+ leaveChannel,
377
+ })
378
+ }
@@ -1,25 +1,29 @@
1
1
 
2
- import expressX from '../src/index.mjs'
3
2
  import express from 'express'
4
3
  import bodyParser from 'body-parser'
5
4
  import axios from 'axios'
5
+ import io from 'socket.io-client'
6
6
 
7
- import { expect, assert } from 'chai'
7
+
8
+ import { assert } from 'chai'
9
+
10
+ import { expressXServer, expressXClient } from '../src/index.mjs'
8
11
 
9
12
 
10
13
  // `app` is a regular express application, enhanced with express-x features
11
- const app = expressX(express(), { debug: false })
14
+ const app = expressXServer(express(), { debug: false })
12
15
 
13
16
  app.createDatabaseService('User')
14
17
  app.createDatabaseService('Post')
15
18
 
16
19
 
17
20
 
18
- describe('ExpressX API', () => {
21
+ describe('ExpressX API (no running server)', () => {
19
22
 
20
23
  it("can delete all users", async () => {
21
24
  const res = await app.service('User').deleteMany()
22
- assert(res.count === 1)
25
+ console.log('res delete', res)
26
+ assert(res.count >= 0)
23
27
  })
24
28
 
25
29
  it("can create a user", async () => {
@@ -56,17 +60,19 @@ describe('ExpressX API', () => {
56
60
 
57
61
  describe('HTTP/REST API', () => {
58
62
 
59
- // add body parsers for http requests
60
- app.use(bodyParser.json())
61
- app.use(bodyParser.urlencoded({ extended: false }))
63
+ let chris
62
64
 
63
- // add http/rest endpoints
64
- app.addHttpRest('/api/user', app.service('User'))
65
- app.addHttpRest('/api/post', app.service('Post'))
65
+ before(() => {
66
+ // add body parsers for http requests
67
+ app.use(bodyParser.json())
68
+ app.use(bodyParser.urlencoded({ extended: false }))
66
69
 
67
- app.server.listen(8008, () => console.log(`App listening at http://localhost:8008`))
70
+ // add http/rest endpoints
71
+ app.addHttpRest('/api/user', app.service('User'))
72
+ app.addHttpRest('/api/post', app.service('Post'))
68
73
 
69
- let chris
74
+ app.server.listen(8008, () => console.log(`App listening at http://localhost:8008`))
75
+ })
70
76
 
71
77
  it("can create a user", async () => {
72
78
  const res = await axios.post('http://localhost:8008/api/user', {
@@ -104,7 +110,36 @@ describe('HTTP/REST API', () => {
104
110
  assert(res?.data?.name === "Christophe")
105
111
  })
106
112
 
107
- it("can stop server", () => {
108
- app.server.close()
113
+ after(async () => {
114
+ await app.server.close()
115
+ })
116
+ })
117
+
118
+
119
+ // test compatibility with `express-x-client`
120
+ describe('Client API', () => {
121
+
122
+ let clientApp, socket
123
+
124
+ before(() => {
125
+ app.server.listen(8008, () => console.log(`App listening at http://localhost:8008`))
126
+
127
+ socket = io('http://localhost:8008', { transports: ["websocket"] })
128
+ clientApp = expressXClient(socket)
129
+ })
130
+
131
+ it("can create a user", async () => {
132
+ const user = await clientApp.service('User').create({
133
+ data: {
134
+ name: "chris",
135
+ email: 'chris@mail.fr'
136
+ },
137
+ })
138
+ assert(user.name === 'chris')
139
+ })
140
+
141
+ after(async () => {
142
+ await socket.close()
143
+ await app.server.close()
109
144
  })
110
145
  })