Improve builder socket

This commit is contained in:
Andrew Kingston 2023-05-16 14:18:31 +01:00
parent cb9d35f6aa
commit 6ed691be1d
15 changed files with 185 additions and 59 deletions

View File

@ -2,6 +2,7 @@ import { getFrontendStore } from "./store/frontend"
import { getAutomationStore } from "./store/automation"
import { getTemporalStore } from "./store/temporal"
import { getThemeStore } from "./store/theme"
import { getUsersStore } from "./store/users"
import { derived } from "svelte/store"
import { findComponent, findComponentPath } from "./componentUtils"
import { RoleUtils } from "@budibase/frontend-core"
@ -12,6 +13,7 @@ export const store = getFrontendStore()
export const automationStore = getAutomationStore()
export const themeStore = getThemeStore()
export const temporalStore = getTemporalStore()
export const usersStore = getUsersStore()
// Setup history for screens
export const screenHistoryStore = createHistoryStore({

View File

@ -37,6 +37,7 @@ import {
} from "builderStore/dataBinding"
import { makePropSafe as safe } from "@budibase/string-templates"
import { getComponentFieldOptions } from "helpers/formFields"
import { createBuilderWebsocket } from "builderStore/websocket"
const INITIAL_FRONTEND_STATE = {
initialised: false,
@ -84,10 +85,14 @@ const INITIAL_FRONTEND_STATE = {
// Onboarding
onboarding: false,
tourNodes: null,
// Multi user collab
users: [],
}
export const getFrontendStore = () => {
const store = writable({ ...INITIAL_FRONTEND_STATE })
let websocket
// This is a fake implementation of a "patch" API endpoint to try and prevent
// 409s. All screen doc mutations (aside from creation) use this function,
@ -112,9 +117,11 @@ export const getFrontendStore = () => {
store.actions = {
reset: () => {
store.set({ ...INITIAL_FRONTEND_STATE })
websocket?.disconnect()
},
initialise: async pkg => {
const { layouts, screens, application, clientLibPath, hasLock } = pkg
websocket = createBuilderWebsocket()
await store.actions.components.refreshDefinitions(application.appId)

View File

@ -0,0 +1,10 @@
import { writable } from "svelte/store"
export const getUsersStore = () => {
const initialValue = {
users: [],
}
const store = writable(initialValue)
return store
}

View File

@ -0,0 +1,26 @@
import { createWebsocket } from "@budibase/frontend-core"
import { store } from "builderStore"
export const createBuilderWebsocket = () => {
const socket = createWebsocket("/socket/builder")
socket.on("connect", () => {
socket.emit("get-users", null, response => {
console.log("conntected!", response.users)
store.update(state => ({
...state,
users: response.users,
}))
})
})
socket.on("user-update", user => {})
socket.on("user-disconnect", user => {})
socket.on("connect_error", err => {
console.log("Failed to connect to builder websocket:", err.message)
})
return socket
}

View File

@ -0,0 +1,33 @@
<script>
export let users = []
</script>
<div class="avatars">
{#each users as user}
<div class="avatar" style="background:{user.color};" title={user.email}>
{user.email[0]}
</div>
{/each}
</div>
<style>
.avatars {
display: flex;
gap: 4px;
}
.avatar {
width: 24px;
height: 24px;
display: grid;
place-items: center;
color: white;
border-radius: 50%;
font-size: 12px;
font-weight: 700;
text-transform: uppercase;
line-height: 0;
}
.avatar:hover {
cursor: pointer;
}
</style>

View File

@ -22,6 +22,7 @@
import TourWrap from "components/portal/onboarding/TourWrap.svelte"
import TourPopover from "components/portal/onboarding/TourPopover.svelte"
import BuilderSidePanel from "./_components/BuilderSidePanel.svelte"
import UserAvatars from "./_components/UserAvatars.svelte"
import { TOUR_KEYS, TOURS } from "components/portal/onboarding/tours.js"
export let application
@ -125,10 +126,7 @@
})
onDestroy(() => {
store.update(state => {
state.appId = null
return state
})
store.actions.reset()
})
</script>
@ -211,6 +209,7 @@
{/if}
</div>
<div class="toprightnav">
<UserAvatars users={$store.users} />
<AppActions {application} />
</div>
{/if}

View File

@ -24,7 +24,7 @@
import RowHeightButton from "../controls/RowHeightButton.svelte"
import ColumnWidthButton from "../controls/ColumnWidthButton.svelte"
import NewRow from "./NewRow.svelte"
import { createWebsocket } from "../lib/websocket"
import { createGridWebsocket } from "../lib/websocket"
import {
MaxCellRenderHeight,
MaxCellRenderWidthOverflow,
@ -98,7 +98,7 @@
export const getContext = () => context
// Initialise websocket for multi-user
onMount(() => createWebsocket(context))
onMount(() => createGridWebsocket(context))
</script>
<div

View File

@ -15,6 +15,6 @@
.users {
display: flex;
flex-direction: row;
gap: 8px;
gap: 4px;
}
</style>

View File

@ -1,24 +1,9 @@
import { get } from "svelte/store"
import { io } from "socket.io-client"
import { createWebsocket } from "../../../utils"
export const createWebsocket = context => {
export const createGridWebsocket = context => {
const { rows, tableId, users, userId, focusedCellId } = context
// Determine connection info
const tls = location.protocol === "https:"
const proto = tls ? "wss:" : "ws:"
const host = location.hostname
const port = location.port || (tls ? 443 : 80)
const socket = io(`${proto}//${host}:${port}`, {
path: "/socket/grid",
// Cap reconnection attempts to 3 (total of 15 seconds before giving up)
reconnectionAttempts: 3,
// Delay reconnection attempt by 5 seconds
reconnectionDelay: 5000,
reconnectionDelayMax: 5000,
// Timeout after 4 seconds so we never stack requests
timeout: 4000,
})
const socket = createWebsocket("/socket/grid")
const connectToTable = tableId => {
if (!socket.connected) {

View File

@ -21,35 +21,6 @@ export const createStores = () => {
return 0
}
})
// Enrich users with colors
.map((user, idx) => {
// Generate random colour hue
let hue = 1
for (let i = 0; i < user.email.length && i < 5; i++) {
hue *= user.email.charCodeAt(i + 1)
hue /= 17
}
hue = hue % 360
const color =
idx === 0
? "var(--spectrum-global-color-blue-400)"
: `hsl(${hue}, 50%, 40%)`
// Generate friendly label
let label = user.email
if (user.firstName) {
label = user.firstName
if (user.lastName) {
label += ` ${user.lastName}`
}
}
return {
...user,
color,
label,
}
})
)
},
[]

View File

@ -3,3 +3,4 @@ export * as JSONUtils from "./json"
export * as CookieUtils from "./cookies"
export * as RoleUtils from "./roles"
export * as Utils from "./utils"
export { createWebsocket } from "./websocket"

View File

@ -0,0 +1,23 @@
import { io } from "socket.io-client"
export const createWebsocket = path => {
if (!path) {
throw "A websocket path must be provided"
}
// Determine connection info
const tls = location.protocol === "https:"
const proto = tls ? "wss:" : "ws:"
const host = location.hostname
const port = location.port || (tls ? 443 : 80)
return io(`${proto}//${host}:${port}`, {
path,
// Cap reconnection attempts to 3 (total of 15 seconds before giving up)
reconnectionAttempts: 3,
// Delay reconnection attempt by 5 seconds
reconnectionDelay: 5000,
reconnectionDelayMax: 5000,
// Timeout after 4 seconds so we never stack requests
timeout: 4000,
})
}

View File

@ -0,0 +1,43 @@
import authorized from "../middleware/authorized"
import Socket from "./websocket"
import { permissions } from "@budibase/backend-core"
import http from "http"
import Koa from "koa"
import { Table } from "@budibase/types"
export default class BuilderSocket extends Socket {
constructor(app: Koa, server: http.Server) {
super(app, server, "/socket/builder", [authorized(permissions.BUILDER)])
this.io.on("connection", socket => {
// Join a room for this app
const user = socket.data.user
const appId = socket.data.appId
socket.join(appId)
socket.to(appId).emit("user-update", socket.data.user)
console.log(`Builder user connected: ${user?.id}`)
// Initial identification of connected spreadsheet
socket.on("get-users", async (payload, callback) => {
const sockets = await this.io.in(appId).fetchSockets()
callback({
users: sockets.map(socket => socket.data.user),
id: user.id,
})
})
// Disconnection cleanup
socket.on("disconnect", () => {
socket.to(appId).emit("user-disconnect", socket.data.user)
})
})
}
emitTableUpdate(ctx: any, table: Table) {
this.io.in(ctx.appId).emit("table-update", { id: table._id, table })
}
emitTableDeletion(ctx: any, id: string) {
this.io.in(ctx.appId).emit("table-update", { id, table: null })
}
}

View File

@ -1,14 +1,17 @@
import http from "http"
import Koa from "koa"
import GridSocket from "./grid"
import ClientAppSocket from "./client"
import GridSocket from "./grid"
import BuilderSocket from "./builder"
let clientAppSocket: ClientAppSocket
let gridSocket: GridSocket
let builderSocket: BuilderSocket
export const initialise = (app: Koa, server: http.Server) => {
clientAppSocket = new ClientAppSocket(app, server)
gridSocket = new GridSocket(app, server)
builderSocket = new BuilderSocket(app, server)
}
export { clientAppSocket, gridSocket }

View File

@ -59,13 +59,36 @@ export default class Socket {
for (let [idx, middleware] of middlewares.entries()) {
await middleware(ctx, () => {
if (idx === middlewares.length - 1) {
// Middlewares are finished.
// Middlewares are finished
// Extract some data from our enriched koa context to persist
// as metadata for the socket
socket.data.user = {
id: ctx.user._id,
email: ctx.user.email,
// Add user info, including a deterministic color and friendly
// label
const { _id, email, firstName, lastName } = ctx.user
let hue = 1
for (let i = 0; i < email.length && i < 5; i++) {
hue *= email.charCodeAt(i + 1)
hue /= 17
}
hue = hue % 360
const color = `hsl(${hue}, 50%, 40%)`
let label = email
if (firstName) {
label = firstName
if (lastName) {
label += ` ${lastName}`
}
}
socket.data.user = {
id: _id,
email,
color,
label,
}
// Add app ID to help split sockets into rooms
socket.data.appId = ctx.appId
next()
}
})