Websocket for real-time updates of projects

This commit is contained in:
Brieuc Dubois 2024-01-12 02:52:36 +01:00
parent fbff4a2466
commit f61b30e3cc
9 changed files with 238 additions and 32 deletions

View File

@ -6,6 +6,8 @@ require github.com/gofiber/fiber/v2 v2.51.0
require ( require (
github.com/andybalholm/brotli v1.0.5 // indirect 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/google/uuid v1.4.0 // indirect
github.com/klauspost/compress v1.16.7 // indirect github.com/klauspost/compress v1.16.7 // indirect
github.com/mattn/go-colorable v0.1.13 // 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/mattn/go-runewidth v0.0.15 // indirect
github.com/philhofer/fwd v1.1.2 // indirect github.com/philhofer/fwd v1.1.2 // indirect
github.com/rivo/uniseg v0.2.0 // 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/tinylib/msgp v1.1.8 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.50.0 // indirect github.com/valyala/fasthttp v1.50.0 // indirect

View File

@ -1,7 +1,11 @@
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= 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 h1:JNACcZy5e2tGApWB2QrRpenTWn0fq0hkFm6k0C86gKQ=
github.com/gofiber/fiber/v2 v2.51.0/go.mod h1:xaQRZQJGqnKOQnbQw+ltvku3/h8QxvNi8o6JiJ7Ll0U= 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 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= 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/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 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 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 h1:FCXC1xanKO4I8plpHGH2P7koL/RzZs12l/+r7vakfm0=
github.com/tinylib/msgp v1.1.8/go.mod h1:qkpG+2ldGg4xRFmx+jfTvZPxfGFhi64BcnL9vkCm/Tw= 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= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=

View File

@ -12,6 +12,7 @@ func v1Router(router fiber.Router) error {
tagsRouter(router.Group("/tags")) tagsRouter(router.Group("/tags"))
viewsRouter(router.Group("/views")) viewsRouter(router.Group("/views"))
filtersRouter(router.Group("/filters")) filtersRouter(router.Group("/filters"))
wsRouter(router.Group("/ws"))
return nil return nil
} }

View File

@ -83,9 +83,22 @@ func CreateProject(c *fiber.Ctx) error {
projectsLastEdit = time.Now().Truncate(time.Second); 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, "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 { func UpdateProject(c *fiber.Ctx) error {
@ -113,7 +126,18 @@ func UpdateProject(c *fiber.Ctx) error {
projectsLastEdit = time.Now().Truncate(time.Second); 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 { func DeleteProject(c *fiber.Ctx) error {
@ -136,7 +160,17 @@ func DeleteProject(c *fiber.Ctx) error {
projectsLastEdit = time.Now().Truncate(time.Second); 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 { func GetProjectCards(c *fiber.Ctx) error {

57
backend/handlers/ws.go Normal file
View File

@ -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()
}
}
}

View File

@ -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;
}
}

View File

@ -1,26 +1,55 @@
import axios, { Axios, type AxiosResponse } from 'axios'; import axios, { Axios, type AxiosResponse } from 'axios';
import { toastAlert } from './toasts';
import { setupCache } from 'axios-cache-interceptor'; import { setupCache } from 'axios-cache-interceptor';
import { toastAlert } from './toasts';
// import { env } from '$env/dynamic/public'; // import { env } from '$env/dynamic/public';
// const backend = env.PUBLIC_BACKEND_URL || 'http://localhost:3000'; // const backend = env.PUBLIC_BACKEND_URL || 'http://localhost:3000';
const backend = 'http://localhost:3000'; const backendUrl = 'http://localhost:3000';
export default setupCache( let pendingRequests = 0;
new Axios({
export function hasPendingRequests() {
return pendingRequests > 0;
}
const axiosInstance = new Axios({
...axios.defaults, ...axios.defaults,
baseURL: backend + '/api', baseURL: backendUrl + '/api',
validateStatus: () => true, validateStatus: () => true,
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}), });
{
const cachedInstance = setupCache(axiosInstance, {
interpretHeader: true, interpretHeader: true,
modifiedSince: 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<any, any>, message: string = '') { export function processError(response: AxiosResponse<any, any>, message: string = '') {
let title = `${response.status} ${response.statusText}`; let title = `${response.status} ${response.statusText}`;
let subtitle = message; let subtitle = message;

View File

@ -1,18 +1,29 @@
import { toast } from '@zerodevx/svelte-toast'; import { toast } from '@zerodevx/svelte-toast';
export function toastAlert(title: string, subtitle: string = '', persistant: boolean = false) { export function toastAlert(title: string, subtitle: string = '', persistant: boolean = false) {
toast.push( toast.push(`<strong>${title}</strong><br>${subtitle}`, {
`<strong>${title}</strong><br>${subtitle}`,
{
theme: { theme: {
'--toastBackground': '#ff4d4f', '--toastBackground': '#ff4d4f',
'--toastBarBackground': '#d32f2f', '--toastBarBackground': '#d32f2f',
'--toastColor': '#fff', '--toastColor': '#fff'
}, },
initial: persistant ? 0 : 1, initial: persistant ? 0 : 1,
next: 0, next: 0,
duration: 10000, duration: 10000,
pausable: true, pausable: true
}, });
) }
export function toastWarning(title: string, subtitle: string = '', persistant: boolean = false) {
toast.push(`<strong>${title}</strong><br>${subtitle}`, {
theme: {
'--toastBackground': '#faad14',
'--toastBarBackground': '#d48806',
'--toastColor': '#fff'
},
initial: persistant ? 0 : 1,
next: 0,
duration: 5000,
pausable: true
});
} }

View File

@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import projectsApi from '$lib/api/projectsApi'; import projectsApi from '$lib/api/projectsApi';
import { connectWebSocket } from '$lib/api/websocket';
import Project, { projects } from '$lib/types/Project'; import Project, { projects } from '$lib/types/Project';
import { SvelteToast } from '@zerodevx/svelte-toast'; import { SvelteToast } from '@zerodevx/svelte-toast';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
@ -8,6 +9,8 @@
onMount(async () => { onMount(async () => {
await projectsApi.getAll(); await projectsApi.getAll();
}); });
connectWebSocket();
</script> </script>
<section> <section>