@jcbuisson/express-x 2.1.20 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +207 -136
- package/package.json +3 -3
- package/src/{index.mjs → server.mjs} +283 -2
package/README.md
CHANGED
|
@@ -1,172 +1,153 @@
|
|
|
1
1
|
|
|
2
|
+
Full doc: [https://expressx.jcbuisson.dev](https://expressx.jcbuisson.dev)
|
|
2
3
|
|
|
3
|
-
# Getting started
|
|
4
|
-
|
|
5
|
-
## Create a project
|
|
6
|
-
|
|
7
|
-
Let's create a new folder for our application:
|
|
8
4
|
|
|
9
|
-
|
|
10
|
-
mkdir expressx-project
|
|
11
|
-
cd expressx-project
|
|
12
|
-
```
|
|
5
|
+
# Getting started
|
|
13
6
|
|
|
14
|
-
|
|
7
|
+
ExpressX is a framework handling both backend and frontend and their communication using websockets.\
|
|
8
|
+
A single websocket is used to channel both data and events between the server and each client.
|
|
15
9
|
|
|
16
10
|
```bash
|
|
17
|
-
|
|
11
|
+
mkdir myproject
|
|
12
|
+
cd myproject
|
|
13
|
+
mkdir backend frontend
|
|
18
14
|
```
|
|
19
15
|
|
|
20
|
-
|
|
16
|
+
## Initialize backend
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
## Install ExpressX
|
|
18
|
+
`@jcbuisson/express-x` is the server-side library
|
|
24
19
|
|
|
25
20
|
```bash
|
|
26
|
-
|
|
21
|
+
cd backend
|
|
22
|
+
npm init es6
|
|
23
|
+
npm install @jcbuisson/express-x cors
|
|
27
24
|
```
|
|
28
25
|
|
|
29
|
-
##
|
|
26
|
+
## Back-end example with a custom service
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
backed in a [Prisma](https://www.prisma.io/) database
|
|
28
|
+
The following example provides a 'math' service with two custom functions 'square' and 'cube':
|
|
33
29
|
|
|
34
30
|
```js
|
|
35
31
|
// app.js
|
|
36
|
-
import {
|
|
32
|
+
import { expressX } from '@jcbuisson/express-x';
|
|
33
|
+
import cors from 'cors'
|
|
37
34
|
|
|
38
|
-
// `app` is a regular express application, enhanced with
|
|
39
|
-
const app = expressX()
|
|
35
|
+
// `app` is a regular express application, enhanced with express-x services and real-time features
|
|
36
|
+
const app = expressX();
|
|
37
|
+
// regular express middleware, to allow access from our front-end
|
|
38
|
+
app.use(cors());
|
|
40
39
|
|
|
41
|
-
//
|
|
42
|
-
|
|
40
|
+
// create a custom 'math' service with 2 methods
|
|
41
|
+
app.createService('math', {
|
|
42
|
+
square: (x) => x*x,
|
|
43
|
+
cube: (x) => x*x*x,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
app.httpServer.listen(8000, () => console.log(`App listening at http://localhost:8000`));
|
|
47
|
+
```
|
|
43
48
|
|
|
44
|
-
|
|
45
|
-
app.createService('User', prisma.User)
|
|
46
|
-
app.createService('Post', prisma.Post)
|
|
49
|
+
A service may have as many parameters as needed, of any types as long as they are serializable.
|
|
47
50
|
|
|
48
|
-
|
|
51
|
+
## Run back-end
|
|
52
|
+
```
|
|
53
|
+
node app.js
|
|
49
54
|
```
|
|
50
55
|
|
|
51
|
-
|
|
56
|
+
## Initialize front-end
|
|
52
57
|
|
|
58
|
+
`@jcbuisson/express-x-client` is the client-side library
|
|
53
59
|
|
|
54
|
-
|
|
60
|
+
```bash
|
|
61
|
+
cd frontend
|
|
62
|
+
npm init es6
|
|
63
|
+
npm install @jcbuisson/express-x-client socket.io-client
|
|
64
|
+
```
|
|
55
65
|
|
|
56
|
-
|
|
66
|
+
## Front-end example
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
```prisma
|
|
60
|
-
generator client {
|
|
61
|
-
provider = "prisma-client-js"
|
|
62
|
-
}
|
|
68
|
+
index.html
|
|
63
69
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
70
|
+
```html
|
|
71
|
+
<html>
|
|
72
|
+
<input id="value-id" type="number" placeholder="Enter value"><br>
|
|
73
|
+
<button id="square-id" class="btn">Square</button>
|
|
74
|
+
<button id="cube-id" class="btn">Cube</button>
|
|
75
|
+
<p id="result-id"></p>
|
|
76
|
+
</html>
|
|
69
77
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
author User @relation(fields: [authorId], references: [id], onDelete: Cascade)
|
|
74
|
-
authorId Int
|
|
75
|
-
}
|
|
78
|
+
<script type="module">
|
|
79
|
+
import io from 'socket.io-client';
|
|
80
|
+
import expressXClient from '@jcbuisson/express-x-client';
|
|
76
81
|
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
```
|
|
82
|
+
const socket = io('http://localhost:8000', {
|
|
83
|
+
transports: ["websocket"],
|
|
84
|
+
});
|
|
82
85
|
|
|
83
|
-
|
|
84
|
-
```bash
|
|
85
|
-
npx prisma db push
|
|
86
|
-
```
|
|
86
|
+
const app = expressXClient(socket);
|
|
87
87
|
|
|
88
|
-
|
|
88
|
+
const valueInput = document.getElementById('value-id');
|
|
89
|
+
const squareBtn = document.getElementById('square-id');
|
|
90
|
+
const cubeBtn = document.getElementById('cube-id');
|
|
91
|
+
const resultParagraph = document.getElementById('result-id');
|
|
89
92
|
|
|
93
|
+
squareBtn.addEventListener('click', async (ev) => {
|
|
94
|
+
const result = await app.service('math').square(valueInput.value);
|
|
95
|
+
resultParagraph.innerHTML = result;
|
|
96
|
+
})
|
|
90
97
|
|
|
91
|
-
|
|
98
|
+
cubeBtn.addEventListener('click', async (ev) => {
|
|
99
|
+
const result = await app.service('math').cube(valueInput.value);
|
|
100
|
+
resultParagraph.innerHTML = result;
|
|
101
|
+
})
|
|
102
|
+
</script>
|
|
103
|
+
```
|
|
92
104
|
|
|
93
|
-
|
|
94
|
-
|
|
105
|
+
## Run front-end
|
|
106
|
+
```
|
|
107
|
+
npx vite
|
|
95
108
|
```
|
|
96
109
|
|
|
110
|
+
Calling a service method from the frontend is as easy as `await app.service('math').square(value)`
|
|
97
111
|
|
|
98
|
-
## Use it with a websocket client
|
|
99
112
|
|
|
100
|
-
|
|
113
|
+
## Add a CRUD API over a relational database
|
|
101
114
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
import io from 'socket.io-client'
|
|
105
|
-
import { expressXClient } from '@jcbuisson/express-x'
|
|
115
|
+
With a few more lines to the backend, we can add a complete CRUD API on a `User` resource
|
|
116
|
+
backed in a [Prisma](https://www.prisma.io/) database
|
|
106
117
|
|
|
107
|
-
|
|
118
|
+
```js
|
|
119
|
+
// app.js
|
|
120
|
+
import { expressX } from '@jcbuisson/express-x'
|
|
121
|
+
import { PrismaClient } from '@prisma/client'
|
|
108
122
|
|
|
109
|
-
const
|
|
123
|
+
const prisma = new PrismaClient()
|
|
110
124
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
name: "Joe",
|
|
114
|
-
})
|
|
115
|
-
await app.service('Post').create({
|
|
116
|
-
authorId: user.id,
|
|
117
|
-
text: "Post#1"
|
|
118
|
-
})
|
|
119
|
-
await app.service('Post').create({
|
|
120
|
-
authorId: user.id,
|
|
121
|
-
text: "Post#2"
|
|
122
|
-
})
|
|
123
|
-
const joe = await app.service('User').findUnique({
|
|
124
|
-
where: {
|
|
125
|
-
id: user.id,
|
|
126
|
-
},
|
|
127
|
-
include: {
|
|
128
|
-
posts: true,
|
|
129
|
-
},
|
|
130
|
-
})
|
|
131
|
-
console.log('joe', joe)
|
|
132
|
-
process.exit(0)
|
|
133
|
-
}
|
|
134
|
-
main()
|
|
135
|
-
```
|
|
125
|
+
// `app` is a regular express application, enhanced with express-x services and real-time features
|
|
126
|
+
const app = expressX()
|
|
136
127
|
|
|
137
|
-
|
|
128
|
+
...
|
|
138
129
|
|
|
139
|
-
|
|
140
|
-
|
|
130
|
+
// Create a CRUD database 'user' service with the Prisma methods: `create`, 'findUnique', etc.
|
|
131
|
+
// Conveniently, `Prisma.User` is the map of all CRUD methods on User table
|
|
132
|
+
app.createService('user', Prisma.User)
|
|
141
133
|
|
|
142
|
-
|
|
143
|
-
```bash
|
|
144
|
-
node client.js
|
|
134
|
+
app.httpServer.listen(8000, () => console.log(`App listening at http://localhost:8000`))
|
|
145
135
|
```
|
|
146
136
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
posts: [
|
|
153
|
-
{ id: 12, text: 'Post#1', authorId: 11 },
|
|
154
|
-
{ id: 13, text: 'Post#2', authorId: 11 }
|
|
155
|
-
]
|
|
156
|
-
}
|
|
137
|
+
Of course the database must be created and setup first.
|
|
138
|
+
|
|
139
|
+
Now the full API of Prisma is accessible from the client-side, for example:
|
|
140
|
+
```js
|
|
141
|
+
const user = await app.service('user').findUnique({ where: { id: userId }})
|
|
157
142
|
```
|
|
158
143
|
|
|
159
|
-
|
|
144
|
+
By default, errors on the server-side are serialized and re-emitted on the client-side, so you can catch them if needed.
|
|
160
145
|
|
|
161
|
-
::: info
|
|
162
|
-
We could have removed from `app.js` all lines related to HTTP, since we are only using the websocket transport.
|
|
163
|
-
:::
|
|
164
146
|
|
|
165
147
|
|
|
166
148
|
## Real-time applications
|
|
167
149
|
|
|
168
|
-
When
|
|
169
|
-
two twings happen on method completion:
|
|
150
|
+
When a connected client calls a service method, two things happen on method completion:
|
|
170
151
|
|
|
171
152
|
- the resulting value is sent to the client
|
|
172
153
|
- an event is emitted, and sent to connected clients we'll call subscribers. The calling client may or not be one of those subscribers.
|
|
@@ -177,50 +158,140 @@ For example in a medical application, whenever a patients's record is modified,
|
|
|
177
158
|
to channels in order to receive those events. ExpressX provides functions to configure which events are published to which channels.
|
|
178
159
|
A channel is represented by a name and you can create and use as many channels as you need.
|
|
179
160
|
|
|
180
|
-
|
|
161
|
+
### Example: shared bilboard
|
|
162
|
+
|
|
163
|
+
In the following example of a shared bilboard, every time a client connects to the server, it joins (= is subscribed to) the 'all' channel.
|
|
164
|
+
And whenever an event is emited by the `bilboard` service, this event is published on this channel,
|
|
165
|
+
and then broacasted to all connected clients, leading to real-time updates.
|
|
166
|
+
|
|
167
|
+
```js
|
|
168
|
+
// app.js
|
|
169
|
+
// Run it with: `node app.js`
|
|
170
|
+
import { expressX } from '@jcbuisson/express-x';
|
|
171
|
+
import cors from 'cors'
|
|
172
|
+
|
|
173
|
+
// `app` is a regular express application, enhanced with express-x services and real-time features
|
|
174
|
+
const app = expressX();
|
|
175
|
+
// express middleware which prevents cors issues with dev front-end
|
|
176
|
+
app.use(cors());
|
|
177
|
+
|
|
178
|
+
let bilboard = '';
|
|
179
|
+
|
|
180
|
+
// create a custom 'bilboard' service with 1 method
|
|
181
|
+
app.createService('bilboard', {
|
|
182
|
+
sendMessage: (message) => {
|
|
183
|
+
bilboard = message;
|
|
184
|
+
return message;
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// publish
|
|
189
|
+
app.service('bilboard').publish(async (context) => {
|
|
190
|
+
return ['all']
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// subscribe
|
|
194
|
+
app.on('connection', (socket) => {
|
|
195
|
+
app.joinChannel('all', socket)
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
app.httpServer.listen(8000, () => console.log(`App listening at http://localhost:8000`));
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
```html
|
|
202
|
+
<!-- index.html; run it with: npx vite -->
|
|
203
|
+
<html>
|
|
204
|
+
<input id="message-id" type="text" placeholder="Enter message"><br>
|
|
205
|
+
<button id="send-id" class="btn">Send</button>
|
|
206
|
+
|
|
207
|
+
<div id="bilboard-id"></div>
|
|
208
|
+
</html>
|
|
209
|
+
|
|
210
|
+
<script type="module">
|
|
211
|
+
import io from 'socket.io-client';
|
|
212
|
+
import expressXClient from '@jcbuisson/express-x-client';
|
|
213
|
+
|
|
214
|
+
const socket = io('http://localhost:8000', {
|
|
215
|
+
transports: ["websocket"],
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const app = expressXClient(socket);
|
|
219
|
+
|
|
220
|
+
const messageInput = document.getElementById('message-id');
|
|
221
|
+
const sendBtn = document.getElementById('send-id');
|
|
222
|
+
const bilboardDiv = document.getElementById('bilboard-id');
|
|
223
|
+
|
|
224
|
+
sendBtn.addEventListener('click', async (ev) => {
|
|
225
|
+
await app.service('bilboard').sendMessage(messageInput.value);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
app.service('bilboard').on('sendMessage', (message) => {
|
|
229
|
+
bilboardDiv.innerHTML = bilboardDiv.innerHTML + '<br>' + message;
|
|
230
|
+
})
|
|
231
|
+
</script>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
The listener is triggered whenever the client receives from the server a `sendMessage` event from the `bilboard` service.
|
|
235
|
+
This event is sent to all subscribers after the execution of `app.service('bilboard').sendMessage()` on the server.
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
### Example : CRUD database
|
|
239
|
+
|
|
240
|
+
In this other example, every time a client connects to the server, it joins (= is subscribed to) the 'anonymous' channel.
|
|
181
241
|
And whenever an event is emited by the `post` or `user` service, this event is published on this channel,
|
|
182
242
|
and then broacasted to all connected clients, leading to real-time updates.
|
|
183
243
|
|
|
184
244
|
```js
|
|
185
245
|
// app.js
|
|
186
|
-
import {
|
|
187
|
-
import { PrismaClient } from '@prisma/client'
|
|
246
|
+
import { expressX } from '@jcbuisson/express-x';
|
|
247
|
+
import { PrismaClient } from '@prisma/client';
|
|
188
248
|
|
|
189
|
-
|
|
190
|
-
const app = expressX()
|
|
249
|
+
const prisma = new PrismaClient();
|
|
191
250
|
|
|
192
|
-
//
|
|
193
|
-
const
|
|
251
|
+
// `app` is a regular express application, enhanced with express-x services and real-time features
|
|
252
|
+
const app = expressX(prisma);
|
|
194
253
|
|
|
195
|
-
// create two CRUD database services
|
|
196
|
-
app.createService('
|
|
197
|
-
app.createService('
|
|
254
|
+
// create two CRUD database services with the Prisma methods: `create`, 'update', etc
|
|
255
|
+
app.createService('user', prisma.User);
|
|
256
|
+
app.createService('post', prisma.Post);
|
|
198
257
|
|
|
199
258
|
// publish
|
|
200
|
-
app.service('
|
|
259
|
+
app.service('user').publish(async (context) => {
|
|
201
260
|
return ['anonymous']
|
|
202
|
-
})
|
|
203
|
-
app.service('
|
|
261
|
+
});
|
|
262
|
+
app.service('post').publish(async (context) => {
|
|
204
263
|
return ['anonymous']
|
|
205
|
-
})
|
|
264
|
+
});
|
|
206
265
|
|
|
207
266
|
// subscribe
|
|
208
|
-
app.
|
|
209
|
-
console.log('connection', socket.id)
|
|
267
|
+
app.addConnectListener((socket) => {
|
|
210
268
|
app.joinChannel('anonymous', socket)
|
|
211
|
-
})
|
|
269
|
+
});
|
|
212
270
|
|
|
213
|
-
app.
|
|
271
|
+
app.httpServer.listen(8000, () => console.log(`App listening at http://localhost:8000`));
|
|
214
272
|
```
|
|
215
273
|
|
|
216
274
|
Here is how a client may listen to channel events:
|
|
217
275
|
|
|
218
276
|
```js
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
277
|
+
import io from 'socket.io-client'
|
|
278
|
+
import expressXClient from '@jcbuisson/express-x-client'
|
|
279
|
+
|
|
280
|
+
const socket = io('http://localhost:8000', { transports: ["websocket"] })
|
|
281
|
+
|
|
282
|
+
const app = expressXClient(socket)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
app.service('user').on('create', (user) => {
|
|
286
|
+
console.log('User created', user)
|
|
287
|
+
// update client cache
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
app.service('post').on('create', (post) => {
|
|
291
|
+
console.log('Post created', post)
|
|
292
|
+
// update client cache
|
|
222
293
|
})
|
|
223
294
|
```
|
|
224
295
|
|
|
225
296
|
The listener is triggered whenever the client receives from the server a `create` event from the service `post`.
|
|
226
|
-
This event
|
|
297
|
+
This event is sent to all subscribers after the execution of `app.service('post').create()` on the server.
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jcbuisson/express-x",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.1",
|
|
4
4
|
"description": "",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "src/
|
|
6
|
+
"main": "src/server.mjs",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "git@github.com
|
|
9
|
+
"url": "git+ssh://git@github.com/jcbuisson/express-x.git"
|
|
10
10
|
},
|
|
11
11
|
"author": "Jean-Christophe Buisson <buisson@enseeiht.fr> (jcbuisson.dev)",
|
|
12
12
|
"license": "MIT",
|
|
@@ -5,9 +5,51 @@ import bcrypt from 'bcryptjs'
|
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
|
|
8
|
+
|
|
8
9
|
// UTILISER L'ACKNOWLEDGEMENT : https://socket.io/docs/v4/#acknowledgements
|
|
9
10
|
|
|
10
11
|
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
////////////////////////// UTILITIES //////////////////////////
|
|
15
|
+
|
|
16
|
+
function truncateString(str, maxLength = 300, ellipsis = '...') {
|
|
17
|
+
// Check if the string already fits
|
|
18
|
+
if (str.length <= maxLength) return str;
|
|
19
|
+
// Calculate the cut-off point, accounting for the ellipsis length
|
|
20
|
+
const cutLength = maxLength - ellipsis.length;
|
|
21
|
+
// Ensure the string is long enough to be cut
|
|
22
|
+
if (cutLength < 0) return str.substring(0, maxLength); // Just cut it off if ellipsis doesn't fit
|
|
23
|
+
// Truncate the string and add the ellipsis
|
|
24
|
+
return str.substring(0, cutLength) + ellipsis;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Mutex {
|
|
29
|
+
constructor() {
|
|
30
|
+
this.locked = false;
|
|
31
|
+
this.queue = [];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async acquire() {
|
|
35
|
+
if (this.locked) {
|
|
36
|
+
return new Promise(resolve => this.queue.push(resolve));
|
|
37
|
+
}
|
|
38
|
+
this.locked = true;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
release() {
|
|
42
|
+
if (this.queue.length > 0) {
|
|
43
|
+
const next = this.queue.shift();
|
|
44
|
+
next();
|
|
45
|
+
} else {
|
|
46
|
+
this.locked = false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
////////////////////////// EXPRESSX //////////////////////////
|
|
52
|
+
|
|
11
53
|
export function expressX(config) {
|
|
12
54
|
|
|
13
55
|
const services = {}
|
|
@@ -254,8 +296,8 @@ export function expressX(config) {
|
|
|
254
296
|
return service
|
|
255
297
|
}
|
|
256
298
|
|
|
257
|
-
function configure(callback) {
|
|
258
|
-
callback(app)
|
|
299
|
+
function configure(callback, ...args) {
|
|
300
|
+
callback(app, ...args)
|
|
259
301
|
}
|
|
260
302
|
|
|
261
303
|
// `app.service(name)` starts here!
|
|
@@ -336,3 +378,242 @@ export const protect = (field) => (context) => {
|
|
|
336
378
|
}
|
|
337
379
|
}
|
|
338
380
|
}
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
////////////////////////// RELOAD PLUGIN //////////////////////////
|
|
384
|
+
|
|
385
|
+
export const roomCache = {}
|
|
386
|
+
export const dataCache = {}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
export async function reloadPlugin(app) {
|
|
390
|
+
|
|
391
|
+
const io = app.get('io')
|
|
392
|
+
|
|
393
|
+
app.addDisconnectingListener((socket, reason) => {
|
|
394
|
+
console.log('onSocketDisconnecting', socket.id, reason)
|
|
395
|
+
// save socket data & rooms in caches
|
|
396
|
+
const alreadySavedData = dataCache[socket.id]
|
|
397
|
+
const alreadySavedRooms = roomCache[socket.id]
|
|
398
|
+
|
|
399
|
+
dataCache[socket.id] = Object.assign({}, socket.data)
|
|
400
|
+
roomCache[socket.id] = new Set(socket.rooms)
|
|
401
|
+
|
|
402
|
+
if (alreadySavedData) dataCache[socket.id] = Object.assign(dataCache[socket.id], alreadySavedData)
|
|
403
|
+
if (alreadySavedRooms) roomCache[socket.id].add(alreadySavedRooms)
|
|
404
|
+
})
|
|
405
|
+
|
|
406
|
+
app.addConnectListener((socket) => {
|
|
407
|
+
console.log('onSocketConnect', socket.id)
|
|
408
|
+
|
|
409
|
+
// when client ask for transfer from fromSocketId to toSocketId
|
|
410
|
+
socket.on('cnx-transfer', async (fromSocketId, toSocketId) => {
|
|
411
|
+
app.log('verbose', `cnx-transfer from ${fromSocketId} to ${toSocketId}`)
|
|
412
|
+
console.log('dataCache', dataCache)
|
|
413
|
+
console.log('roomCache', roomCache)
|
|
414
|
+
// copy connection room & data from 'fromSocketId' to 'toSocketId'
|
|
415
|
+
const toSocket = io.sockets.sockets.get(toSocketId)
|
|
416
|
+
// data & rooms of fromSocketId are taken from dataCache and roomCache, since socket no longer exists
|
|
417
|
+
const fromSocketRooms = roomCache[fromSocketId]
|
|
418
|
+
if (toSocket && fromSocketRooms) {
|
|
419
|
+
// copy rooms
|
|
420
|
+
for (const room of fromSocketRooms) {
|
|
421
|
+
if (room === fromSocketId) continue // do not include room associated to socket#id
|
|
422
|
+
toSocket.join(room)
|
|
423
|
+
}
|
|
424
|
+
// copy data
|
|
425
|
+
toSocket.data = dataCache[fromSocketId]
|
|
426
|
+
// console.log('cnx-transfer data', toSocket.data)
|
|
427
|
+
// console.log('cnx-transfer rooms', toSocket.rooms)
|
|
428
|
+
// remove 'from' cache data
|
|
429
|
+
delete roomCache[fromSocketId]
|
|
430
|
+
delete dataCache[fromSocketId]
|
|
431
|
+
// send acknowlegment to toSocket
|
|
432
|
+
toSocket.emit('cnx-transfer-ack', fromSocketId, toSocketId)
|
|
433
|
+
} else {
|
|
434
|
+
console.log(`*** CNX TRANSFER ERROR, ${fromSocketId} -> ${toSocketId}`)
|
|
435
|
+
toSocket.emit('cnx-transfer-error', fromSocketId, toSocketId)
|
|
436
|
+
}
|
|
437
|
+
})
|
|
438
|
+
})
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
////////////////////////// OFFLINE PLUGIN //////////////////////////
|
|
443
|
+
|
|
444
|
+
const mutex = new Mutex()
|
|
445
|
+
|
|
446
|
+
export function offlinePlugin(app, modelNames) {
|
|
447
|
+
|
|
448
|
+
const prisma = app.get('prisma');
|
|
449
|
+
|
|
450
|
+
if (!prisma.metadata) {
|
|
451
|
+
app.log('error', "A model named 'metadata' is expected in the prisma database - see https://expressx.jcbuisson.dev/server-api.html#offline")
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
// add a database service for each model
|
|
456
|
+
for (const modelName of modelNames) {
|
|
457
|
+
const model = prisma[modelName];
|
|
458
|
+
|
|
459
|
+
app.createService(modelName, {
|
|
460
|
+
|
|
461
|
+
findUnique: model.findUnique,
|
|
462
|
+
findMany: model.findMany,
|
|
463
|
+
|
|
464
|
+
createWithMeta: async (uid, data, created_at) => {
|
|
465
|
+
const [value, meta] = await prisma.$transaction([
|
|
466
|
+
model.create({ data: { uid, ...data } }),
|
|
467
|
+
prisma.metadata.create({ data: { uid, created_at } })
|
|
468
|
+
])
|
|
469
|
+
return [value, meta]
|
|
470
|
+
},
|
|
471
|
+
|
|
472
|
+
updateWithMeta: async (uid, data, updated_at) => {
|
|
473
|
+
const [value, meta] = await prisma.$transaction([
|
|
474
|
+
model.update({ where: { uid }, data }),
|
|
475
|
+
prisma.metadata.update({ where: { uid }, data: { updated_at } })
|
|
476
|
+
])
|
|
477
|
+
return [value, meta]
|
|
478
|
+
},
|
|
479
|
+
|
|
480
|
+
deleteWithMeta: async (uid, deleted_at) => {
|
|
481
|
+
const [value, meta] = await prisma.$transaction([
|
|
482
|
+
model.delete({ where: { uid } }),
|
|
483
|
+
prisma.metadata.update({ where: { uid }, data: { deleted_at } })
|
|
484
|
+
])
|
|
485
|
+
return [value, meta]
|
|
486
|
+
},
|
|
487
|
+
})
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// add a synchronization service
|
|
491
|
+
app.createService('sync', {
|
|
492
|
+
|
|
493
|
+
// AMÉLIORER : ne pas avoir une exclusion mutuelle globale, mais seulement par model/where
|
|
494
|
+
go: async (modelName, where, cutoffDate, clientMetadataDict) => {
|
|
495
|
+
await mutex.acquire()
|
|
496
|
+
try {
|
|
497
|
+
console.log('>>>>> SYNC', modelName, where, cutoffDate)
|
|
498
|
+
const databaseService = app.service(modelName)
|
|
499
|
+
const prisma = app.get('prisma')
|
|
500
|
+
|
|
501
|
+
// STEP 1: get existing database `where` values
|
|
502
|
+
const databaseValues = await databaseService.findMany({ where })
|
|
503
|
+
|
|
504
|
+
const databaseValuesDict = databaseValues.reduce((accu, value) => {
|
|
505
|
+
accu[value.uid] = value
|
|
506
|
+
return accu
|
|
507
|
+
}, {})
|
|
508
|
+
// console.log('clientMetadataDict', clientMetadataDict)
|
|
509
|
+
// console.log('databaseValuesDict', databaseValuesDict)
|
|
510
|
+
|
|
511
|
+
// STEP 2: compute intersections between client and database uids
|
|
512
|
+
const onlyDatabaseIds = new Set()
|
|
513
|
+
const onlyClientIds = new Set()
|
|
514
|
+
const databaseAndClientIds = new Set()
|
|
515
|
+
|
|
516
|
+
for (const uid in databaseValuesDict) {
|
|
517
|
+
if (uid in clientMetadataDict) {
|
|
518
|
+
databaseAndClientIds.add(uid)
|
|
519
|
+
} else {
|
|
520
|
+
onlyDatabaseIds.add(uid)
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
for (const uid in clientMetadataDict) {
|
|
525
|
+
if (uid in databaseValuesDict) {
|
|
526
|
+
databaseAndClientIds.add(uid)
|
|
527
|
+
} else {
|
|
528
|
+
onlyClientIds.add(uid)
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
// console.log('onlyDatabaseIds', onlyDatabaseIds)
|
|
532
|
+
// console.log('onlyClientIds', onlyClientIds)
|
|
533
|
+
// console.log('databaseAndClientIds', databaseAndClientIds)
|
|
534
|
+
|
|
535
|
+
// STEP 3: build add/update/delete sets
|
|
536
|
+
const addDatabase = []
|
|
537
|
+
const updateDatabase = []
|
|
538
|
+
const deleteDatabase = []
|
|
539
|
+
|
|
540
|
+
const addClient = []
|
|
541
|
+
const updateClient = []
|
|
542
|
+
const deleteClient = []
|
|
543
|
+
|
|
544
|
+
for (const uid of onlyDatabaseIds) {
|
|
545
|
+
const databaseValue = databaseValuesDict[uid]
|
|
546
|
+
let databaseMetaData = await prisma.metadata.findUnique({ where: { uid }})
|
|
547
|
+
if (!databaseMetaData) {
|
|
548
|
+
console.log('no metadata - should not happen', modelName, where, uid)
|
|
549
|
+
databaseMetaData = { uid, created_at: new Date() }
|
|
550
|
+
}
|
|
551
|
+
addClient.push([databaseValue, databaseMetaData])
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
for (const uid of onlyClientIds) {
|
|
555
|
+
const clientMetaData = clientMetadataDict[uid]
|
|
556
|
+
if (clientMetaData.deleted_at) {
|
|
557
|
+
deleteClient.push([uid, clientMetaData.deleted_at])
|
|
558
|
+
} else if (new Date(clientMetaData.created_at) > cutoffDate) {
|
|
559
|
+
addDatabase.push(clientMetaData)
|
|
560
|
+
} else {
|
|
561
|
+
// ???
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
for (const uid of databaseAndClientIds) {
|
|
566
|
+
const databaseValue = databaseValuesDict[uid]
|
|
567
|
+
const clientMetaData = clientMetadataDict[uid]
|
|
568
|
+
|| { uid, created_at: new Date() } // should not happen
|
|
569
|
+
if (clientMetaData.deleted_at) {
|
|
570
|
+
deleteDatabase.push(uid)
|
|
571
|
+
deleteClient.push([uid, clientMetaData.deleted_at])
|
|
572
|
+
} else {
|
|
573
|
+
const databaseMetaData = await prisma.metadata.findUnique({ where: { uid }})
|
|
574
|
+
|| { uid, created_at: new Date() } // should not happen
|
|
575
|
+
const clientUpdatedAt = new Date(clientMetaData.updated_at || clientMetaData.created_at)
|
|
576
|
+
const databaseUpdatedAt = new Date(databaseMetaData.updated_at || databaseMetaData.created_at)
|
|
577
|
+
const dateDifference = clientUpdatedAt - databaseUpdatedAt
|
|
578
|
+
// console.log('databaseMetaData', databaseMetaData, 'clientMetaData', clientMetaData, 'dateDifference', dateDifference)
|
|
579
|
+
if (dateDifference > 0) {
|
|
580
|
+
updateDatabase.push(clientMetaData)
|
|
581
|
+
} else if (dateDifference < 0) {
|
|
582
|
+
updateClient.push(databaseValue)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
console.log('addDatabase', truncateString(JSON.stringify(addDatabase)))
|
|
587
|
+
console.log('deleteDatabase', truncateString(JSON.stringify(deleteDatabase)))
|
|
588
|
+
console.log('updateDatabase', truncateString(JSON.stringify(updateDatabase)))
|
|
589
|
+
|
|
590
|
+
console.log('addClient', truncateString(JSON.stringify(addClient)))
|
|
591
|
+
console.log('deleteClient', truncateString(JSON.stringify(deleteClient)))
|
|
592
|
+
console.log('updateClient', truncateString(JSON.stringify(updateClient)))
|
|
593
|
+
|
|
594
|
+
// STEP4: execute database deletions
|
|
595
|
+
for (const uid of deleteDatabase) {
|
|
596
|
+
const clientMetaData = clientMetadataDict[uid]
|
|
597
|
+
// console.log('---delete', uid, clientMetaData)
|
|
598
|
+
await databaseService.deleteWithMeta(uid, clientMetaData.deleted_at)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// STEP5: return to client the changes to perform on its cache, and create/update to perform on database with full data
|
|
602
|
+
// database creations & updates are done later by the client with complete data (this function only has client values's meta-data)
|
|
603
|
+
return {
|
|
604
|
+
toAdd: addClient,
|
|
605
|
+
toUpdate: updateClient,
|
|
606
|
+
toDelete: deleteClient,
|
|
607
|
+
|
|
608
|
+
addDatabase,
|
|
609
|
+
updateDatabase,
|
|
610
|
+
}
|
|
611
|
+
} catch(err) {
|
|
612
|
+
console.log('*** err sync', err)
|
|
613
|
+
} finally {
|
|
614
|
+
mutex.release()
|
|
615
|
+
}
|
|
616
|
+
},
|
|
617
|
+
})
|
|
618
|
+
|
|
619
|
+
}
|