diff --git a/apps/client/src/common/api/constants.ts b/apps/client/src/common/api/constants.ts index a5968445c..fe9891d51 100644 --- a/apps/client/src/common/api/constants.ts +++ b/apps/client/src/common/api/constants.ts @@ -1,3 +1,5 @@ +import axios from 'axios'; + import { serverURL } from '../../externals'; // keys in tanstack store @@ -23,3 +25,7 @@ const cssOverridePath = 'styles/override.css'; export const overrideStylesURL = `${serverURL}/${userAssetsPath}/${cssOverridePath}`; export const projectLogoPath = `${serverURL}/${userAssetsPath}/logo`; + +axios.defaults.validateStatus = (status) => { + return (status >= 200 && status < 300) || status === 304; +}; diff --git a/apps/client/src/common/api/viewSettings.ts b/apps/client/src/common/api/viewSettings.ts index f835f29e7..7899b58f6 100644 --- a/apps/client/src/common/api/viewSettings.ts +++ b/apps/client/src/common/api/viewSettings.ts @@ -1,21 +1,15 @@ import axios from 'axios'; -import { ViewSettings } from 'ontime-types'; +import type { ViewSettings } from 'ontime-types'; import { apiEntryUrl } from './constants'; +const viewSettingsPath = apiEntryUrl + '/view-settings'; -const viewSettingsPath = `${apiEntryUrl}/view-settings`; - -/** - * HTTP request to retrieve view settings - */ -export async function getView(): Promise { +export async function getViewSettings() { const res = await axios.get(viewSettingsPath); return res.data; } -/** - * HTTP request to mutate view settings - */ export async function postViewSettings(data: ViewSettings) { - return axios.post(viewSettingsPath, data); + const res = await axios.post(viewSettingsPath, data); + return res.data as ViewSettings; } diff --git a/apps/client/src/common/hooks-query/useViewSettings.ts b/apps/client/src/common/hooks-query/useViewSettings.ts index 98cf5a316..8da03e1c0 100644 --- a/apps/client/src/common/hooks-query/useViewSettings.ts +++ b/apps/client/src/common/hooks-query/useViewSettings.ts @@ -1,20 +1,28 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery } from '@tanstack/react-query'; +import { MILLIS_PER_HOUR } from 'ontime-utils'; -import { queryRefetchIntervalSlow } from '../../ontimeConfig'; +import { getViewSettings, postViewSettings } from '../../common/api/viewSettings'; +import { ontimeQueryClient } from '../../common/queryClient'; import { VIEW_SETTINGS } from '../api/constants'; -import { getView } from '../api/viewSettings'; import { viewsSettingsPlaceholder } from '../models/ViewSettings.type'; export default function useViewSettings() { - const { data, status, isFetching, isError, refetch } = useQuery({ + const { data, isPending } = useQuery({ queryKey: VIEW_SETTINGS, - queryFn: getView, + queryFn: getViewSettings, placeholderData: (previousData, _previousQuery) => previousData, - retry: 5, - retryDelay: (attempt) => attempt * 2500, - refetchInterval: queryRefetchIntervalSlow, - networkMode: 'always', + staleTime: MILLIS_PER_HOUR, }); - return { data: data ?? viewsSettingsPlaceholder, status, isError, refetch, isFetching }; + const { mutateAsync } = useMutation({ + mutationFn: postViewSettings, + onMutate: () => { + ontimeQueryClient.cancelQueries({ queryKey: VIEW_SETTINGS }); + }, + onSuccess: (data) => { + ontimeQueryClient.setQueryData(VIEW_SETTINGS, data); + }, + }); + + return { data: data ?? viewsSettingsPlaceholder, mutateAsync, isPending }; } diff --git a/apps/client/src/common/hooks/useClientPath.ts b/apps/client/src/common/hooks/useClientPath.ts index 37acadf33..846d22750 100644 --- a/apps/client/src/common/hooks/useClientPath.ts +++ b/apps/client/src/common/hooks/useClientPath.ts @@ -1,9 +1,10 @@ import { useEffect } from 'react'; import { useLocation, useNavigate } from 'react-router-dom'; +import { MessageTag } from 'ontime-types'; import { useShallow } from 'zustand/shallow'; import { useClientStore } from '../stores/clientStore'; -import { socketSendJson } from '../utils/socket'; +import { sendSocket } from '../utils/socket'; import { useIsOnline } from './useSocket'; @@ -22,7 +23,7 @@ export const useClientPath = () => { useEffect(() => { if (!isOnline) return; - socketSendJson('set-client-path', pathname + search); + sendSocket(MessageTag.ClientSetPath, pathname + search); }, [pathname, search, isOnline]); // navigate to new path when received from server diff --git a/apps/client/src/common/hooks/useSocket.ts b/apps/client/src/common/hooks/useSocket.ts index 333faeb4d..d0f1636c2 100644 --- a/apps/client/src/common/hooks/useSocket.ts +++ b/apps/client/src/common/hooks/useSocket.ts @@ -1,7 +1,7 @@ import { OffsetMode, RuntimeStore, SimpleDirection, SimplePlayback, TimerMessage } from 'ontime-types'; import { useRuntimeStore } from '../stores/runtime'; -import { socketSendJson } from '../utils/socket'; +import { sendSocket } from '../utils/socket'; const createSelector = (selector: (state: RuntimeStore) => T) => @@ -9,9 +9,9 @@ const createSelector = useRuntimeStore(selector); export const setClientRemote = { - setIdentify: (payload: { target: string; identify: boolean }) => socketSendJson('client', payload), - setRedirect: (payload: { target: string; redirect: string }) => socketSendJson('client', payload), - setClientName: (payload: { target: string; rename: string }) => socketSendJson('client', payload), + setIdentify: (payload: { target: string; identify: boolean }) => sendSocket('client', payload), + setRedirect: (payload: { target: string; redirect: string }) => sendSocket('client', payload), + setClientName: (payload: { target: string; rename: string }) => sendSocket('client', payload), }; export const useRundownEditor = createSelector((state: RuntimeStore) => ({ @@ -53,13 +53,13 @@ export const useMessagePreview = createSelector((state: RuntimeStore) => ({ })); export const setMessage = { - timerText: (payload: string) => socketSendJson('message', { timer: { text: payload } }), - timerVisible: (payload: boolean) => socketSendJson('message', { timer: { visible: payload } }), - externalText: (payload: string) => socketSendJson('message', { external: payload }), - timerBlink: (payload: boolean) => socketSendJson('message', { timer: { blink: payload } }), - timerBlackout: (payload: boolean) => socketSendJson('message', { timer: { blackout: payload } }), + timerText: (payload: string) => sendSocket('message', { timer: { text: payload } }), + timerVisible: (payload: boolean) => sendSocket('message', { timer: { visible: payload } }), + externalText: (payload: string) => sendSocket('message', { external: payload }), + timerBlink: (payload: boolean) => sendSocket('message', { timer: { blink: payload } }), + timerBlackout: (payload: boolean) => sendSocket('message', { timer: { blackout: payload } }), timerSecondary: (payload: TimerMessage['secondarySource']) => - socketSendJson('message', { timer: { secondarySource: payload } }), + sendSocket('message', { timer: { secondarySource: payload } }), }; export const usePlaybackControl = createSelector((state: RuntimeStore) => ({ @@ -70,24 +70,24 @@ export const usePlaybackControl = createSelector((state: RuntimeStore) => ({ })); export const setPlayback = { - start: () => socketSendJson('start'), - pause: () => socketSendJson('pause'), - roll: () => socketSendJson('roll'), - startNext: () => socketSendJson('start', 'next'), + start: () => sendSocket('start', undefined), + pause: () => sendSocket('pause', undefined), + roll: () => sendSocket('roll', undefined), + startNext: () => sendSocket('start', 'next'), previous: () => { - socketSendJson('load', 'previous'); + sendSocket('load', 'previous'); }, next: () => { - socketSendJson('load', 'next'); + sendSocket('load', 'next'); }, stop: () => { - socketSendJson('stop'); + sendSocket('stop', undefined); }, reload: () => { - socketSendJson('reload'); + sendSocket('reload', undefined); }, addTime: (amount: number) => { - socketSendJson('addtime', amount); + sendSocket('addtime', amount); }, }; @@ -99,11 +99,11 @@ export const useAuxTimerControl = createSelector((state: RuntimeStore) => ({ })); export const setAuxTimer = { - start: () => socketSendJson('auxtimer', { '1': SimplePlayback.Start }), - pause: () => socketSendJson('auxtimer', { '1': SimplePlayback.Pause }), - stop: () => socketSendJson('auxtimer', { '1': SimplePlayback.Stop }), - setDirection: (direction: SimpleDirection) => socketSendJson('auxtimer', { '1': { direction } }), - setDuration: (time: number) => socketSendJson('auxtimer', { '1': { duration: time } }), + start: () => sendSocket('auxtimer', { '1': SimplePlayback.Start }), + pause: () => sendSocket('auxtimer', { '1': SimplePlayback.Pause }), + stop: () => sendSocket('auxtimer', { '1': SimplePlayback.Stop }), + setDirection: (direction: SimpleDirection) => sendSocket('auxtimer', { '1': { direction } }), + setDuration: (time: number) => sendSocket('auxtimer', { '1': { duration: time } }), }; export const useSelectedEventId = createSelector((state: RuntimeStore) => ({ @@ -115,10 +115,10 @@ export const useCurrentBlockId = createSelector((state: RuntimeStore) => ({ })); export const setEventPlayback = { - loadEvent: (id: string) => socketSendJson('load', { id }), - startEvent: (id: string) => socketSendJson('start', { id }), - start: () => socketSendJson('start'), - pause: () => socketSendJson('pause'), + loadEvent: (id: string) => sendSocket('load', { id }), + startEvent: (id: string) => sendSocket('start', { id }), + start: () => sendSocket('start', undefined), + pause: () => sendSocket('pause', undefined), }; export const useTimer = createSelector((state: RuntimeStore) => ({ @@ -190,7 +190,7 @@ export const useOffsetMode = createSelector((state: RuntimeStore) => ({ offsetMode: state.runtime.offsetMode, })); -export const setOffsetMode = (payload: OffsetMode) => socketSendJson('offsetmode', payload); +export const setOffsetMode = (payload: OffsetMode) => sendSocket('offsetmode', payload); export const usePlayback = () => { const featureSelector = (state: RuntimeStore) => ({ diff --git a/apps/client/src/common/queryClient.ts b/apps/client/src/common/queryClient.ts index c391abdfb..81970932f 100644 --- a/apps/client/src/common/queryClient.ts +++ b/apps/client/src/common/queryClient.ts @@ -1,11 +1,17 @@ import { QueryClient } from '@tanstack/react-query'; +import { MILLIS_PER_MINUTE } from 'ontime-utils'; import { isOntimeCloud } from '../externals'; export const ontimeQueryClient = new QueryClient({ defaultOptions: { queries: { - gcTime: 1000 * 60 * 10, // 10 min + gcTime: 10 * MILLIS_PER_MINUTE, + // staleTime: MILLIS_PER_HOUR, //TODO: when all routes have implemented refetch signal from server, we can the assume that the data is not stale until we get the signal + networkMode: 'always', + refetchOnWindowFocus: false, + retry: 5, + retryDelay: (attempt) => attempt * 2500, }, mutations: { /** diff --git a/apps/client/src/common/stores/logger.ts b/apps/client/src/common/stores/logger.ts index 3e919cf46..ee8f04832 100644 --- a/apps/client/src/common/stores/logger.ts +++ b/apps/client/src/common/stores/logger.ts @@ -1,10 +1,10 @@ import { useCallback } from 'react'; -import { Log, LogLevel, LogOrigin } from 'ontime-types'; +import { Log, LogLevel, LogOrigin, MessageTag } from 'ontime-types'; import { generateId, millisToString } from 'ontime-utils'; import { useStore } from 'zustand'; import { createStore } from 'zustand/vanilla'; -import { socketSendJson } from '../utils/socket'; +import { sendSocket } from '../utils/socket'; import { nowInMillis } from '../utils/time'; type LogStore = { @@ -40,7 +40,7 @@ export function useEmitLog() { text, }; - socketSendJson('ontime-log', log); + sendSocket(MessageTag.Log, log); }, []); /** diff --git a/apps/client/src/common/utils/socket.ts b/apps/client/src/common/utils/socket.ts index e6e809593..47cf099af 100644 --- a/apps/client/src/common/utils/socket.ts +++ b/apps/client/src/common/utils/socket.ts @@ -1,7 +1,16 @@ -import { Log, Rundown, RuntimeStore } from 'ontime-types'; +import { + ApiAction, + Log, + MessageTag, + RefetchKey, + Rundown, + RuntimeStore, + WsPacketToClient, + WsPacketToServer, +} from 'ontime-types'; import { isProduction, websocketUrl } from '../../externals'; -import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME } from '../api/constants'; +import { CLIENT_LIST, CUSTOM_FIELDS, REPORT, RUNDOWN, RUNTIME, VIEW_SETTINGS } from '../api/constants'; import { invalidateAllCaches } from '../api/utils'; import { ontimeQueryClient } from '../queryClient'; import { @@ -33,16 +42,14 @@ export const connectSocket = () => { hasConnected = true; reconnectAttempts = 0; - socketSendJson('set-client-patch', { + sendSocket(MessageTag.ClientSet, { type: 'ontime', origin: window.location.origin, path: window.location.pathname + window.location.search, + name: preferredClientName, }); + invalidateAllCaches(); // assume all data to be stale after a reconnect setOnlineStatus(true); - - if (preferredClientName) { - socketSendJson('set-client-name', preferredClientName); - } }; websocket.onclose = () => { @@ -65,56 +72,48 @@ export const connectSocket = () => { console.error('WebSocket error:', error); }; - websocket.onmessage = (event) => { + websocket.onmessage = async (event) => { try { - const data = JSON.parse(event.data); + const data = JSON.parse(event.data) as WsPacketToClient; - const { type, payload } = data; + const { tag, payload } = data; - if (!type) { + if (!tag) { return; } - switch (type) { - case 'pong': { + switch (tag) { + case MessageTag.Pong: { const offset = (new Date().getTime() - new Date(payload).getTime()) * 0.5; patchRuntimeProperty('ping', offset); updateDevTools({ ping: offset }); break; } - case 'client': { - if (typeof payload === 'object' || payload !== null) { - if (payload.clientId && payload.clientName) { - setClientId(payload.clientId); - if (!preferredClientName) { - setClientName(payload.clientName); - } - } + case MessageTag.ClientInit: { + setClientId(payload.clientId); + if (!preferredClientName) { + setClientName(payload.clientName); } break; } - case 'client-rename': { - if (typeof payload === 'object') { - const id = getClientId(); - if (payload.target && payload.target === id) { - setClientName(payload.name); - } + case MessageTag.ClientRename: { + const id = getClientId(); + if (payload.target === id) { + setClientName(payload.name); } break; } - case 'client-redirect': { - if (typeof payload === 'object') { - const id = getClientId(); - if (payload.target && payload.target === id) { - setClientRedirect(payload.path); - } + case MessageTag.ClientRedirect: { + const id = getClientId(); + if (payload.target === id) { + setClientRedirect(payload.path); } break; } - case 'client-list': { + case MessageTag.ClientList: { setClients(payload); if (!isProduction) { ontimeQueryClient.setQueryData(CLIENT_LIST, payload); @@ -122,50 +121,57 @@ export const connectSocket = () => { break; } - case 'dialog': { + case MessageTag.Dialog: { if (payload.dialog === 'welcome') { addDialog('welcome'); } break; } - case 'ontime-log': { + case MessageTag.Log: { addLog(payload as Log); break; } - case 'ontime': { + case MessageTag.RuntimeData: { // eslint-disable-next-line @typescript-eslint/no-unused-vars -- removing the key from the payload - const { ping, ...serverPayload } = payload as Partial; - + const { ping, ...serverPayload } = payload; patchRuntime(serverPayload); updateDevTools(serverPayload); break; } - case 'ontime-patch': { - const patch = payload as Partial; + case MessageTag.RuntimePatch: { + const patch = payload; patchRuntime(patch); updateDevTools(patch); break; } - case 'ontime-refetch': { + case MessageTag.Refetch: { // the refetch message signals that the rundown has changed in the server side - const { reload, target } = payload; - if (reload) { - invalidateAllCaches(); - } else if (target === 'RUNDOWN') { - const { revision } = payload; - const currentRevision = ontimeQueryClient.getQueryData(RUNDOWN)?.revision ?? -1; - if (revision > currentRevision) { + const { target, revision } = payload; + switch (target) { + case RefetchKey.All: + invalidateAllCaches(); + break; + case RefetchKey.Rundown: + if (revision === (ontimeQueryClient.getQueryData(RUNDOWN) as Rundown).revision) break; ontimeQueryClient.invalidateQueries({ queryKey: RUNDOWN }); ontimeQueryClient.invalidateQueries({ queryKey: CUSTOM_FIELDS }); + break; + case RefetchKey.ViewSettings: + ontimeQueryClient.invalidateQueries({ queryKey: VIEW_SETTINGS }); + break; + case RefetchKey.Report: + ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); + break; + default: { + target satisfies never; + break; } - } else if (target === 'REPORT') { - ontimeQueryClient.invalidateQueries({ queryKey: REPORT }); } break; } default: { - console.log('unknown WS message', type); + tag satisfies never; break; } } @@ -175,20 +181,14 @@ export const connectSocket = () => { }; }; -export const socketSend = (message: any) => { +export function sendSocket( + tag: T, + payload: T extends MessageTag ? Pick['payload'] : unknown, +): void { if (websocket && websocket.readyState === WebSocket.OPEN) { - websocket.send(message); + websocket.send(JSON.stringify({ tag, payload })); } -}; - -export const socketSendJson = (type: string, payload?: unknown) => { - socketSend( - JSON.stringify({ - type, - payload, - }), - ); -}; +} function updateDevTools(newData: Partial) { if (!isProduction) { diff --git a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx index c7fdcaa54..02ad6ea30 100644 --- a/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx +++ b/apps/client/src/features/app-settings/panel/general-panel/ViewSettingsForm.tsx @@ -4,7 +4,6 @@ import { Button, Input, Switch, useDisclosure } from '@chakra-ui/react'; import { ViewSettings } from 'ontime-types'; import { maybeAxiosError } from '../../../../common/api/utils'; -import { postViewSettings } from '../../../../common/api/viewSettings'; import Info from '../../../../common/components/info/Info'; import { SwatchPickerRHF } from '../../../../common/components/input/colour-input/SwatchPicker'; import ExternalLink from '../../../../common/components/link/external-link/ExternalLink'; @@ -19,17 +18,17 @@ import CodeEditorModal from './StyleEditorModal'; const cssOverrideDocsUrl = 'https://docs.getontime.no/features/custom-styling/'; export default function ViewSettingsForm() { - const { data, status, refetch } = useViewSettings(); + const { data, isPending, mutateAsync } = useViewSettings(); const { data: info, status: infoStatus } = useInfo(); const { isOpen: isCodeEditorOpen, onOpen: onCodeEditorOpen, onClose: onCodeEditorClose } = useDisclosure(); const { control, handleSubmit, + setError, register, reset, - setError, - formState: { isSubmitting, isDirty }, + formState: { isSubmitting, isDirty, errors }, } = useForm({ defaultValues: data, values: data, @@ -46,17 +45,11 @@ export default function ViewSettingsForm() { }, [data, reset]); const onSubmit = async (formData: ViewSettings) => { - const newData = { - ...formData, - }; - try { - await postViewSettings(newData); + mutateAsync(formData); } catch (error) { const message = maybeAxiosError(error); setError('root', { message }); - } finally { - await refetch(); } }; @@ -68,7 +61,7 @@ export default function ViewSettingsForm() { return null; } - const isLoading = status === 'pending' || infoStatus === 'pending'; + const isLoading = isPending || infoStatus === 'pending'; return ( + {errors.root?.message} diff --git a/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx b/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx index 0bc5e9d63..5cb0dbc7b 100644 --- a/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx +++ b/apps/client/src/features/app-settings/panel/network-panel/NetworkLogPanel.tsx @@ -1,8 +1,9 @@ import { useEffect } from 'react'; +import { MessageTag } from 'ontime-types'; import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; import { usePing } from '../../../../common/hooks/useSocket'; -import { socketSendJson } from '../../../../common/utils/socket'; +import { sendSocket } from '../../../../common/utils/socket'; import { isDockerImage, isOntimeCloud } from '../../../../externals'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; @@ -57,10 +58,10 @@ function OntimeCloudStats() { * Send immediate ping request, and keep sending on an interval */ useEffect(() => { - socketSendJson('ping', new Date()); + sendSocket(MessageTag.Ping, new Date()); const doPing = setInterval(() => { - socketSendJson('ping', new Date()); + sendSocket(MessageTag.Ping, new Date()); }, 5000); return () => { diff --git a/apps/client/src/features/rundown/RundownEntry.tsx b/apps/client/src/features/rundown/RundownEntry.tsx index 5947635b6..5934bb905 100644 --- a/apps/client/src/features/rundown/RundownEntry.tsx +++ b/apps/client/src/features/rundown/RundownEntry.tsx @@ -158,8 +158,10 @@ export default function RundownEntry(props: RundownEntryProps) { return emitError(`Unknown field: ${field}`); } - default: + default: { + action satisfies never; throw new Error(`Unhandled event ${action}`); + } } }); diff --git a/apps/client/src/features/viewers/common/viewUtils.ts b/apps/client/src/features/viewers/common/viewUtils.ts index 27379f357..8df9bd273 100644 --- a/apps/client/src/features/viewers/common/viewUtils.ts +++ b/apps/client/src/features/viewers/common/viewUtils.ts @@ -37,8 +37,10 @@ export function getTimerByType( return timerObject.clock; case TimerType.None: return null; - default: + default: { + viewTimerType satisfies never; return null; + } } } diff --git a/apps/server/src/adapters/WebsocketAdapter.ts b/apps/server/src/adapters/WebsocketAdapter.ts index fd8a91e1d..3b26ff103 100644 --- a/apps/server/src/adapters/WebsocketAdapter.ts +++ b/apps/server/src/adapters/WebsocketAdapter.ts @@ -6,7 +6,7 @@ * * Messages should be in JSON format with two top level objects * { - * type: ... + * tag: ... * payload: ... * } * @@ -14,7 +14,15 @@ * Payload: adds necessary payload for the request to be completed */ -import { Client, LogOrigin } from 'ontime-types'; +import { + Client, + LogOrigin, + WsPacketToClient, + WsPacketToServer, + MessageTag, + RefetchKey, + MaybeNumber, +} from 'ontime-types'; import { WebSocket, WebSocketServer } from 'ws'; import type { Server } from 'http'; @@ -60,6 +68,12 @@ class SocketServer implements IAdapter { }); const clientId = generateId(); const clientName = getRandomName(); + function sendPacket( + tag: T, + payload: Pick['payload'], + ) { + ws.send(JSON.stringify({ tag, payload })); + } this.clients.set(clientId, { type: 'unknown', @@ -72,25 +86,12 @@ class SocketServer implements IAdapter { this.lastConnection = new Date(); logger.info(LogOrigin.Client, `${this.clients.size} Connections with new: ${clientId}`); - ws.send( - JSON.stringify({ - type: 'client', - payload: { - clientId, - clientName, - }, - }), - ); + sendPacket(MessageTag.ClientInit, { clientId, clientName }); this.sendClientList(); // send store payload on connect - ws.send( - JSON.stringify({ - type: 'ontime', - payload: eventStore.poll(), - }), - ); + sendPacket(MessageTag.RuntimeData, eventStore.poll()); ws.on('error', console.error); @@ -102,104 +103,53 @@ class SocketServer implements IAdapter { ws.on('message', (data) => { try { - // @ts-expect-error -- this works fine - const message = JSON.parse(data); - const { type, payload } = message; + const message = JSON.parse(data.toString()) as WsPacketToServer; + const { tag, payload } = message; - if (type === 'ping') { - ws.send( - JSON.stringify({ - type: 'pong', - payload, - }), - ); - return; - } - - if (type === 'get-client-name') { - ws.send( - JSON.stringify({ - type: 'client-name', - payload: this.getOrCreateClient(clientId), - }), - ); - return; - } - - if (type === 'set-client-patch') { - if (payload && typeof payload == 'object') { - this.clients.set(clientId, { ...this.clients.get(clientId), ...payload }); + switch (tag) { + case MessageTag.Ping: { + sendPacket(MessageTag.Pong, payload); + break; } - this.sendClientList(); - return; - } - - if (type === 'set-client-type') { - if (payload && typeof payload == 'string') { + case MessageTag.ClientSet: { const previousData = this.getOrCreateClient(clientId); - this.clients.set(clientId, { ...previousData, type: payload }); + this.clients.set(clientId, { ...previousData, ...payload }); + this.sendClientList(); + break; } - this.sendClientList(); - return; - } - - if (type === 'set-client-path') { - if (payload && typeof payload == 'string') { + case MessageTag.ClientSetPath: { const previousData = this.getOrCreateClient(clientId); previousData.path = payload; this.clients.set(clientId, previousData); - if (payload.includes('editor') && this.shouldShowWelcome) { this.shouldShowWelcome = false; - ws.send( - JSON.stringify({ - type: 'dialog', - payload: { dialog: 'welcome' }, - }), - ); + sendPacket(MessageTag.Dialog, { dialog: 'welcome' }); } + this.sendClientList(); + break; } - - this.sendClientList(); - return; - } - - if (type === 'set-client-name') { - if (payload) { - const previousData = this.getOrCreateClient(clientId); - logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${payload}`); - this.clients.set(clientId, { ...previousData, name: payload }); - ws.send( - JSON.stringify({ - type: 'client-name', - payload: this.getOrCreateClient(clientId).name, - }), - ); - } - this.sendClientList(); - return; - } - - if (type === 'ontime-log') { - if (payload.level && payload.origin && payload.text) { + case MessageTag.Log: { logger.emit(payload.level, payload.origin, payload.text); + break; } - return; - } - - // Protocol specific stuff handled above - try { - const reply = dispatchFromAdapter(type, payload, 'ws'); - if (reply) { - ws.send( - JSON.stringify({ - type, - payload: reply.payload, - }), - ); + default: { + tag satisfies never; + // Protocol specific stuff handled above + try { + const reply = dispatchFromAdapter(tag, payload, 'ws'); + if (reply) { + ws.send( + JSON.stringify({ + type: tag, + payload: reply.payload, + }), + ); + } + } catch (error) { + logger.error(LogOrigin.Rx, `WS IN: ${error}`); + } + break; } - } catch (error) { - logger.error(LogOrigin.Rx, `WS IN: ${error}`); } } catch (_) { // we ignore unknown @@ -230,7 +180,7 @@ class SocketServer implements IAdapter { private sendClientList(): void { const payload = Object.fromEntries(this.clients.entries()); - this.sendAsJson({ type: 'client-list', payload }); + this.sendAsJson(MessageTag.ClientList, payload); } public getClientList(): string[] { @@ -244,10 +194,7 @@ class SocketServer implements IAdapter { } logger.info(LogOrigin.Client, `Client ${previousData.name} renamed to ${name}`); this.clients.set(target, { ...previousData, name }); - this.sendAsJson({ - type: 'client-rename', - payload: { name, target }, - }); + this.sendAsJson(MessageTag.ClientRename, { name, target }); this.sendClientList(); } @@ -256,7 +203,7 @@ class SocketServer implements IAdapter { if (!previousData) { throw new Error(`Client "${target}" not found`); } - this.sendAsJson({ type: 'client-redirect', payload: { target, path } }); + this.sendAsJson(MessageTag.ClientRedirect, { target, path }); } public identifyClient(target: string, identify: boolean) { @@ -269,9 +216,9 @@ class SocketServer implements IAdapter { } // message is any serializable value - public sendAsJson(message: unknown) { + public sendAsJson(tag: T, payload: Pick['payload']) { try { - const stringifiedMessage = JSON.stringify(message); + const stringifiedMessage = JSON.stringify({ tag, payload }); this.wss?.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(stringifiedMessage); @@ -288,3 +235,10 @@ class SocketServer implements IAdapter { } export const socket = new SocketServer(); + +/** + * Utility function to notify clients that the REST data is stale + */ +export function sendRefetch(target: RefetchKey, revision: MaybeNumber = null) { + socket.sendAsJson(MessageTag.Refetch, { target, revision }); +} diff --git a/apps/server/src/adapters/websocketAux.ts b/apps/server/src/adapters/websocketAux.ts deleted file mode 100644 index 4626e904b..000000000 --- a/apps/server/src/adapters/websocketAux.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { socket } from './WebsocketAdapter.js'; - -export enum RefetchTargets { - Rundown = 'rundown', - Report = 'report', -} - -/** - * Utility function to notify clients that the REST data is stale - * @param payload -- possible patch payload - */ -export function sendRefetch(payload: unknown = null) { - socket.sendAsJson({ - type: 'ontime-refetch', - payload, - }); -} diff --git a/apps/server/src/api-data/automation/automation.service.ts b/apps/server/src/api-data/automation/automation.service.ts index 000a810b7..87b0aec6d 100644 --- a/apps/server/src/api-data/automation/automation.service.ts +++ b/apps/server/src/api-data/automation/automation.service.ts @@ -115,8 +115,10 @@ export function testConditions( return typeof fieldValue === 'string' && fieldValue.includes(value); case 'not_contains': return typeof fieldValue === 'string' && !fieldValue.includes(value); - default: + default: { + operator satisfies never; return false; + } } } } diff --git a/apps/server/src/api-data/automation/clients/ontime.client.ts b/apps/server/src/api-data/automation/clients/ontime.client.ts index 2a2612f67..1c140bcae 100644 --- a/apps/server/src/api-data/automation/clients/ontime.client.ts +++ b/apps/server/src/api-data/automation/clients/ontime.client.ts @@ -5,7 +5,8 @@ import { auxTimerService } from '../../../services/aux-timer-service/AuxTimerSer import * as messageService from '../../../services/message-service/MessageService.js'; export function toOntimeAction(action: OntimeAction) { - switch (action.action) { + const actionType = action.action; + switch (actionType) { // Aux timer actions case 'aux-start': auxTimerService.start(); @@ -40,9 +41,10 @@ export function toOntimeAction(action: OntimeAction) { break; } - default: - // @ts-expect-error -- this guard checks that we handled all the cases, but we still want to log just in case - logger.warning(LogOrigin.Tx, `Unknown action type: ${action.type}`); + default: { + actionType satisfies never; + logger.warning(LogOrigin.Tx, `Unknown action type: ${actionType}`); break; + } } } diff --git a/apps/server/src/api-data/report/report.service.ts b/apps/server/src/api-data/report/report.service.ts index 0b3d358a3..62e860c28 100644 --- a/apps/server/src/api-data/report/report.service.ts +++ b/apps/server/src/api-data/report/report.service.ts @@ -1,7 +1,7 @@ -import { OntimeReport, OntimeEventReport, TimerLifeCycle } from 'ontime-types'; +import { OntimeReport, OntimeEventReport, TimerLifeCycle, RefetchKey } from 'ontime-types'; import { RuntimeState } from '../../stores/runtimeState.js'; -import { RefetchTargets, sendRefetch } from '../../adapters/websocketAux.js'; import { DeepReadonly } from 'ts-essentials'; +import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; const report = new Map(); @@ -57,8 +57,6 @@ export function triggerReportEntry( const startedAt = report.get(eventId)?.startedAt ?? null; report.set(eventId, { startedAt, endedAt: state.clock }); formattedReport = null; - sendRefetch({ - target: RefetchTargets.Report, - }); + sendRefetch(RefetchKey.Report); } } diff --git a/apps/server/src/api-data/rundown/rundown.service.ts b/apps/server/src/api-data/rundown/rundown.service.ts index af8a65ca9..7fd4e6cf7 100644 --- a/apps/server/src/api-data/rundown/rundown.service.ts +++ b/apps/server/src/api-data/rundown/rundown.service.ts @@ -10,17 +10,18 @@ import { OntimeEntry, OntimeEvent, PatchWithId, + RefetchKey, Rundown, } from 'ontime-types'; import { customFieldLabelToKey } from 'ontime-utils'; import { updateRundownData } from '../../stores/runtimeState.js'; -import { sendRefetch } from '../../adapters/websocketAux.js'; import { runtimeService } from '../../services/runtime-service/RuntimeService.js'; import { createTransaction, customFieldMutation, rundownCache, rundownMutation } from './rundown.dao.js'; import type { RundownMetadata } from './rundown.types.js'; import { generateEvent, getInsertAfterId, hasChanges } from './rundown.utils.js'; +import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; /** * creates a new entry with given data @@ -553,13 +554,10 @@ export function notifyChanges(rundownMetadata: RundownMetadata, revision: number } // notify external services of changes - if (options.external) { - const payload = { - target: 'RUNDOWN', - reload: options.reload, - revision, - }; - sendRefetch(payload); + if (options.reload) { + sendRefetch(RefetchKey.All); + } else if (options.external) { + sendRefetch(RefetchKey.Rundown, revision); } } diff --git a/apps/server/src/api-data/view-settings/viewSettings.controller.ts b/apps/server/src/api-data/view-settings/viewSettings.controller.ts deleted file mode 100644 index 3f4a1d219..000000000 --- a/apps/server/src/api-data/view-settings/viewSettings.controller.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { ErrorResponse, ViewSettings } from 'ontime-types'; -import { getErrorMessage } from 'ontime-utils'; - -import type { Request, Response } from 'express'; - -import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; - -export async function getViewSettings(_req: Request, res: Response) { - const views = getDataProvider().getViewSettings(); - res.status(200).send(views); -} - -export async function postViewSettings(req: Request, res: Response) { - try { - const newData = { - dangerColor: req.body.dangerColor, - endMessage: req.body.endMessage, - freezeEnd: req.body.freezeEnd, - normalColor: req.body.normalColor, - overrideStyles: req.body.overrideStyles, - warningColor: req.body.warningColor, - } as ViewSettings; - await getDataProvider().setViewSettings(newData); - res.status(200).send(newData); - } catch (error) { - const message = getErrorMessage(error); - res.status(400).send({ message }); - } -} diff --git a/apps/server/src/api-data/view-settings/viewSettings.router.ts b/apps/server/src/api-data/view-settings/viewSettings.router.ts index c1077a4e2..1a0f4f55a 100644 --- a/apps/server/src/api-data/view-settings/viewSettings.router.ts +++ b/apps/server/src/api-data/view-settings/viewSettings.router.ts @@ -1,9 +1,36 @@ import express from 'express'; +import type { Request, Response } from 'express'; +import { RefetchKey, type ErrorResponse, type ViewSettings } from 'ontime-types'; +import { getErrorMessage } from 'ontime-utils'; import { validateViewSettings } from './viewSettings.validation.js'; -import { getViewSettings, postViewSettings } from './viewSettings.controller.js'; +import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; +import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; export const router = express.Router(); -router.get('/', getViewSettings); -router.post('/', validateViewSettings, postViewSettings); +router.get('/', (_req: Request, res: Response) => { + const views = getDataProvider().getViewSettings(); + res.status(200).send(views); +}); + +router.post('/', validateViewSettings, async (req: Request, res: Response) => { + try { + const newData = { + dangerColor: req.body.dangerColor, + endMessage: req.body.endMessage, + freezeEnd: req.body.freezeEnd, + normalColor: req.body.normalColor, + overrideStyles: req.body.overrideStyles, + warningColor: req.body.warningColor, + } as ViewSettings; + await getDataProvider().setViewSettings(newData); + res.status(200).send(newData); + setImmediate(() => { + sendRefetch(RefetchKey.ViewSettings); + }); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).send({ message }); + } +}); diff --git a/apps/server/src/api-integration/integration.controller.ts b/apps/server/src/api-integration/integration.controller.ts index 306c3e0bf..4d78d6757 100644 --- a/apps/server/src/api-integration/integration.controller.ts +++ b/apps/server/src/api-integration/integration.controller.ts @@ -1,4 +1,12 @@ -import { MessageState, OffsetMode, OntimeEvent, PatchWithId, SimpleDirection, SimplePlayback } from 'ontime-types'; +import { + ApiAction, + MessageState, + OffsetMode, + OntimeEvent, + PatchWithId, + SimpleDirection, + SimplePlayback, +} from 'ontime-types'; import { MILLIS_PER_HOUR } from 'ontime-utils'; import { DeepPartial } from 'ts-essentials'; @@ -21,15 +29,15 @@ import { willCauseRegeneration } from '../api-data/rundown/rundown.utils.js'; const throttledEditEvent = throttle(editEntry, 20); let lastRequest: Date | null = null; -export function dispatchFromAdapter(type: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') { - const action = type.toLowerCase(); - const handler = actionHandlers[action]; +export function dispatchFromAdapter(tag: string, payload: unknown, _source?: 'osc' | 'ws' | 'http') { + const action = tag.toLowerCase(); + const handler = actionHandlers[action as ApiAction]; lastRequest = new Date(); if (handler) { return handler(payload); } else { - throw new Error(`Unhandled message ${type}`); + throw new Error(`Unhandled message ${tag}`); } } @@ -39,7 +47,7 @@ export function getLastRequest() { type ActionHandler = (payload: unknown) => { payload: unknown }; -const actionHandlers: Record = { +const actionHandlers: Record = { /* General */ version: () => ({ payload: ONTIME_VERSION }), poll: () => ({ diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 13c6acb26..9d77e3258 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -73,6 +73,7 @@ if (!isProduction) { app.use(serverTiming()); } app.disable('x-powered-by'); +app.enable('etag'); // Implement middleware app.use(cors()); // setup cors for all routes @@ -88,12 +89,12 @@ app.use(`${prefix}/data`, authenticate, appRouter); // router for application da app.use(`${prefix}/api`, authenticate, integrationRouter); // router for integrations // serve static external files -app.use(`${prefix}/external`, express.static(publicDir.externalDir)); +app.use(`${prefix}/external`, express.static(publicDir.externalDir, { etag: false, lastModified: true })); app.use(`${prefix}/external`, (req, res) => { // if the user reaches to the root, we show a 404 res.status(404).send(`${req.originalUrl} not found`); }); -app.use(`${prefix}/user`, express.static(publicDir.userDir)); +app.use(`${prefix}/user`, express.static(publicDir.userDir, { etag: false, lastModified: true })); // Base route for static files app.use(`${prefix}`, authenticateAndRedirect, compressedStatic); diff --git a/apps/server/src/classes/Logger.ts b/apps/server/src/classes/Logger.ts index 43cde5c5b..8d5aae686 100644 --- a/apps/server/src/classes/Logger.ts +++ b/apps/server/src/classes/Logger.ts @@ -1,4 +1,4 @@ -import { Log, LogLevel } from 'ontime-types'; +import { Log, LogLevel, MessageTag } from 'ontime-types'; import { generateId, millisToString } from 'ontime-utils'; import { socket } from '../adapters/WebsocketAdapter.js'; @@ -54,10 +54,7 @@ class Logger { } try { - socket.sendAsJson({ - type: 'ontime-log', - payload: log, - }); + socket.sendAsJson(MessageTag.Log, log); } catch (_e) { this.addToQueue(log); } diff --git a/apps/server/src/stores/EventStore.ts b/apps/server/src/stores/EventStore.ts index 8ffc2abd1..fa58a8ecd 100644 --- a/apps/server/src/stores/EventStore.ts +++ b/apps/server/src/stores/EventStore.ts @@ -1,4 +1,4 @@ -import { RuntimeStore } from 'ontime-types'; +import { RuntimeStore, MessageTag } from 'ontime-types'; import { socket } from '../adapters/WebsocketAdapter.js'; import { isEmptyObject } from '../utils/parserUtils.js'; @@ -23,7 +23,7 @@ export const eventStore = { }, set(key: T, value: RuntimeStore[T]) { store[key] = value; - socket.sendAsJson({ type: 'ontime-patch', payload: { [key]: value } }); + socket.sendAsJson(MessageTag.RuntimePatch, { [key]: value }); }, createBatch() { const patch: Partial = {}; @@ -34,7 +34,7 @@ export const eventStore = { send() { if (isEmptyObject(patch)) return; store = { ...store, ...patch }; - socket.sendAsJson({ type: 'ontime-patch', payload: patch }); + socket.sendAsJson(MessageTag.RuntimePatch, patch); }, }; }, @@ -42,9 +42,9 @@ export const eventStore = { return store as RuntimeStore; }, broadcast() { - socket.sendAsJson({ - type: 'ontime', - payload: store, - }); + socket.sendAsJson( + MessageTag.RuntimeData, + store as RuntimeStore, // We assume that it has been initialized at this point + ); }, }; diff --git a/packages/types/src/api/websocket/api.type.ts b/packages/types/src/api/websocket/api.type.ts new file mode 100644 index 000000000..f6a509d8d --- /dev/null +++ b/packages/types/src/api/websocket/api.type.ts @@ -0,0 +1,15 @@ +export type ApiAction = + | 'version' + | 'poll' + | 'change' + | 'message' + | 'start' + | 'pause' + | 'stop' + | 'reload' + | 'roll' + | 'load' + | 'addtime' + | 'auxtimer' + | 'client' + | 'offsetmode'; diff --git a/packages/types/src/api/websocket/data.type.ts b/packages/types/src/api/websocket/data.type.ts new file mode 100644 index 000000000..8c8a1ff3e --- /dev/null +++ b/packages/types/src/api/websocket/data.type.ts @@ -0,0 +1,63 @@ +import type { Client } from '../../definitions/Clients.type.js'; +import type { Log } from '../../definitions/runtime/Logger.type.js'; +import type { RuntimeStore } from '../../definitions/runtime/RuntimeStore.type.js'; +import type { MaybeNumber } from '../../utils/utils.type.js'; +import type { RefetchKey } from './refetch.type.js'; + +export enum MessageTag { + Ping = 'ping', + Pong = 'pong', + ClientInit = 'client-init', + ClientSet = 'client-set', + ClientSetPath = 'client-set-path', + ClientRename = 'client-rename', + ClientRedirect = 'client-redirect', + ClientList = 'client-list', + Dialog = 'dialog', + Log = 'log', + RuntimeData = 'runtime-data', + RuntimePatch = 'runtime-patch', + Refetch = 'refetch', +} + +//CLIENT TO SERVER +type PingPacket = { tag: MessageTag.Ping; payload: Date }; +type SetClientPacket = { tag: MessageTag.ClientSetPath; payload: string }; +type SetClientPathPacket = { tag: MessageTag.ClientSet; payload: Partial }; + +// SERVER TO CLIENT +type PongPacket = { tag: MessageTag.Pong; payload: Date }; +type InitClientPacket = { tag: MessageTag.ClientInit; payload: { clientId: string; clientName: string } }; +type RenameClientPacket = { tag: MessageTag.ClientRename; payload: { target: string; name: string } }; +type RedirectClientPacket = { tag: MessageTag.ClientRedirect; payload: { target: string; path: string } }; +type DialogPacket = { tag: MessageTag.Dialog; payload: { dialog: string } }; +type ListClientPacket = { + tag: MessageTag.ClientList; + payload: Record; +}; +type RuntimePacket = { tag: MessageTag.RuntimeData; payload: RuntimeStore }; +type RuntimePatchPacket = { tag: MessageTag.RuntimePatch; payload: Partial }; + +type RefetchPacket = { + tag: MessageTag.Refetch; + payload: { + target: RefetchKey; + revision: MaybeNumber; + }; +}; + +// SHARED +type LogPacket = { tag: MessageTag.Log; payload: Log }; + +export type WsPacketToServer = PingPacket | SetClientPacket | SetClientPathPacket | LogPacket; +export type WsPacketToClient = + | PongPacket + | InitClientPacket + | RenameClientPacket + | RedirectClientPacket + | DialogPacket + | LogPacket + | ListClientPacket + | RuntimePacket + | RuntimePatchPacket + | RefetchPacket; diff --git a/packages/types/src/api/websocket/refetch.type.ts b/packages/types/src/api/websocket/refetch.type.ts new file mode 100644 index 000000000..1e08f6f44 --- /dev/null +++ b/packages/types/src/api/websocket/refetch.type.ts @@ -0,0 +1,6 @@ +export enum RefetchKey { + All = 'all', + Report = 'report', + Rundown = 'rundown', + ViewSettings = 'view-settings', +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e8a6d4ad6..0629ffc81 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -85,6 +85,11 @@ export type { TransientEventPayload, } from './api/rundown-controller/BackendResponse.type.js'; +// web socket +export { MessageTag } from './api/websocket/data.type.js'; +export type { WsPacketToServer, WsPacketToClient } from './api/websocket/data.type.js'; +export { RefetchKey } from './api/websocket/refetch.type.js'; +export type { ApiAction } from './api/websocket/api.type.js'; // SERVER RUNTIME export { type Log, LogLevel, type LogMessage, LogOrigin } from './definitions/runtime/Logger.type.js'; export { Playback } from './definitions/runtime/Playback.type.js';