@budibase/server 2.6.19-alpha.37 → 2.6.19-alpha.39

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@budibase/server",
3
3
  "email": "hi@budibase.com",
4
- "version": "2.6.19-alpha.37",
4
+ "version": "2.6.19-alpha.39",
5
5
  "description": "Budibase Web Server",
6
6
  "main": "src/index.ts",
7
7
  "repository": {
@@ -46,12 +46,12 @@
46
46
  "license": "GPL-3.0",
47
47
  "dependencies": {
48
48
  "@apidevtools/swagger-parser": "10.0.3",
49
- "@budibase/backend-core": "2.6.19-alpha.37",
50
- "@budibase/client": "2.6.19-alpha.37",
51
- "@budibase/pro": "2.6.19-alpha.37",
52
- "@budibase/shared-core": "2.6.19-alpha.37",
53
- "@budibase/string-templates": "2.6.19-alpha.37",
54
- "@budibase/types": "2.6.19-alpha.37",
49
+ "@budibase/backend-core": "2.6.19-alpha.39",
50
+ "@budibase/client": "2.6.19-alpha.39",
51
+ "@budibase/pro": "2.6.19-alpha.39",
52
+ "@budibase/shared-core": "2.6.19-alpha.39",
53
+ "@budibase/string-templates": "2.6.19-alpha.39",
54
+ "@budibase/types": "2.6.19-alpha.39",
55
55
  "@bull-board/api": "3.7.0",
56
56
  "@bull-board/koa": "3.9.4",
57
57
  "@elastic/elasticsearch": "7.10.0",
@@ -196,5 +196,5 @@
196
196
  }
197
197
  }
198
198
  },
199
- "gitHead": "f2d47aa5dc4f641dd85032eaeb314968a1decb7e"
199
+ "gitHead": "8d4d2b1e79361f182c0575a41d8173a03fae982e"
200
200
  }
@@ -4,9 +4,10 @@ import { ContextUser } from "@budibase/types"
4
4
 
5
5
  const APP_DEV_LOCK_SECONDS = 600
6
6
  const AUTOMATION_TEST_FLAG_SECONDS = 60
7
- let devAppClient: any, debounceClient: any, flagClient: any, socketClient: any
7
+ let devAppClient: any, debounceClient: any, flagClient: any
8
8
 
9
9
  // We need to maintain a duplicate client for socket.io pub/sub
10
+ let socketClient: any
10
11
  let socketSubClient: any
11
12
 
12
13
  // We init this as we want to keep the connection open all the time
@@ -15,21 +16,20 @@ export async function init() {
15
16
  devAppClient = new redis.Client(redis.utils.Databases.DEV_LOCKS)
16
17
  debounceClient = new redis.Client(redis.utils.Databases.DEBOUNCE)
17
18
  flagClient = new redis.Client(redis.utils.Databases.FLAGS)
18
- socketClient = new redis.Client(redis.utils.Databases.SOCKET_IO)
19
19
  await devAppClient.init()
20
20
  await debounceClient.init()
21
21
  await flagClient.init()
22
- await socketClient.init()
23
22
 
24
23
  // Duplicate the socket client for pub/sub
24
+ socketClient = await redis.clients.getSocketClient()
25
25
  socketSubClient = socketClient.getClient().duplicate()
26
26
  }
27
27
 
28
28
  export async function shutdown() {
29
+ console.log("REDIS SHUTDOWN")
29
30
  if (devAppClient) await devAppClient.finish()
30
31
  if (debounceClient) await debounceClient.finish()
31
32
  if (flagClient) await flagClient.finish()
32
- if (socketClient) await socketClient.finish()
33
33
  if (socketSubClient) socketSubClient.disconnect()
34
34
  // shutdown core clients
35
35
  await redis.clients.shutdown()
@@ -1,69 +1,74 @@
1
1
  import authorized from "../middleware/authorized"
2
- import Socket from "./websocket"
2
+ import { BaseSocket } from "./websocket"
3
3
  import { permissions } from "@budibase/backend-core"
4
4
  import http from "http"
5
5
  import Koa from "koa"
6
- import { Datasource, Table } from "@budibase/types"
6
+ import { Datasource, Table, SocketSession, ContextUser } from "@budibase/types"
7
7
  import { gridSocket } from "./index"
8
8
  import { clearLock } from "../utilities/redis"
9
+ import { Socket } from "socket.io"
10
+ import { BuilderSocketEvent } from "@budibase/shared-core"
9
11
 
10
- export default class BuilderSocket extends Socket {
12
+ export default class BuilderSocket extends BaseSocket {
11
13
  constructor(app: Koa, server: http.Server) {
12
14
  super(app, server, "/socket/builder", [authorized(permissions.BUILDER)])
15
+ }
13
16
 
14
- this.io.on("connection", socket => {
15
- // Join a room for this app
16
- const user = socket.data.user
17
- const appId = socket.data.appId
18
- socket.join(appId)
19
- socket.to(appId).emit("user-update", user)
20
-
21
- // Initial identification of connected spreadsheet
22
- socket.on("get-users", async (payload, callback) => {
23
- const sockets = await this.io.in(appId).fetchSockets()
24
- callback({
25
- users: sockets.map(socket => socket.data.user),
26
- })
27
- })
17
+ async onConnect(socket: Socket) {
18
+ // Initial identification of selected app
19
+ socket.on(BuilderSocketEvent.SelectApp, async (appId, callback) => {
20
+ await this.joinRoom(socket, appId)
28
21
 
29
- // Disconnection cleanup
30
- socket.on("disconnect", async () => {
31
- socket.to(appId).emit("user-disconnect", user)
22
+ // Reply with all users in current room
23
+ const sessions = await this.getRoomSessions(appId)
24
+ callback({ users: sessions })
25
+ })
26
+ }
32
27
 
33
- // Remove app lock from this user if they have no other connections
34
- try {
35
- const sockets = await this.io.in(appId).fetchSockets()
36
- const hasOtherConnection = sockets.some(socket => {
37
- const { _id, sessionId } = socket.data.user
38
- return _id === user._id && sessionId !== user.sessionId
39
- })
40
- if (!hasOtherConnection) {
41
- await clearLock(appId, user)
42
- }
43
- } catch (e) {
44
- // This is fine, just means this user didn't hold the lock
45
- }
28
+ async onDisconnect(socket: Socket) {
29
+ // Remove app lock from this user if they have no other connections
30
+ try {
31
+ // @ts-ignore
32
+ const session: SocketSession = socket.data
33
+ const { _id, sessionId, room } = session
34
+ const sessions = await this.getRoomSessions(room)
35
+ const hasOtherSession = sessions.some(otherSession => {
36
+ return _id === otherSession._id && sessionId !== otherSession.sessionId
46
37
  })
47
- })
38
+ if (!hasOtherSession && room) {
39
+ // @ts-ignore
40
+ const user: ContextUser = { _id: socket.data._id }
41
+ await clearLock(room, user)
42
+ }
43
+ } catch (e) {
44
+ // This is fine, just means this user didn't hold the lock
45
+ }
48
46
  }
49
47
 
50
48
  emitTableUpdate(ctx: any, table: Table) {
51
- this.io.in(ctx.appId).emit("table-change", { id: table._id, table })
49
+ this.io
50
+ .in(ctx.appId)
51
+ .emit(BuilderSocketEvent.TableChange, { id: table._id, table })
52
52
  gridSocket.emitTableUpdate(table)
53
53
  }
54
54
 
55
55
  emitTableDeletion(ctx: any, id: string) {
56
- this.io.in(ctx.appId).emit("table-change", { id, table: null })
56
+ this.io
57
+ .in(ctx.appId)
58
+ .emit(BuilderSocketEvent.TableChange, { id, table: null })
57
59
  gridSocket.emitTableDeletion(id)
58
60
  }
59
61
 
60
62
  emitDatasourceUpdate(ctx: any, datasource: Datasource) {
61
- this.io
62
- .in(ctx.appId)
63
- .emit("datasource-change", { id: datasource._id, datasource })
63
+ this.io.in(ctx.appId).emit(BuilderSocketEvent.DatasourceChange, {
64
+ id: datasource._id,
65
+ datasource,
66
+ })
64
67
  }
65
68
 
66
69
  emitDatasourceDeletion(ctx: any, id: string) {
67
- this.io.in(ctx.appId).emit("datasource-change", { id, datasource: null })
70
+ this.io
71
+ .in(ctx.appId)
72
+ .emit(BuilderSocketEvent.DatasourceChange, { id, datasource: null })
68
73
  }
69
74
  }
@@ -1,10 +1,10 @@
1
- import Socket from "./websocket"
1
+ import { BaseSocket } from "./websocket"
2
2
  import authorized from "../middleware/authorized"
3
3
  import http from "http"
4
4
  import Koa from "koa"
5
5
  import { permissions } from "@budibase/backend-core"
6
6
 
7
- export default class ClientAppWebsocket extends Socket {
7
+ export default class ClientAppWebsocket extends BaseSocket {
8
8
  constructor(app: Koa, server: http.Server) {
9
9
  super(app, server, "/socket/client", [authorized(permissions.BUILDER)])
10
10
  }
@@ -1,73 +1,51 @@
1
1
  import authorized from "../middleware/authorized"
2
- import Socket from "./websocket"
2
+ import { BaseSocket } from "./websocket"
3
3
  import { permissions } from "@budibase/backend-core"
4
4
  import http from "http"
5
5
  import Koa from "koa"
6
6
  import { getTableId } from "../api/controllers/row/utils"
7
7
  import { Row, Table } from "@budibase/types"
8
+ import { Socket } from "socket.io"
9
+ import { GridSocketEvent } from "@budibase/shared-core"
8
10
 
9
- export default class GridSocket extends Socket {
11
+ export default class GridSocket extends BaseSocket {
10
12
  constructor(app: Koa, server: http.Server) {
11
13
  super(app, server, "/socket/grid", [authorized(permissions.BUILDER)])
14
+ }
12
15
 
13
- this.io.on("connection", socket => {
14
- const user = socket.data.user
15
-
16
- // Socket state
17
- let currentRoom: string
18
-
19
- // Initial identification of connected spreadsheet
20
- socket.on("select-table", async (tableId, callback) => {
21
- // Leave current room
22
- if (currentRoom) {
23
- socket.to(currentRoom).emit("user-disconnect", user)
24
- socket.leave(currentRoom)
25
- }
26
-
27
- // Join new room
28
- currentRoom = tableId
29
- socket.join(currentRoom)
30
- socket.to(currentRoom).emit("user-update", user)
31
-
32
- // Reply with all users in current room
33
- const sockets = await this.io.in(currentRoom).fetchSockets()
34
- callback({
35
- users: sockets.map(socket => socket.data.user),
36
- })
37
- })
16
+ async onConnect(socket: Socket) {
17
+ // Initial identification of connected spreadsheet
18
+ socket.on(GridSocketEvent.SelectTable, async (tableId, callback) => {
19
+ await this.joinRoom(socket, tableId)
38
20
 
39
- // Handle users selecting a new cell
40
- socket.on("select-cell", cellId => {
41
- socket.data.user.focusedCellId = cellId
42
- if (currentRoom) {
43
- socket.to(currentRoom).emit("user-update", user)
44
- }
45
- })
21
+ // Reply with all users in current room
22
+ const sessions = await this.getRoomSessions(tableId)
23
+ callback({ users: sessions })
24
+ })
46
25
 
47
- // Disconnection cleanup
48
- socket.on("disconnect", () => {
49
- if (currentRoom) {
50
- socket.to(currentRoom).emit("user-disconnect", user)
51
- }
52
- })
26
+ // Handle users selecting a new cell
27
+ socket.on(GridSocketEvent.SelectCell, cellId => {
28
+ this.updateUser(socket, { focusedCellId: cellId })
53
29
  })
54
30
  }
55
31
 
56
32
  emitRowUpdate(ctx: any, row: Row) {
57
33
  const tableId = getTableId(ctx)
58
- this.io.in(tableId).emit("row-change", { id: row._id, row })
34
+ this.io.in(tableId).emit(GridSocketEvent.RowChange, { id: row._id, row })
59
35
  }
60
36
 
61
37
  emitRowDeletion(ctx: any, id: string) {
62
38
  const tableId = getTableId(ctx)
63
- this.io.in(tableId).emit("row-change", { id, row: null })
39
+ this.io.in(tableId).emit(GridSocketEvent.RowChange, { id, row: null })
64
40
  }
65
41
 
66
42
  emitTableUpdate(table: Table) {
67
- this.io.in(table._id!).emit("table-change", { id: table._id, table })
43
+ this.io
44
+ .in(table._id!)
45
+ .emit(GridSocketEvent.TableChange, { id: table._id, table })
68
46
  }
69
47
 
70
48
  emitTableDeletion(id: string) {
71
- this.io.in(id).emit("table-change", { id, table: null })
49
+ this.io.in(id).emit(GridSocketEvent.TableChange, { id, table: null })
72
50
  }
73
51
  }
@@ -3,14 +3,18 @@ import http from "http"
3
3
  import Koa from "koa"
4
4
  import Cookies from "cookies"
5
5
  import { userAgent } from "koa-useragent"
6
- import { auth } from "@budibase/backend-core"
6
+ import { auth, redis } from "@budibase/backend-core"
7
7
  import currentApp from "../middleware/currentapp"
8
8
  import { createAdapter } from "@socket.io/redis-adapter"
9
+ import { Socket } from "socket.io"
9
10
  import { getSocketPubSubClients } from "../utilities/redis"
10
- import uuid from "uuid"
11
+ import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core"
12
+ import { SocketSession } from "@budibase/types"
11
13
 
12
- export default class Socket {
14
+ export class BaseSocket {
13
15
  io: Server
16
+ path: string
17
+ redisClient?: redis.Client
14
18
 
15
19
  constructor(
16
20
  app: Koa,
@@ -18,6 +22,7 @@ export default class Socket {
18
22
  path: string = "/",
19
23
  additionalMiddlewares?: any[]
20
24
  ) {
25
+ this.path = path
21
26
  this.io = new Server(server, {
22
27
  path,
23
28
  })
@@ -65,18 +70,14 @@ export default class Socket {
65
70
  // Middlewares are finished
66
71
  // Extract some data from our enriched koa context to persist
67
72
  // as metadata for the socket
68
- // Add user info, including a deterministic color and label
69
73
  const { _id, email, firstName, lastName } = ctx.user
70
- socket.data.user = {
74
+ socket.data = {
71
75
  _id,
72
76
  email,
73
77
  firstName,
74
78
  lastName,
75
- sessionId: uuid.v4(),
79
+ sessionId: socket.id,
76
80
  }
77
-
78
- // Add app ID to help split sockets into rooms
79
- socket.data.appId = ctx.appId
80
81
  next()
81
82
  }
82
83
  })
@@ -86,10 +87,184 @@ export default class Socket {
86
87
  }
87
88
  })
88
89
 
89
- // Instantiate redis adapter
90
+ // Initialise redis before handling connections
91
+ this.initialise().then(() => {
92
+ this.io.on("connection", async socket => {
93
+ // Add built in handler for heartbeats
94
+ socket.on(SocketEvent.Heartbeat, async () => {
95
+ console.log(socket.data.email, "heartbeat received")
96
+ await this.extendSessionTTL(socket.data.sessionId)
97
+ })
98
+
99
+ // Add early disconnection handler to clean up and leave room
100
+ socket.on("disconnect", async () => {
101
+ // Run any custom disconnection logic before we leave the room,
102
+ // so that we have access to their room etc before disconnection
103
+ await this.onDisconnect(socket)
104
+
105
+ // Leave the current room when the user disconnects if we're in one
106
+ await this.leaveRoom(socket)
107
+ })
108
+
109
+ // Add handlers for this socket
110
+ await this.onConnect(socket)
111
+ })
112
+ })
113
+ }
114
+
115
+ async initialise() {
116
+ // Instantiate redis adapter.
117
+ // We use a fully qualified key name here as this bypasses the normal
118
+ // redis client#s key prefixing.
90
119
  const { pub, sub } = getSocketPubSubClients()
91
- const opts = { key: `socket.io-${path}` }
120
+ const opts = {
121
+ key: `${redis.utils.Databases.SOCKET_IO}-${this.path}-pubsub`,
122
+ }
92
123
  this.io.adapter(createAdapter(pub, sub, opts))
124
+
125
+ // Fetch redis client
126
+ this.redisClient = await redis.clients.getSocketClient()
127
+ }
128
+
129
+ // Gets the redis key for a certain session ID
130
+ getSessionKey(sessionId: string) {
131
+ return `${this.path}-session:${sessionId}`
132
+ }
133
+
134
+ // Gets the redis key for certain room name
135
+ getRoomKey(room: string) {
136
+ return `${this.path}-room:${room}`
137
+ }
138
+
139
+ async extendSessionTTL(sessionId: string) {
140
+ const key = this.getSessionKey(sessionId)
141
+ await this.redisClient?.setExpiry(key, SocketSessionTTL)
142
+ }
143
+
144
+ // Gets an array of all redis keys of users inside a certain room
145
+ async getRoomSessionIds(room: string): Promise<string[]> {
146
+ const keys = await this.redisClient?.get(this.getRoomKey(room))
147
+ return keys || []
148
+ }
149
+
150
+ // Sets the list of redis keys for users inside a certain room.
151
+ // There is no TTL on the actual room key map itself.
152
+ async setRoomSessionIds(room: string, ids: string[]) {
153
+ await this.redisClient?.store(this.getRoomKey(room), ids)
154
+ }
155
+
156
+ // Gets a list of all users inside a certain room
157
+ async getRoomSessions(room?: string): Promise<SocketSession[]> {
158
+ if (room) {
159
+ const sessionIds = await this.getRoomSessionIds(room)
160
+ const keys = sessionIds.map(this.getSessionKey.bind(this))
161
+ const sessions = await this.redisClient?.bulkGet(keys)
162
+ return Object.values(sessions || {})
163
+ } else {
164
+ return []
165
+ }
166
+ }
167
+
168
+ // Detects keys which have been pruned from redis due to TTL expiry in a certain
169
+ // room and broadcasts disconnection messages to ensure clients are aware
170
+ async pruneRoom(room: string) {
171
+ const sessionIds = await this.getRoomSessionIds(room)
172
+ const sessionsExist = await Promise.all(
173
+ sessionIds.map(id => this.redisClient?.exists(this.getSessionKey(id)))
174
+ )
175
+ const prunedSessionIds = sessionIds.filter((id, idx) => {
176
+ if (!sessionsExist[idx]) {
177
+ this.io.to(room).emit(SocketEvent.UserDisconnect, sessionIds[idx])
178
+ return false
179
+ }
180
+ return true
181
+ })
182
+
183
+ // Store new pruned keys
184
+ await this.setRoomSessionIds(room, prunedSessionIds)
185
+ }
186
+
187
+ // Adds a user to a certain room
188
+ async joinRoom(socket: Socket, room: string) {
189
+ if (!room) {
190
+ return
191
+ }
192
+ // Prune room before joining
193
+ await this.pruneRoom(room)
194
+
195
+ // Check if we're already in a room, as we'll need to leave if we are before we
196
+ // can join a different room
197
+ const oldRoom = socket.data.room
198
+ if (oldRoom && oldRoom !== room) {
199
+ await this.leaveRoom(socket)
200
+ }
201
+
202
+ // Join new room
203
+ if (!oldRoom || oldRoom !== room) {
204
+ socket.join(room)
205
+ socket.data.room = room
206
+ }
207
+
208
+ // Store in redis
209
+ // @ts-ignore
210
+ let user: SocketSession = socket.data
211
+ const { sessionId } = user
212
+ const key = this.getSessionKey(sessionId)
213
+ await this.redisClient?.store(key, user, SocketSessionTTL)
214
+ const sessionIds = await this.getRoomSessionIds(room)
215
+ if (!sessionIds.includes(sessionId)) {
216
+ await this.setRoomSessionIds(room, [...sessionIds, sessionId])
217
+ }
218
+
219
+ // Notify other users
220
+ socket.to(room).emit(SocketEvent.UserUpdate, user)
221
+ }
222
+
223
+ // Disconnects a socket from its current room
224
+ async leaveRoom(socket: Socket) {
225
+ // @ts-ignore
226
+ let user: SocketSession = socket.data
227
+ const { room, sessionId } = user
228
+ if (!room) {
229
+ return
230
+ }
231
+
232
+ // Leave room
233
+ socket.leave(room)
234
+ socket.data.room = undefined
235
+
236
+ // Delete from redis
237
+ const key = this.getSessionKey(sessionId)
238
+ await this.redisClient?.delete(key)
239
+ const sessionIds = await this.getRoomSessionIds(room)
240
+ await this.setRoomSessionIds(
241
+ room,
242
+ sessionIds.filter(id => id !== sessionId)
243
+ )
244
+
245
+ // Notify other users
246
+ socket.to(room).emit(SocketEvent.UserDisconnect, sessionId)
247
+ }
248
+
249
+ // Updates a connected user's metadata, assuming a room change is not required.
250
+ async updateUser(socket: Socket, patch: Object) {
251
+ socket.data = {
252
+ ...socket.data,
253
+ ...patch,
254
+ }
255
+
256
+ // If we're in a room, notify others of this change and update redis
257
+ if (socket.data.room) {
258
+ await this.joinRoom(socket, socket.data.room)
259
+ }
260
+ }
261
+
262
+ async onConnect(socket: Socket) {
263
+ // Override
264
+ }
265
+
266
+ async onDisconnect(socket: Socket) {
267
+ // Override
93
268
  }
94
269
 
95
270
  // Emit an event to all sockets