From 31d47ed1e5d9276d656ccc10ef6124b3fcb52982 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Sat, 21 Jun 2025 12:18:59 +0200 Subject: [PATCH] refactor: WS (#1622) * refactor: WS * refactor refetch extract refetch keys to package/types auto invalidata all keys use refetch keys for project data no need for constant update of info, just fetch when looking at it split refetch and query keys rename types * refactor viewSettings * fixup! refactor: WS * rearange files and make types for api calls * cleanup viewsettings * switch exhaustiveCheck * combine send socket function * exhaustive check * rename type to tag * fixup! fixup! refactor: WS * remove custom etag solution * get errors from socket * lint * small change --------- Co-authored-by: Carlos Valente --- apps/client/src/common/api/constants.ts | 6 + apps/client/src/common/api/viewSettings.ts | 16 +- .../src/common/hooks-query/useViewSettings.ts | 28 ++- apps/client/src/common/hooks/useClientPath.ts | 5 +- apps/client/src/common/hooks/useSocket.ts | 58 +++--- apps/client/src/common/queryClient.ts | 8 +- apps/client/src/common/stores/logger.ts | 6 +- apps/client/src/common/utils/socket.ts | 128 ++++++------- .../panel/general-panel/ViewSettingsForm.tsx | 18 +- .../panel/network-panel/NetworkLogPanel.tsx | 7 +- .../src/features/rundown/RundownEntry.tsx | 4 +- .../src/features/viewers/common/viewUtils.ts | 4 +- apps/server/src/adapters/WebsocketAdapter.ts | 172 +++++++----------- apps/server/src/adapters/websocketAux.ts | 17 -- .../api-data/automation/automation.service.ts | 4 +- .../automation/clients/ontime.client.ts | 10 +- .../src/api-data/report/report.service.ts | 8 +- .../src/api-data/rundown/rundown.service.ts | 14 +- .../view-settings/viewSettings.controller.ts | 29 --- .../view-settings/viewSettings.router.ts | 33 +++- .../api-integration/integration.controller.ts | 20 +- apps/server/src/app.ts | 5 +- apps/server/src/classes/Logger.ts | 7 +- apps/server/src/stores/EventStore.ts | 14 +- packages/types/src/api/websocket/api.type.ts | 15 ++ packages/types/src/api/websocket/data.type.ts | 63 +++++++ .../types/src/api/websocket/refetch.type.ts | 6 + packages/types/src/index.ts | 5 + 28 files changed, 377 insertions(+), 333 deletions(-) delete mode 100644 apps/server/src/adapters/websocketAux.ts delete mode 100644 apps/server/src/api-data/view-settings/viewSettings.controller.ts create mode 100644 packages/types/src/api/websocket/api.type.ts create mode 100644 packages/types/src/api/websocket/data.type.ts create mode 100644 packages/types/src/api/websocket/refetch.type.ts 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';