From f61b30e3cc6c2f34c949ccadaa0eb07d24205873 Mon Sep 17 00:00:00 2001 From: Bhasher Date: Fri, 12 Jan 2024 02:52:36 +0100 Subject: [PATCH] Websocket for real-time updates of projects --- backend/go.mod | 3 ++ backend/go.sum | 6 +++ backend/handlers/main.go | 1 + backend/handlers/projects.go | 40 ++++++++++++++++++-- backend/handlers/ws.go | 57 ++++++++++++++++++++++++++++ frontend/src/lib/api/websocket.ts | 62 +++++++++++++++++++++++++++++++ frontend/src/lib/utils/api.ts | 57 +++++++++++++++++++++------- frontend/src/lib/utils/toasts.ts | 41 ++++++++++++-------- frontend/src/routes/+page.svelte | 3 ++ 9 files changed, 238 insertions(+), 32 deletions(-) create mode 100644 backend/handlers/ws.go create mode 100644 frontend/src/lib/api/websocket.ts diff --git a/backend/go.mod b/backend/go.mod index a2ef4d0..dd943b7 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -6,6 +6,8 @@ require github.com/gofiber/fiber/v2 v2.51.0 require ( github.com/andybalholm/brotli v1.0.5 // indirect + github.com/fasthttp/websocket v1.5.3 // indirect + github.com/gofiber/websocket/v2 v2.2.1 // indirect github.com/google/uuid v1.4.0 // indirect github.com/klauspost/compress v1.16.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -13,6 +15,7 @@ require ( github.com/mattn/go-runewidth v0.0.15 // indirect github.com/philhofer/fwd v1.1.2 // indirect github.com/rivo/uniseg v0.2.0 // indirect + github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee // indirect github.com/tinylib/msgp v1.1.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.50.0 // indirect diff --git a/backend/go.sum b/backend/go.sum index d493c50..ffeed72 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -1,7 +1,11 @@ github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/fasthttp/websocket v1.5.3 h1:TPpQuLwJYfd4LJPXvHDYPMFWbLjsT91n3GpWtCQtdek= +github.com/fasthttp/websocket v1.5.3/go.mod h1:46gg/UBmTU1kUaTcwQXpUxtRwG2PvIZYeA8oL6vF3Fs= github.com/gofiber/fiber/v2 v2.51.0 h1:JNACcZy5e2tGApWB2QrRpenTWn0fq0hkFm6k0C86gKQ= github.com/gofiber/fiber/v2 v2.51.0/go.mod h1:xaQRZQJGqnKOQnbQw+ltvku3/h8QxvNi8o6JiJ7Ll0U= +github.com/gofiber/websocket/v2 v2.2.1 h1:C9cjxvloojayOp9AovmpQrk8VqvVnT8Oao3+IUygH7w= +github.com/gofiber/websocket/v2 v2.2.1/go.mod h1:Ao/+nyNnX5u/hIFPuHl28a+NIkrqK7PRimyKaj4JxVU= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= @@ -19,6 +23,8 @@ github.com/philhofer/fwd v1.1.2 h1:bnDivRJ1EWPjUIRXV5KfORO897HTbpFAQddBdE8t7Gw= github.com/philhofer/fwd v1.1.2/go.mod h1:qkPdfjR2SIEbspLqpe1tO4n5yICnr2DY7mqEx2tUTP0= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee h1:8Iv5m6xEo1NR1AvpV+7XmhI4r39LGNzwUL4YpMuL5vk= +github.com/savsgio/gotils v0.0.0-20230208104028-c358bd845dee/go.mod h1:qwtSXrKuJh/zsFQ12yEE89xfCrGKK63Rr7ctU/uCo4g= github.com/tinylib/msgp v1.1.8 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0= github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= diff --git a/backend/handlers/main.go b/backend/handlers/main.go index e5456b1..38f82d2 100644 --- a/backend/handlers/main.go +++ b/backend/handlers/main.go @@ -12,6 +12,7 @@ func v1Router(router fiber.Router) error { tagsRouter(router.Group("/tags")) viewsRouter(router.Group("/views")) filtersRouter(router.Group("/filters")) + wsRouter(router.Group("/ws")) return nil } diff --git a/backend/handlers/projects.go b/backend/handlers/projects.go index 94f0eed..1a49505 100644 --- a/backend/handlers/projects.go +++ b/backend/handlers/projects.go @@ -83,9 +83,22 @@ func CreateProject(c *fiber.Ctx) error { projectsLastEdit = time.Now().Truncate(time.Second); - return c.Status(fiber.StatusCreated).JSON(fiber.Map{ + if err = c.Status(fiber.StatusCreated).JSON(fiber.Map{ "id": id, + }); err != nil { + return err; + } + + p.ID = id; + + publish(fiber.Map{ + "object": "project", + "action": "create", + "id": id, + "value": p, }) + + return nil; } func UpdateProject(c *fiber.Ctx) error { @@ -113,7 +126,18 @@ func UpdateProject(c *fiber.Ctx) error { projectsLastEdit = time.Now().Truncate(time.Second); - return c.SendStatus(fiber.StatusNoContent) + if err = c.SendStatus(fiber.StatusNoContent); err != nil { + return err; + } + + publish(fiber.Map{ + "object": "project", + "action": "update", + "id": id, + "value": p, +}) + + return nil; } func DeleteProject(c *fiber.Ctx) error { @@ -136,7 +160,17 @@ func DeleteProject(c *fiber.Ctx) error { projectsLastEdit = time.Now().Truncate(time.Second); - return c.SendStatus(fiber.StatusNoContent) + if err = c.SendStatus(fiber.StatusNoContent); err != nil { + return err; + } + + publish(fiber.Map{ + "object": "project", + "action": "delete", + "id": id, + }) + + return nil; } func GetProjectCards(c *fiber.Ctx) error { diff --git a/backend/handlers/ws.go b/backend/handlers/ws.go new file mode 100644 index 0000000..4da86ca --- /dev/null +++ b/backend/handlers/ws.go @@ -0,0 +1,57 @@ +package handlers + +import ( + "encoding/json" + "log" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/websocket/v2" +) + +var ( + subscribers = make(map[*websocket.Conn]bool) +) + +func wsRouter(router fiber.Router) error { + router.Use("/", upgradeWebsocket) + router.Get("/", websocket.New(handleWebsocket)) + return nil; +} + +func upgradeWebsocket(c *fiber.Ctx) error { + if websocket.IsWebSocketUpgrade(c) { + c.Locals("allowed", true) + return c.Next() + } + return fiber.ErrUpgradeRequired +} + +func handleWebsocket(c *websocket.Conn) { + subscribers[c] = true + defer func() { + delete(subscribers, c) + c.Close() + }() + + for { + _, _, err := c.ReadMessage() + if err != nil { + return + } + } +} + +func publish(content fiber.Map) { + jsonMessage, err := json.Marshal(content) + if err != nil { + log.Println("Error marshalling JSON:", err) + return + } + + for s := range subscribers { + if err := s.WriteMessage(websocket.TextMessage, jsonMessage); err != nil { + log.Println("Error writing to websocket:", err) + s.Close() + } + } +} \ No newline at end of file diff --git a/frontend/src/lib/api/websocket.ts b/frontend/src/lib/api/websocket.ts new file mode 100644 index 0000000..a57ac7b --- /dev/null +++ b/frontend/src/lib/api/websocket.ts @@ -0,0 +1,62 @@ +import Project, { projects } from '$lib/types/Project'; +import { hasPendingRequests } from '$lib/utils/api'; +import { toastWarning } from '$lib/utils/toasts'; +import { get } from 'svelte/store'; + +let socket: WebSocket; + +export function connectWebSocket() { + socket = new WebSocket('ws://localhost:3000/api/v1/ws'); + + socket.onopen = () => { + console.log('WebSocket connected'); + }; + + socket.onclose = () => { + console.log('WebSocket disconnected'); + toastWarning( + 'WebSocket disconnected', + 'You may experience sync issues. You can try to reload the page.' + ); + + const reconnectTimer = setTimeout(() => { + connectWebSocket(); + clearTimeout(reconnectTimer); + }, 5000); + }; + + socket.onerror = (err) => { + console.error('WebSocket error:', err); + }; + + socket.onmessage = async (event) => { + const parsed = JSON.parse(event.data); + while (hasPendingRequests()) { + await new Promise((resolve) => setTimeout(resolve, 100)); + } + applyMessage(parsed); + }; +} + +function applyMessage(data: any) { + switch (data.object) { + case 'project': + applyProject(data); + break; + default: + console.log('Unknown object:', data); + } +} + +function applyProject(data: any) { + switch (data.action) { + case 'create': + if (get(projects).find((p) => p.id === data.id)) break; + case 'update': + Project.parse(data.value); + break; + case 'delete': + projects.set(get(projects).filter((p) => p.id !== data.id)); + break; + } +} diff --git a/frontend/src/lib/utils/api.ts b/frontend/src/lib/utils/api.ts index b658871..cedf4a8 100644 --- a/frontend/src/lib/utils/api.ts +++ b/frontend/src/lib/utils/api.ts @@ -1,26 +1,55 @@ import axios, { Axios, type AxiosResponse } from 'axios'; -import { toastAlert } from './toasts'; import { setupCache } from 'axios-cache-interceptor'; +import { toastAlert } from './toasts'; // import { env } from '$env/dynamic/public'; // const backend = env.PUBLIC_BACKEND_URL || 'http://localhost:3000'; -const backend = 'http://localhost:3000'; +const backendUrl = 'http://localhost:3000'; -export default setupCache( - new Axios({ - ...axios.defaults, - baseURL: backend + '/api', - validateStatus: () => true, - headers: { - 'Content-Type': 'application/json' - } - }), - { - interpretHeader: true, - modifiedSince: true +let pendingRequests = 0; + +export function hasPendingRequests() { + return pendingRequests > 0; +} + +const axiosInstance = new Axios({ + ...axios.defaults, + baseURL: backendUrl + '/api', + validateStatus: () => true, + headers: { + 'Content-Type': 'application/json' + } +}); + +const cachedInstance = setupCache(axiosInstance, { + interpretHeader: true, + modifiedSince: true +}); + +axiosInstance.interceptors.request.use( + (config) => { + pendingRequests++; + return config; + }, + (error) => { + pendingRequests--; + return Promise.reject(error); } ); +axiosInstance.interceptors.response.use( + (response) => { + pendingRequests--; + return response; + }, + (error) => { + pendingRequests--; + return Promise.reject(error); + } +); + +export default axiosInstance; + export function processError(response: AxiosResponse, message: string = '') { let title = `${response.status} ${response.statusText}`; let subtitle = message; diff --git a/frontend/src/lib/utils/toasts.ts b/frontend/src/lib/utils/toasts.ts index 6f81539..bb5b948 100644 --- a/frontend/src/lib/utils/toasts.ts +++ b/frontend/src/lib/utils/toasts.ts @@ -1,18 +1,29 @@ import { toast } from '@zerodevx/svelte-toast'; export function toastAlert(title: string, subtitle: string = '', persistant: boolean = false) { - toast.push( - `${title}
${subtitle}`, - { - theme: { - '--toastBackground': '#ff4d4f', - '--toastBarBackground': '#d32f2f', - '--toastColor': '#fff', - }, - initial: persistant ? 0 : 1, - next: 0, - duration: 10000, - pausable: true, - }, - ) -} \ No newline at end of file + toast.push(`${title}
${subtitle}`, { + theme: { + '--toastBackground': '#ff4d4f', + '--toastBarBackground': '#d32f2f', + '--toastColor': '#fff' + }, + initial: persistant ? 0 : 1, + next: 0, + duration: 10000, + pausable: true + }); +} + +export function toastWarning(title: string, subtitle: string = '', persistant: boolean = false) { + toast.push(`${title}
${subtitle}`, { + theme: { + '--toastBackground': '#faad14', + '--toastBarBackground': '#d48806', + '--toastColor': '#fff' + }, + initial: persistant ? 0 : 1, + next: 0, + duration: 5000, + pausable: true + }); +} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 9870045..07abb3d 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,5 +1,6 @@