diff --git a/packages/backend-core/src/constants/misc.ts b/packages/backend-core/src/constants/misc.ts index 2bb8f815cf..ba2533cf4a 100644 --- a/packages/backend-core/src/constants/misc.ts +++ b/packages/backend-core/src/constants/misc.ts @@ -16,6 +16,7 @@ export enum Header { LICENSE_KEY = "x-budibase-license-key", API_VER = "x-budibase-api-version", APP_ID = "x-budibase-app-id", + SESSION_ID = "x-budibase-session-id", TYPE = "x-budibase-type", PREVIEW_ROLE = "x-budibase-role", TENANT_ID = "x-budibase-tenant-id", diff --git a/packages/client/src/websocket.js b/packages/client/src/websocket.js index 1e8e908d2b..8ca95fdc84 100644 --- a/packages/client/src/websocket.js +++ b/packages/client/src/websocket.js @@ -18,7 +18,9 @@ export const initWebsocket = () => { } // Initialise connection - socket = createWebsocket("/socket/client", false) + socket = createWebsocket("/socket/client", { + heartbeat: false, + }) // Event handlers socket.on("plugin-update", data => { diff --git a/packages/frontend-core/src/api/index.js b/packages/frontend-core/src/api/index.js index f8eee45cb8..739e0fe711 100644 --- a/packages/frontend-core/src/api/index.js +++ b/packages/frontend-core/src/api/index.js @@ -1,3 +1,4 @@ +import { Helpers } from "@budibase/bbui" import { ApiVersion } from "../constants" import { buildAnalyticsEndpoints } from "./analytics" import { buildAppEndpoints } from "./app" @@ -30,6 +31,14 @@ import { buildEnvironmentVariableEndpoints } from "./environmentVariables" import { buildEventEndpoints } from "./events" import { buildAuditLogsEndpoints } from "./auditLogs" +/** + * Random identifier to uniquely identify a session in a tab. This is + * used to determine the originator of calls to the API, which is in + * turn used to determine who caused a websocket message to be sent, so + * that we can ignore events caused by ourselves. + */ +export const APISessionID = Helpers.uuid() + const defaultAPIClientConfig = { /** * Certain definitions can't change at runtime for client apps, such as the @@ -116,6 +125,7 @@ export const createAPIClient = config => { // Build headers let headers = { Accept: "application/json" } + headers["x-budibase-session-id"] = APISessionID if (!external) { headers["x-budibase-api-version"] = ApiVersion } diff --git a/packages/frontend-core/src/components/grid/lib/websocket.js b/packages/frontend-core/src/components/grid/lib/websocket.js index bb1d6991b0..11164b3148 100644 --- a/packages/frontend-core/src/components/grid/lib/websocket.js +++ b/packages/frontend-core/src/components/grid/lib/websocket.js @@ -26,15 +26,15 @@ export const createGridWebsocket = context => { }) // User events - socket.on(SocketEvent.UserUpdate, user => { + socket.onOther(SocketEvent.UserUpdate, user => { users.actions.updateUser(user) }) - socket.on(SocketEvent.UserDisconnect, user => { + socket.onOther(SocketEvent.UserDisconnect, user => { users.actions.removeUser(user) }) // Row events - socket.on(GridSocketEvent.RowChange, async data => { + socket.onOther(GridSocketEvent.RowChange, async data => { if (data.id) { rows.actions.replaceRow(data.id, data.row) } else if (data.row.id) { @@ -44,7 +44,7 @@ export const createGridWebsocket = context => { }) // Table events - socket.on(GridSocketEvent.TableChange, data => { + socket.onOther(GridSocketEvent.TableChange, data => { // Only update table if one exists. If the table was deleted then we don't // want to know - let the builder navigate away if (data.table) { diff --git a/packages/frontend-core/src/utils/websocket.js b/packages/frontend-core/src/utils/websocket.js index 561a020e13..3a0fa23179 100644 --- a/packages/frontend-core/src/utils/websocket.js +++ b/packages/frontend-core/src/utils/websocket.js @@ -1,17 +1,26 @@ import { io } from "socket.io-client" import { SocketEvent, SocketSessionTTL } from "@budibase/shared-core" +import { APISessionID } from "../api" -export const createWebsocket = (path, heartbeat = true) => { +const DefaultOptions = { + heartbeat: true, +} + +export const createWebsocket = (path, options = DefaultOptions) => { if (!path) { throw "A websocket path must be provided" } + const { heartbeat } = { + ...DefaultOptions, + ...options, + } // 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}`, { + let socket = io(`${proto}//${host}:${port}`, { path, // Cap reconnection attempts to 3 (total of 15 seconds before giving up) reconnectionAttempts: 3, @@ -37,5 +46,17 @@ export const createWebsocket = (path, heartbeat = true) => { clearInterval(interval) }) + // Helper utility to ignore events that were triggered due to API + // changes made by us + socket.onOther = (event, callback) => { + socket.on(event, data => { + if (data?.apiSessionId !== APISessionID) { + callback(data) + } else { + console.log("ignore", event, data) + } + }) + } + return socket } diff --git a/packages/server/src/websockets/builder.ts b/packages/server/src/websockets/builder.ts index ff7c50ebf4..0015b7f601 100644 --- a/packages/server/src/websockets/builder.ts +++ b/packages/server/src/websockets/builder.ts @@ -46,29 +46,32 @@ export default class BuilderSocket extends BaseSocket { } emitTableUpdate(ctx: any, table: Table) { - this.io - .in(ctx.appId) - .emit(BuilderSocketEvent.TableChange, { id: table._id, table }) - gridSocket?.emitTableUpdate(table) + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.TableChange, { + id: table._id, + table, + }) + gridSocket?.emitTableUpdate(ctx, table) } emitTableDeletion(ctx: any, id: string) { - this.io - .in(ctx.appId) - .emit(BuilderSocketEvent.TableChange, { id, table: null }) - gridSocket?.emitTableDeletion(id) + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.TableChange, { + id, + table: null, + }) + gridSocket?.emitTableDeletion(ctx, id) } emitDatasourceUpdate(ctx: any, datasource: Datasource) { - this.io.in(ctx.appId).emit(BuilderSocketEvent.DatasourceChange, { + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.DatasourceChange, { id: datasource._id, datasource, }) } emitDatasourceDeletion(ctx: any, id: string) { - this.io - .in(ctx.appId) - .emit(BuilderSocketEvent.DatasourceChange, { id, datasource: null }) + this.emitToRoom(ctx, ctx.appId, BuilderSocketEvent.DatasourceChange, { + id, + datasource: null, + }) } } diff --git a/packages/server/src/websockets/grid.ts b/packages/server/src/websockets/grid.ts index 440b4f78a5..c12715990e 100644 --- a/packages/server/src/websockets/grid.ts +++ b/packages/server/src/websockets/grid.ts @@ -31,21 +31,25 @@ export default class GridSocket extends BaseSocket { emitRowUpdate(ctx: any, row: Row) { const tableId = getTableId(ctx) - this.io.in(tableId).emit(GridSocketEvent.RowChange, { id: row._id, row }) + this.emitToRoom(ctx, tableId, GridSocketEvent.RowChange, { + id: row._id, + row, + }) } emitRowDeletion(ctx: any, id: string) { const tableId = getTableId(ctx) - this.io.in(tableId).emit(GridSocketEvent.RowChange, { id, row: null }) + this.emitToRoom(ctx, tableId, GridSocketEvent.RowChange, { id, row: null }) } - emitTableUpdate(table: Table) { - this.io - .in(table._id!) - .emit(GridSocketEvent.TableChange, { id: table._id, table }) + emitTableUpdate(ctx: any, table: Table) { + this.emitToRoom(ctx, table._id!, GridSocketEvent.TableChange, { + id: table._id, + table, + }) } - emitTableDeletion(id: string) { - this.io.in(id).emit(GridSocketEvent.TableChange, { id, table: null }) + emitTableDeletion(ctx: any, id: string) { + this.emitToRoom(ctx, id, GridSocketEvent.TableChange, { id, table: null }) } } diff --git a/packages/server/src/websockets/websocket.ts b/packages/server/src/websockets/websocket.ts index a2d2474c77..d9c105d77c 100644 --- a/packages/server/src/websockets/websocket.ts +++ b/packages/server/src/websockets/websocket.ts @@ -3,7 +3,7 @@ import http from "http" import Koa from "koa" import Cookies from "cookies" import { userAgent } from "koa-useragent" -import { auth, redis } from "@budibase/backend-core" +import { auth, Header, redis } from "@budibase/backend-core" import currentApp from "../middleware/currentapp" import { createAdapter } from "@socket.io/redis-adapter" import { Socket } from "socket.io" @@ -271,4 +271,13 @@ export class BaseSocket { emit(event: string, payload: any) { this.io.sockets.emit(event, payload) } + + // Emit an event to everyone in a room, including metadata of whom + // the originator of the request was + emitToRoom(ctx: any, room: string, event: string, payload: any) { + this.io.in(room).emit(event, { + ...payload, + apiSessionId: ctx.headers?.[Header.SESSION_ID], + }) + } }