@jcbuisson/express-x-plugins 4.0.11 → 4.0.13

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-plugins",
3
- "version": "4.0.11",
3
+ "version": "4.0.13",
4
4
  "description": "Plugins for express-x",
5
5
  "type": "module",
6
6
  "main": "./src/electric-server-plugin.mjs",
@@ -19,11 +19,13 @@ export async function reloadPlugin(app, options = {}) {
19
19
  const data = Object.create(null)
20
20
  const transferTokens = Object.create(null)
21
21
  const transferExpiryTimers = Object.create(null)
22
+ const consumedSocketIds = new Set()
22
23
  roomCache.set(app, rooms)
23
24
  dataCache.set(app, data)
24
25
 
25
26
  app.addDisconnectingListener((socket, reason) => {
26
27
  console.log('onSocketDisconnecting', socket.id, reason)
28
+ if (consumedSocketIds.delete(socket.id)) return
27
29
  // save socket data & rooms in caches
28
30
  const alreadySavedData = data[socket.id]
29
31
  const alreadySavedRooms = rooms[socket.id]
@@ -48,13 +50,14 @@ export async function reloadPlugin(app, options = {}) {
48
50
  console.log('onSocketConnect', socket.id)
49
51
  const transferToken = randomUUID()
50
52
  socket.data.__cnxTransferToken = transferToken
53
+ transferTokens[socket.id] = transferToken
51
54
  socket.emit('cnx-transfer-token', transferToken)
52
55
 
53
56
  // when client ask for transfer from fromSocketId to toSocketId
54
57
  socket.on('cnx-transfer', async (fromSocketId, toSocketId, claimedToken) => {
55
58
  app.log('verbose', `cnx-transfer from ${fromSocketId} to ${toSocketId}`)
56
59
  // A socket may only claim its own ID as the destination — prevent session hijacking
57
- if (toSocketId !== socket.id || typeof claimedToken !== 'string'
60
+ if (toSocketId !== socket.id || fromSocketId === socket.id || typeof claimedToken !== 'string'
58
61
  || transferTokens[fromSocketId] !== claimedToken) {
59
62
  app.log('verbose', `cnx-transfer rejected: toSocketId ${toSocketId} !== socket.id ${socket.id}`)
60
63
  socket.emit('cnx-transfer-error', fromSocketId, toSocketId)
@@ -62,17 +65,20 @@ export async function reloadPlugin(app, options = {}) {
62
65
  }
63
66
  // copy connection room & data from 'fromSocketId' to 'toSocketId'
64
67
  const toSocket = io.sockets.sockets.get(toSocketId)
65
- // data & rooms of fromSocketId are taken from dataCache and roomCache, since socket no longer exists
66
- const fromSocketRooms = rooms[fromSocketId]
68
+ const fromSocket = io.sockets.sockets.get(fromSocketId)
69
+ // Usually the old socket has disconnected and its state is cached. During a
70
+ // fast reload it may still be live, so snapshot it directly instead.
71
+ const fromSocketRooms = rooms[fromSocketId] ?? fromSocket?.rooms
72
+ const fromSocketData = data[fromSocketId] ?? fromSocket?.data
67
73
  if (toSocket && fromSocketRooms) {
68
74
  // copy rooms
69
75
  for (const room of fromSocketRooms) {
70
76
  if (room === fromSocketId) continue // do not include room associated to socket#id
71
- toSocket.join(room)
77
+ await toSocket.join(room)
72
78
  }
73
79
  // copy data
74
80
  toSocket.data = {
75
- ...data[fromSocketId],
81
+ ...fromSocketData,
76
82
  ...toSocket.data,
77
83
  __cnxTransferToken: transferToken,
78
84
  }
@@ -84,6 +90,10 @@ export async function reloadPlugin(app, options = {}) {
84
90
  delete transferTokens[fromSocketId]
85
91
  clearTimeout(transferExpiryTimers[fromSocketId])
86
92
  delete transferExpiryTimers[fromSocketId]
93
+ if (fromSocket) {
94
+ consumedSocketIds.add(fromSocketId)
95
+ fromSocket.disconnect(true)
96
+ }
87
97
  // send acknowlegment to toSocket
88
98
  toSocket.emit('cnx-transfer-ack', fromSocketId, toSocketId)
89
99
  } else {
@@ -0,0 +1,71 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+
4
+ import { reloadPlugin } from '../src/reload-server-plugin.mjs'
5
+
6
+ class FakeSocket {
7
+ constructor(id, sockets, disconnectingListener) {
8
+ this.id = id
9
+ this.data = {}
10
+ this.rooms = new Set([id])
11
+ this.handlers = new Map()
12
+ this.emitted = []
13
+ this.sockets = sockets
14
+ this.disconnectingListener = disconnectingListener
15
+ this.joinDelay = Promise.resolve()
16
+ }
17
+
18
+ on(event, handler) { this.handlers.set(event, handler) }
19
+ emit(event, ...args) { this.emitted.push([event, ...args]) }
20
+ async join(room) {
21
+ await this.joinDelay
22
+ this.rooms.add(room)
23
+ }
24
+ disconnect() {
25
+ this.disconnectingListener(this, 'server namespace disconnect')
26
+ this.sockets.delete(this.id)
27
+ }
28
+ }
29
+
30
+ test('transfers state when the replacement connects before the old socket disconnects', async () => {
31
+ const sockets = new Map()
32
+ let connectListener
33
+ let disconnectingListener
34
+ const app = {
35
+ get(key) {
36
+ if (key === 'io') return { sockets: { sockets } }
37
+ if (key === 'config') return {}
38
+ },
39
+ addConnectListener(listener) { connectListener = listener },
40
+ addDisconnectingListener(listener) { disconnectingListener = listener },
41
+ log() {},
42
+ }
43
+ await reloadPlugin(app, { authorizeRoomRestore: async () => true })
44
+
45
+ const oldSocket = new FakeSocket('old', sockets, (...args) => disconnectingListener(...args))
46
+ oldSocket.data.user = 'Ada'
47
+ oldSocket.rooms.add('project:1')
48
+ sockets.set(oldSocket.id, oldSocket)
49
+ connectListener(oldSocket)
50
+ const transferToken = oldSocket.emitted.find(([event]) => event === 'cnx-transfer-token')[1]
51
+
52
+ const newSocket = new FakeSocket('new', sockets, (...args) => disconnectingListener(...args))
53
+ let finishJoin
54
+ newSocket.joinDelay = new Promise(resolve => { finishJoin = resolve })
55
+ sockets.set(newSocket.id, newSocket)
56
+ connectListener(newSocket)
57
+ const transfer = newSocket.handlers.get('cnx-transfer')('old', 'new', transferToken)
58
+
59
+ await Promise.resolve()
60
+ assert.equal(newSocket.emitted.some(([event]) => event === 'cnx-transfer-ack'), false)
61
+ finishJoin()
62
+ await transfer
63
+
64
+ assert.equal(newSocket.data.user, 'Ada')
65
+ assert.equal(newSocket.rooms.has('project:1'), true)
66
+ assert.equal(sockets.has('old'), false)
67
+ assert.deepEqual(
68
+ newSocket.emitted.find(([event]) => event === 'cnx-transfer-ack').slice(1),
69
+ ['old', 'new'],
70
+ )
71
+ })