mirror of
https://github.com/cpvalente/ontime.git
synced 2026-08-13 11:23:50 +00:00
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 <carlosvalente@pm.me>
This commit is contained in:
committed by
GitHub
parent
c8fe0bbcba
commit
31d47ed1e5
@@ -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<T extends MessageTag>(
|
||||
tag: T,
|
||||
payload: Pick<WsPacketToClient & { tag: T }, 'payload'>['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<T extends MessageTag>(tag: T, payload: Pick<WsPacketToClient & { tag: T }, 'payload'>['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 });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, OntimeEventReport>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ViewSettings>) {
|
||||
const views = getDataProvider().getViewSettings();
|
||||
res.status(200).send(views);
|
||||
}
|
||||
|
||||
export async function postViewSettings(req: Request, res: Response<ViewSettings | ErrorResponse>) {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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<ViewSettings>) => {
|
||||
const views = getDataProvider().getViewSettings();
|
||||
res.status(200).send(views);
|
||||
});
|
||||
|
||||
router.post('/', validateViewSettings, async (req: Request, res: Response<ViewSettings | ErrorResponse>) => {
|
||||
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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<string, ActionHandler> = {
|
||||
const actionHandlers: Record<ApiAction, ActionHandler> = {
|
||||
/* General */
|
||||
version: () => ({ payload: ONTIME_VERSION }),
|
||||
poll: () => ({
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<T extends keyof RuntimeStore>(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<RuntimeStore> = {};
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user