@jcbuisson/express-x 1.1.4 → 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 +2 -1
- package/prisma/dev.db +0 -0
- package/src/client.mjs +95 -0
- package/src/common-hooks.mjs +53 -0
- package/src/index.mjs +9 -377
- package/src/server.mjs +378 -0
- package/test/index.test.js +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jcbuisson/express-x",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.mjs",
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
"@jcbuisson/express-x-client": "^1.0.10",
|
|
22
22
|
"@prisma/client": "^4.10.1",
|
|
23
23
|
"axios": "^1.4.0",
|
|
24
|
+
"bcrypt": "^5.1.0",
|
|
24
25
|
"config": "^3.3.9",
|
|
25
26
|
"esm": "^3.2.25",
|
|
26
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,381 +1,13 @@
|
|
|
1
1
|
|
|
2
|
-
import
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
-
|
|
8
|
-
|
|
9
|
-
function expressX(app, options={}) {
|
|
6
|
+
export {
|
|
7
|
+
expressXServer,
|
|
8
|
+
expressXClient,
|
|
10
9
|
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
Object.assign(app, {
|
|
368
|
-
options,
|
|
369
|
-
createDatabaseService,
|
|
370
|
-
createService,
|
|
371
|
-
service,
|
|
372
|
-
configure,
|
|
373
|
-
addHttpRest,
|
|
374
|
-
server,
|
|
375
|
-
joinChannel,
|
|
376
|
-
leaveChannel,
|
|
377
|
-
})
|
|
378
|
-
return app
|
|
10
|
+
hashPassword,
|
|
11
|
+
protect,
|
|
12
|
+
setSessionJWT,
|
|
379
13
|
}
|
|
380
|
-
|
|
381
|
-
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
|
+
}
|
package/test/index.test.js
CHANGED
|
@@ -3,16 +3,15 @@ import express from 'express'
|
|
|
3
3
|
import bodyParser from 'body-parser'
|
|
4
4
|
import axios from 'axios'
|
|
5
5
|
import io from 'socket.io-client'
|
|
6
|
-
import expressxClient from '@jcbuisson/express-x-client'
|
|
7
6
|
|
|
8
7
|
|
|
9
|
-
import {
|
|
8
|
+
import { assert } from 'chai'
|
|
10
9
|
|
|
11
|
-
import
|
|
10
|
+
import { expressXServer, expressXClient } from '../src/index.mjs'
|
|
12
11
|
|
|
13
12
|
|
|
14
13
|
// `app` is a regular express application, enhanced with express-x features
|
|
15
|
-
const app =
|
|
14
|
+
const app = expressXServer(express(), { debug: false })
|
|
16
15
|
|
|
17
16
|
app.createDatabaseService('User')
|
|
18
17
|
app.createDatabaseService('Post')
|
|
@@ -23,7 +22,8 @@ describe('ExpressX API (no running server)', () => {
|
|
|
23
22
|
|
|
24
23
|
it("can delete all users", async () => {
|
|
25
24
|
const res = await app.service('User').deleteMany()
|
|
26
|
-
|
|
25
|
+
console.log('res delete', res)
|
|
26
|
+
assert(res.count >= 0)
|
|
27
27
|
})
|
|
28
28
|
|
|
29
29
|
it("can create a user", async () => {
|
|
@@ -125,7 +125,7 @@ describe('Client API', () => {
|
|
|
125
125
|
app.server.listen(8008, () => console.log(`App listening at http://localhost:8008`))
|
|
126
126
|
|
|
127
127
|
socket = io('http://localhost:8008', { transports: ["websocket"] })
|
|
128
|
-
clientApp =
|
|
128
|
+
clientApp = expressXClient(socket)
|
|
129
129
|
})
|
|
130
130
|
|
|
131
131
|
it("can create a user", async () => {
|