From 2586b0b10c6962abad31b734cd329a5aa7262300 Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Sun, 10 Dec 2023 16:56:42 +0100 Subject: [PATCH] feat: http integration (#575) * addtime endpoint * create universal Subscription * add http integration * catch error from HTTP integration emit * unify osc and http validateSubscriptionEntry * add necessary endpoint for http subscription * make subscription part of modal generic * add http subscription to integration modal * remove log * Revert "addtime endpoint" This reverts commit 4c039220dcf869a2d394a20da06c1effcc45811e. * reuse agent and test url compatibility * simplify retun path * add todo in UI * test for http protocol * import not needed yet * lint * fix merge * lint * fix httpPlaceholder * wip: prepare endpoints * wip: temporary fix to get form to work * disable HTTP integration if enabledOut==false * register/unregister http * refactor: subscription types and form register * refactor: validation * cleanup * cleanup * try GOT * allow https * split url and searchParams allow for post option * add options to post * add retry count * fix test * Revert "fix test" This reverts commit 927e88370fa3088320a287946b5f3c4b075e62fc. * Revert "add options to post" This reverts commit 0523a68ef4f0f529c936be113f295dfba23af32f. * Revert "split url and searchParams allow for post option" This reverts commit 54ab8d4ffec36acaaf16caaca3398136240107b0. * missing retryCount in httpPlaceholder * remove global this * remove retry count * remove https --------- Co-authored-by: Carlos Valente --- apps/client/src/common/api/apiConstants.ts | 1 + apps/client/src/common/api/ontimeApi.ts | 18 ++ .../src/common/hooks-query/useHttpSettings.ts | 33 ++++ apps/client/src/common/models/Http.ts | 35 ++-- .../src/common/utils/__tests__/regex.test.ts | 14 +- apps/client/src/common/utils/regex.ts | 1 + .../integration-modal/IntegrationModal.tsx | 9 +- .../http/HttpIntegration.tsx | 152 ++++++++++++++++ .../http/HttpSubscriptionRow.tsx | 95 ++++++++++ .../integration-modal/integration.utils.ts | 30 ++++ .../{ => osc}/OscIntegration.tsx | 47 ++--- .../{ => osc}/OscSettings.tsx | 14 +- .../{ => osc}/OscSubscriptionRow.tsx | 13 +- apps/server/package.json | 1 + apps/server/src/app.ts | 28 ++- .../src/classes/data-provider/DataProvider.ts | 9 + .../src/controllers/ontimeController.ts | 78 ++++++--- .../controllers/ontimeController.validate.ts | 35 +++- apps/server/src/models/dataModel.ts | 11 ++ apps/server/src/routes/ontimeRouter.ts | 11 +- .../integration-service/HttpIntegration.ts | 99 +++++++++++ .../integration-service/IIntegration.ts | 6 +- .../integration-service/IntegrationService.ts | 6 +- .../integration-service/OscIntegration.ts | 8 +- .../utils/__tests__/parserFunctions.test.js | 91 ---------- .../utils/__tests__/parserFunctions.test.ts | 162 ++++++++++++++++++ apps/server/src/utils/parser.ts | 3 +- apps/server/src/utils/parserFunctions.ts | 84 +++++++-- apps/server/test-db/db.json | 11 ++ apps/test-db/db.json | 25 ++- demo-db/db.json | 11 ++ .../types/src/definitions/DataModel.type.ts | 2 + .../src/definitions/core/HttpSettings.type.ts | 9 + .../src/definitions/core/OscSettings.type.ts | 4 +- .../src/definitions/core/Subscription.type.ts | 3 + packages/types/src/index.ts | 4 + pnpm-lock.yaml | 122 ++++++++++--- 37 files changed, 1021 insertions(+), 264 deletions(-) create mode 100644 apps/client/src/common/hooks-query/useHttpSettings.ts create mode 100644 apps/client/src/features/modals/integration-modal/http/HttpIntegration.tsx create mode 100644 apps/client/src/features/modals/integration-modal/http/HttpSubscriptionRow.tsx create mode 100644 apps/client/src/features/modals/integration-modal/integration.utils.ts rename apps/client/src/features/modals/integration-modal/{ => osc}/OscIntegration.tsx (78%) rename apps/client/src/features/modals/integration-modal/{ => osc}/OscSettings.tsx (92%) rename apps/client/src/features/modals/integration-modal/{ => osc}/OscSubscriptionRow.tsx (87%) create mode 100644 apps/server/src/services/integration-service/HttpIntegration.ts delete mode 100644 apps/server/src/utils/__tests__/parserFunctions.test.js create mode 100644 apps/server/src/utils/__tests__/parserFunctions.test.ts create mode 100644 packages/types/src/definitions/core/HttpSettings.type.ts create mode 100644 packages/types/src/definitions/core/Subscription.type.ts diff --git a/apps/client/src/common/api/apiConstants.ts b/apps/client/src/common/api/apiConstants.ts index 9959d7275..4ad3a12f3 100644 --- a/apps/client/src/common/api/apiConstants.ts +++ b/apps/client/src/common/api/apiConstants.ts @@ -5,6 +5,7 @@ export const USERFIELDS = ['userFields']; export const RUNDOWN = ['rundown']; export const APP_INFO = ['appinfo']; export const OSC_SETTINGS = ['oscSettings']; +export const HTTP_SETTINGS = ['httpSettings']; export const APP_SETTINGS = ['appSettings']; export const VIEW_SETTINGS = ['viewSettings']; export const RUNTIME = ['runtimeStore']; diff --git a/apps/client/src/common/api/ontimeApi.ts b/apps/client/src/common/api/ontimeApi.ts index 05c787d9d..5c45a0b2e 100644 --- a/apps/client/src/common/api/ontimeApi.ts +++ b/apps/client/src/common/api/ontimeApi.ts @@ -3,6 +3,7 @@ import { Alias, DatabaseModel, GetInfo, + HttpSettings, OntimeRundown, OSCSettings, OscSubscription, @@ -104,6 +105,23 @@ export async function getOSC(): Promise { return res.data; } +/** + * @description HTTP request to retrieve http settings + * @return {Promise} + */ +export async function getHTTP(): Promise { + const res = await axios.get(`${ontimeURL}/http`); + return res.data; +} + +/** + * @description HTTP request to mutate http settings + * @return {Promise} + */ +export async function postHTTP(data: HttpSettings) { + return axios.post(`${ontimeURL}/http`, data); +} + /** * @description HTTP request to mutate osc settings * @return {Promise} diff --git a/apps/client/src/common/hooks-query/useHttpSettings.ts b/apps/client/src/common/hooks-query/useHttpSettings.ts new file mode 100644 index 000000000..6435eb4fb --- /dev/null +++ b/apps/client/src/common/hooks-query/useHttpSettings.ts @@ -0,0 +1,33 @@ +import { useMutation, useQuery } from '@tanstack/react-query'; +import { HttpSettings } from 'ontime-types'; + +import { queryRefetchIntervalSlow } from '../../ontimeConfig'; +import { HTTP_SETTINGS } from '../api/apiConstants'; +import { logAxiosError } from '../api/apiUtils'; +import { getHTTP, postHTTP } from '../api/ontimeApi'; +import { httpPlaceholder } from '../models/Http'; +import { ontimeQueryClient } from '../queryClient'; + +export function useHttpSettings() { + const { data, status, isFetching, isError, refetch } = useQuery({ + queryKey: HTTP_SETTINGS, + queryFn: getHTTP, + placeholderData: httpPlaceholder, + retry: 5, + retryDelay: (attempt: number) => attempt * 2500, + refetchInterval: queryRefetchIntervalSlow, + networkMode: 'always', + }); + + // we need to jump through some hoops because of the type op port + return { data: data! as unknown as HttpSettings, status, isFetching, isError, refetch }; +} + +export function usePostHttpSettings() { + const { isPending, mutateAsync } = useMutation({ + mutationFn: postHTTP, + onError: (error) => logAxiosError('Error saving HTTP settings', error), + onSettled: () => ontimeQueryClient.invalidateQueries({ queryKey: HTTP_SETTINGS }), + }); + return { isPending, mutateAsync }; +} diff --git a/apps/client/src/common/models/Http.ts b/apps/client/src/common/models/Http.ts index 7c94e470e..f2393a1dd 100644 --- a/apps/client/src/common/models/Http.ts +++ b/apps/client/src/common/models/Http.ts @@ -1,26 +1,13 @@ -export const httpPlaceholder = { - onLoad: { - url: '', - enabled: false, - }, - onStart: { - url: '', - enabled: false, - }, - onUpdate: { - url: '', - enabled: false, - }, - onPause: { - url: '', - enabled: false, - }, - onStop: { - url: '', - enabled: false, - }, - onFinish: { - url: '', - enabled: false, +import { HttpSettings } from 'ontime-types'; + +export const httpPlaceholder: HttpSettings = { + enabledOut: false, + subscriptions: { + onLoad: [], + onStart: [], + onUpdate: [], + onPause: [], + onStop: [], + onFinish: [], }, }; diff --git a/apps/client/src/common/utils/__tests__/regex.test.ts b/apps/client/src/common/utils/__tests__/regex.test.ts index 3ab98f6ad..84c65349d 100644 --- a/apps/client/src/common/utils/__tests__/regex.test.ts +++ b/apps/client/src/common/utils/__tests__/regex.test.ts @@ -1,4 +1,4 @@ -import { isIPAddress, isOnlyNumbers } from '../regex'; +import { isIPAddress, isOnlyNumbers, startsWithHttp } from '../regex'; describe('simple tests for regex', () => { test('isOnlyNumbers', () => { @@ -24,4 +24,16 @@ describe('simple tests for regex', () => { expect(isIPAddress.test(t)).toBe(false); }); }); + + test('startsWithHttp', () => { + const right = ['http://test']; + const wrong = ['https://test', 'testing', '123.0.1']; + + right.forEach((t) => { + expect(startsWithHttp.test(t)).toBe(true); + }); + wrong.forEach((t) => { + expect(startsWithHttp.test(t)).toBe(false); + }); + }); }); diff --git a/apps/client/src/common/utils/regex.ts b/apps/client/src/common/utils/regex.ts index ffc6ad5a7..0d31fa1d6 100644 --- a/apps/client/src/common/utils/regex.ts +++ b/apps/client/src/common/utils/regex.ts @@ -1,2 +1,3 @@ export const isOnlyNumbers = /^\d+$/; export const isIPAddress = /^((25[0-5]|(2[0-4]|1\d|[1-9]|)\d)\.?\b){4}$/; +export const startsWithHttp = /^http:\/\//; diff --git a/apps/client/src/features/modals/integration-modal/IntegrationModal.tsx b/apps/client/src/features/modals/integration-modal/IntegrationModal.tsx index e71e3fb19..ea7fad644 100644 --- a/apps/client/src/features/modals/integration-modal/IntegrationModal.tsx +++ b/apps/client/src/features/modals/integration-modal/IntegrationModal.tsx @@ -2,8 +2,9 @@ import { ModalBody, Tab, TabList, TabPanel, TabPanels, Tabs } from '@chakra-ui/r import ModalWrapper from '../ModalWrapper'; -import OscIntegration from './OscIntegration'; -import OscSettings from './OscSettings'; +import HttpIntegration from './http/HttpIntegration'; +import OscIntegration from './osc/OscIntegration'; +import OscSettings from './osc/OscSettings'; import styles from '../Modal.module.scss'; @@ -30,6 +31,7 @@ export default function IntegrationModal(props: IntegrationModalProps) { OSC OSC Integration + HTTP Integration @@ -38,6 +40,9 @@ export default function IntegrationModal(props: IntegrationModalProps) { + + + diff --git a/apps/client/src/features/modals/integration-modal/http/HttpIntegration.tsx b/apps/client/src/features/modals/integration-modal/http/HttpIntegration.tsx new file mode 100644 index 000000000..617005e84 --- /dev/null +++ b/apps/client/src/features/modals/integration-modal/http/HttpIntegration.tsx @@ -0,0 +1,152 @@ +import { useEffect, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { Switch } from '@chakra-ui/react'; +import type { HttpSettings } from 'ontime-types'; +import { TimerLifeCycle } from 'ontime-types'; + +import { useHttpSettings, usePostHttpSettings } from '../../../../common/hooks-query/useHttpSettings'; +import { useEmitLog } from '../../../../common/stores/logger'; +import ModalLoader from '../../modal-loader/ModalLoader'; +import OntimeModalFooter from '../../OntimeModalFooter'; +import { OntimeCycle, sectionText } from '../integration.utils'; + +import HttpSubscriptionRow from './HttpSubscriptionRow'; + +import styles from '../../Modal.module.scss'; + +export default function HttpIntegration() { + const { data, isFetching } = useHttpSettings(); + const { mutateAsync } = usePostHttpSettings(); + const { emitError } = useEmitLog(); + const { + control, + handleSubmit, + register, + reset, + formState: { isSubmitting, isDirty, isValid }, + } = useForm({ + mode: 'onBlur', + defaultValues: data, + values: data, + resetOptions: { + keepDirtyValues: true, + }, + }); + + const [showSection, setShowSection] = useState(TimerLifeCycle.onLoad); + + useEffect(() => { + if (data) { + reset(data); + } + }, [data, reset]); + + const resetForm = () => { + reset(data); + }; + + const onSubmit = async (values: HttpSettings) => { + try { + const newSettings: HttpSettings = { + enabledOut: Boolean(values.enabledOut), + subscriptions: { + onLoad: values.subscriptions.onLoad ?? [], + onStart: values.subscriptions.onStart ?? [], + onPause: values.subscriptions.onPause ?? [], + onStop: values.subscriptions.onStop ?? [], + onUpdate: values.subscriptions.onUpdate ?? [], + onFinish: values.subscriptions.onFinish ?? [], + }, + }; + + await mutateAsync(newSettings); + } catch (error) { + emitError(`Error setting HTML: ${error}`); + } + }; + + if (isFetching) { + return ; + } + + const placeholder = 'http://x.x.x.x:xxxx/api/path'; + return ( +
+
+
+ HTTP Output + Ontime data feedback +
+ +
+ + + + + + + + + + ); +} diff --git a/apps/client/src/features/modals/integration-modal/http/HttpSubscriptionRow.tsx b/apps/client/src/features/modals/integration-modal/http/HttpSubscriptionRow.tsx new file mode 100644 index 000000000..e9b678fbc --- /dev/null +++ b/apps/client/src/features/modals/integration-modal/http/HttpSubscriptionRow.tsx @@ -0,0 +1,95 @@ +import { Control, useFieldArray, UseFormRegister } from 'react-hook-form'; +import { Button, IconButton, Input, Switch } from '@chakra-ui/react'; +import { FiChevronUp } from '@react-icons/all-files/fi/FiChevronUp'; +import { IoRemove } from '@react-icons/all-files/io5/IoRemove'; +import { HttpSettings, TimerLifeCycle } from 'ontime-types'; + +import { useEmitLog } from '../../../../common/stores/logger'; +import { startsWithHttp } from '../../../../common/utils/regex'; + +import collapseStyles from '../../../../common/components/collapse-bar/CollapseBar.module.scss'; +import styles from '../../Modal.module.scss'; + +interface SubscriptionRowProps { + cycle: TimerLifeCycle; + title: string; + subtitle: string; + visible: boolean; + setShowSection: (cycle: TimerLifeCycle) => void; + register: UseFormRegister; + control: Control; + placeholder: string; +} + +export default function SubscriptionRow(props: SubscriptionRowProps) { + const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props; + const { emitError } = useEmitLog(); + const { fields, append, remove } = useFieldArray({ + name: `subscriptions.${cycle}`, + control, + }); + + const hasTooManyOptions = fields.length >= 3; + const headerStyle = `${styles.splitSection} ${visible ? '' : styles.showPointer}`; + + const sectionTitle = `${title} ${fields.length ? fields.length : '-'} / 3`; + + const handleAddNew = () => { + if (hasTooManyOptions) { + emitError(`Maximum amount of ${cycle} subscriptions reached (3)`); + return; + } + append({ + message: '', + enabled: false, + }); + }; + + return ( + <> +
setShowSection(cycle)}> +
+ {sectionTitle} + {visible && {subtitle}} +
+ +
+ {visible && ( + <> + {fields.map((subscription, index) => ( +
+ } + onClick={() => remove(index)} + aria-label='delete' + size='xs' + colorScheme='red' + /> + + +
+ ))} + + + )} + + ); +} diff --git a/apps/client/src/features/modals/integration-modal/integration.utils.ts b/apps/client/src/features/modals/integration-modal/integration.utils.ts new file mode 100644 index 000000000..8d35c42f7 --- /dev/null +++ b/apps/client/src/features/modals/integration-modal/integration.utils.ts @@ -0,0 +1,30 @@ +import { TimerLifeCycle } from 'ontime-types'; + +export type OntimeCycle = keyof typeof TimerLifeCycle; + +export const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = { + onLoad: { + title: 'On Load', + subtitle: 'Triggers when a timer is loaded', + }, + onStart: { + title: 'On Start', + subtitle: 'Triggers when a timer starts', + }, + onPause: { + title: 'On Pause', + subtitle: 'Triggers when a running timer is paused', + }, + onStop: { + title: 'On Stop', + subtitle: 'Triggers when a running timer is stopped', + }, + onUpdate: { + title: 'On Every Second', + subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)', + }, + onFinish: { + title: 'On Finish', + subtitle: 'Triggers when a running reaches 0', + }, +}; diff --git a/apps/client/src/features/modals/integration-modal/OscIntegration.tsx b/apps/client/src/features/modals/integration-modal/osc/OscIntegration.tsx similarity index 78% rename from apps/client/src/features/modals/integration-modal/OscIntegration.tsx rename to apps/client/src/features/modals/integration-modal/osc/OscIntegration.tsx index 2875e9aab..0cbbf5c8c 100644 --- a/apps/client/src/features/modals/integration-modal/OscIntegration.tsx +++ b/apps/client/src/features/modals/integration-modal/osc/OscIntegration.tsx @@ -3,43 +3,15 @@ import { useForm } from 'react-hook-form'; import type { OscSubscription } from 'ontime-types'; import { TimerLifeCycle } from 'ontime-types'; -import useOscSettings, { usePostOscSubscriptions } from '../../../common/hooks-query/useOscSettings'; -import { useEmitLog } from '../../../common/stores/logger'; -import ModalLoader from '../modal-loader/ModalLoader'; -import OntimeModalFooter from '../OntimeModalFooter'; +import useOscSettings, { usePostOscSubscriptions } from '../../../../common/hooks-query/useOscSettings'; +import { useEmitLog } from '../../../../common/stores/logger'; +import ModalLoader from '../../modal-loader/ModalLoader'; +import OntimeModalFooter from '../../OntimeModalFooter'; +import { type OntimeCycle, sectionText } from '../integration.utils'; import OscSubscriptionRow from './OscSubscriptionRow'; -import styles from '../Modal.module.scss'; - -type OntimeCycle = keyof typeof TimerLifeCycle; - -const sectionText: { [key in TimerLifeCycle]: { title: string; subtitle: string } } = { - onLoad: { - title: 'On Load', - subtitle: 'Triggers when a timer is loaded', - }, - onStart: { - title: 'On Start', - subtitle: 'Triggers when a timer starts', - }, - onPause: { - title: 'On Pause', - subtitle: 'Triggers when a running timer is paused', - }, - onStop: { - title: 'On Stop', - subtitle: 'Triggers when a running timer is stopped', - }, - onUpdate: { - title: 'On Every Second', - subtitle: 'Triggers when a running timer is updated (at least once a second, can be more)', - }, - onFinish: { - title: 'On Finish', - subtitle: 'Triggers when a running reaches 0', - }, -}; +import styles from '../../Modal.module.scss'; export default function OscIntegration() { const { data, isFetching } = useOscSettings(); @@ -92,6 +64,7 @@ export default function OscIntegration() { return ; } + const placeholder = 'OSC message'; return (
void; register: UseFormRegister; control: Control; + placeholder: string; } export default function OscSubscriptionRow(props: OscSubscriptionRowProps) { - const { cycle, title, subtitle, visible, setShowSection, register, control } = props; + const { cycle, title, subtitle, visible, setShowSection, register, control, placeholder } = props; const { emitError } = useEmitLog(); const { fields, append, remove } = useFieldArray({ name: cycle, @@ -64,7 +65,7 @@ export default function OscSubscriptionRow(props: OscSubscriptionRowProps) { colorScheme='red' /> { }; /** - * @description starts OSC server * @description starts OSC server * @param overrideConfig * @return {Promise} @@ -194,20 +194,30 @@ export const startOSCServer = async (overrideConfig = null) => { /** * starts integrations */ -export const startIntegrations = async (config?: { osc: OSCSettings }) => { +export const startIntegrations = async (config?: { osc: OSCSettings; http: HttpSettings }) => { checkStart(OntimeStartOrder.InitIO); - const { osc } = config ?? DataProvider.getData(); + const { osc, http } = config ?? DataProvider.getData(); if (!osc) { return 'OSC Invalid configuration'; + } else { + const { success, message } = oscIntegration.init(osc); + logger.info(LogOrigin.Tx, message); + + if (success) { + integrationService.register(oscIntegration); + } } + if (!http) { + return 'HTTP Invalid configuration'; + } else { + const { success, message } = httpIntegration.init(http); + logger.info(LogOrigin.Tx, message); - const { success, message } = oscIntegration.init(osc); - logger.info(LogOrigin.Tx, message); - - if (success) { - integrationService.register(oscIntegration); + if (success) { + integrationService.register(httpIntegration); + } } }; diff --git a/apps/server/src/classes/data-provider/DataProvider.ts b/apps/server/src/classes/data-provider/DataProvider.ts index 031a537d5..a3074cb85 100644 --- a/apps/server/src/classes/data-provider/DataProvider.ts +++ b/apps/server/src/classes/data-provider/DataProvider.ts @@ -62,6 +62,10 @@ export class DataProvider { return data.osc; } + static getHttp() { + return data.http; + } + static getAliases() { return data.aliases; } @@ -94,6 +98,11 @@ export class DataProvider { await this.persist(); } + static async setHttp(newData) { + data.http = { ...newData }; + await this.persist(); + } + static getRundown() { return [...data.rundown]; } diff --git a/apps/server/src/controllers/ontimeController.ts b/apps/server/src/controllers/ontimeController.ts index fd2463791..a5a6d89a0 100644 --- a/apps/server/src/controllers/ontimeController.ts +++ b/apps/server/src/controllers/ontimeController.ts @@ -1,4 +1,5 @@ -import { Alias, DatabaseModel, GetInfo, LogOrigin, ProjectData } from 'ontime-types'; +import { LogOrigin } from 'ontime-types'; +import type { Alias, DatabaseModel, GetInfo, HttpSettings, ProjectData } from 'ontime-types'; import { RequestHandler, Request, Response } from 'express'; import fs from 'fs'; @@ -11,6 +12,7 @@ import { PlaybackService } from '../services/PlaybackService.js'; import { eventStore } from '../stores/EventStore.js'; import { isDocker, pathToStartStyles, resolveDbPath } from '../setup.js'; import { oscIntegration } from '../services/integration-service/OscIntegration.js'; +import { httpIntegration } from '../services/integration-service/HttpIntegration.js'; import { logger } from '../classes/Logger.js'; import { deleteAllEvents, notifyChanges } from '../services/rundown-service/RundownService.js'; import { deepmerge } from 'ontime-utils'; @@ -284,27 +286,6 @@ export const getOSC = async (req, res) => { res.status(200).send(osc); }; -export const postOscSubscriptions = async (req, res) => { - if (failEmptyObjects(req.body, res)) { - return; - } - - try { - const oscSubscriptions = req.body; - const oscSettings = DataProvider.getOsc(); - oscSettings.subscriptions = oscSubscriptions; - await DataProvider.setOsc(oscSettings); - - // TODO: this update could be more granular, checking that relevant data was changed - const { message } = oscIntegration.init(oscSettings); - logger.info(LogOrigin.Tx, message); - - res.send(oscSettings).status(200); - } catch (error) { - res.status(400).send({ message: error.toString() }); - } -}; - // Create controller for POST request to '/ontime/osc' // Returns ACK message export const postOSC = async (req, res) => { @@ -332,6 +313,59 @@ export const postOSC = async (req, res) => { } }; +export const postOscSubscriptions = async (req, res) => { + if (failEmptyObjects(req.body, res)) { + return; + } + + try { + const subscriptions = req.body; + const oscSettings = DataProvider.getOsc(); + oscSettings.subscriptions = subscriptions; + await DataProvider.setOsc(oscSettings); + + // TODO: this update could be more granular, checking that relevant data was changed + const { message } = oscIntegration.init(oscSettings); + logger.info(LogOrigin.Tx, message); + + res.send(oscSettings).status(200); + } catch (error) { + res.status(400).send({ message: error.toString() }); + } +}; + +// Create controller for GET request to '/ontime/http' +export const getHTTP = async (_req, res: Response) => { + const http = DataProvider.getHttp(); + res.status(200).send(http); +}; + +// Create controller for POST request to '/ontime/http' +export const postHTTP = async (req, res) => { + if (failEmptyObjects(req.body, res)) { + return; + } + + try { + const httpSettings = req.body; + await DataProvider.setHttp(httpSettings); + + integrationService.unregister(httpIntegration); + + // TODO: this update could be more granular, checking that relevant data was changed + const { success, message } = httpIntegration.init(httpSettings); + logger.info(LogOrigin.Tx, message); + + if (success) { + integrationService.register(httpIntegration); + } + + res.send(httpSettings).status(200); + } catch (error) { + res.status(400).send({ message: error.toString() }); + } +}; + export async function patchPartialProjectFile(req, res) { if (failEmptyObjects(req.body, res)) { return; diff --git a/apps/server/src/controllers/ontimeController.validate.ts b/apps/server/src/controllers/ontimeController.validate.ts index 8be1c8bf5..be0d541eb 100644 --- a/apps/server/src/controllers/ontimeController.validate.ts +++ b/apps/server/src/controllers/ontimeController.validate.ts @@ -1,5 +1,9 @@ import { body, check, validationResult } from 'express-validator'; -import { validateOscObject, validateOscSubscriptionEntry } from '../utils/parserFunctions.js'; +import { + validateHttpSubscriptionObject, + validateOscSubscriptionObject, + validateOscSubscriptionCycle, +} from '../utils/parserFunctions.js'; /** * @description Validates object for POST /ontime/views @@ -82,7 +86,22 @@ export const validateOSC = [ body('enabledOut').exists().isBoolean(), body('subscriptions') .isObject() - .custom((value) => validateOscObject(value)), + .custom((value) => validateOscSubscriptionObject(value)), + (req, res, next) => { + const errors = validationResult(req); + if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); + next(); + }, +]; + +/** + * @description Validates object for POST /ontime/http + */ +export const validateHTTP = [ + body('enabledOut').exists().isBoolean(), + body('subscriptions') + .isObject() + .custom((value) => validateHttpSubscriptionObject(value)), (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); @@ -96,22 +115,22 @@ export const validateOSC = [ export const validateOscSubscription = [ body('onLoad') .isArray() - .custom((value) => validateOscSubscriptionEntry(value)), + .custom((value) => validateOscSubscriptionCycle(value)), body('onStart') .isArray() - .custom((value) => validateOscSubscriptionEntry(value)), + .custom((value) => validateOscSubscriptionCycle(value)), body('onPause') .isArray() - .custom((value) => validateOscSubscriptionEntry(value)), + .custom((value) => validateOscSubscriptionCycle(value)), body('onStop') .isArray() - .custom((value) => validateOscSubscriptionEntry(value)), + .custom((value) => validateOscSubscriptionCycle(value)), body('onUpdate') .isArray() - .custom((value) => validateOscSubscriptionEntry(value)), + .custom((value) => validateOscSubscriptionCycle(value)), body('onFinish') .isArray() - .custom((value) => validateOscSubscriptionEntry(value)), + .custom((value) => validateOscSubscriptionCycle(value)), (req, res, next) => { const errors = validationResult(req); if (!errors.isEmpty()) return res.status(422).json({ errors: errors.array() }); diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index 749e1e120..c89e25478 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -57,4 +57,15 @@ export const dbModel: DatabaseModel = { onFinish: [], }, }, + http: { + enabledOut: false, + subscriptions: { + onLoad: [], + onStart: [], + onPause: [], + onStop: [], + onUpdate: [], + onFinish: [], + }, + }, }; diff --git a/apps/server/src/routes/ontimeRouter.ts b/apps/server/src/routes/ontimeRouter.ts index 67f4fb033..080c7a0ef 100644 --- a/apps/server/src/routes/ontimeRouter.ts +++ b/apps/server/src/routes/ontimeRouter.ts @@ -6,6 +6,7 @@ import { getAliases, getInfo, getOSC, + getHTTP, getSettings, getUserFields, getViewSettings, @@ -19,16 +20,18 @@ import { postUserFields, postViewSettings, previewExcel, + postHTTP, } from '../controllers/ontimeController.js'; import { validateAliases, validateOSC, - validateOscSubscription, validatePatchProjectFile, validateSettings, validateUserFields, viewValidator, + validateHTTP, + validateOscSubscription, } from '../controllers/ontimeController.validate.js'; import { projectSanitiser } from '../controllers/projectController.validate.js'; @@ -85,5 +88,11 @@ router.post('/osc', validateOSC, postOSC); // create route between controller and '/ontime/osc-subscriptions' endpoint router.post('/osc-subscriptions', validateOscSubscription, postOscSubscriptions); +// create route between controller and '/ontime/http' endpoint +router.get('/http', getHTTP); + +// create route between controller and '/ontime/http' endpoint +router.post('/http', validateHTTP, postHTTP); + // create route between controller and '/ontime/new' endpoint router.post('/new', projectSanitiser, postNew); diff --git a/apps/server/src/services/integration-service/HttpIntegration.ts b/apps/server/src/services/integration-service/HttpIntegration.ts new file mode 100644 index 000000000..755fa50b4 --- /dev/null +++ b/apps/server/src/services/integration-service/HttpIntegration.ts @@ -0,0 +1,99 @@ +import got from 'got'; + +import { HttpSettings, HttpSubscription, HttpSubscriptionOptions, LogOrigin } from 'ontime-types'; + +import IIntegration, { TimerLifeCycleKey } from './IIntegration.js'; +import { parseTemplateNested } from './integrationUtils.js'; +import { dbModel } from '../../models/dataModel.js'; +import { logger } from '../../classes/Logger.js'; +import { validateHttpSubscriptionObject } from '../../utils/parserFunctions.js'; + +type Action = TimerLifeCycleKey | string; + +/** + * @description Class contains logic towards outgoing HTTP communications + * @class + */ +export class HttpIntegration implements IIntegration { + subscriptions: HttpSubscription; + constructor() { + this.subscriptions = dbModel.http.subscriptions; + } + + /** + * Initializes httpClient + */ + init(config: HttpSettings) { + const { subscriptions, enabledOut } = config; + + if (!enabledOut) { + return { + success: false, + message: 'HTTP output disabled', + }; + } + + this.initSubscriptions(subscriptions); + + try { + return { + success: true, + message: `HTTP integration client ready`, + }; + } catch (error) { + return { + success: false, + message: `Failed initialising HTTP integration: ${error}`, + }; + } + } + + initSubscriptions(subscriptionOptions: HttpSubscription) { + if (validateHttpSubscriptionObject(subscriptionOptions)) { + this.subscriptions = { ...subscriptionOptions }; + } + } + + dispatch(action: Action, state?: object) { + if (!action) { + return { + success: false, + message: 'HTTP called with no action', + }; + } + + // check subscriptions for action + const eventSubscriptions = this.subscriptions?.[action] || []; + + eventSubscriptions.forEach((sub) => { + const { enabled, message } = sub; + if (enabled && message) { + const parsedMessage = parseTemplateNested(message, state || {}); + try { + const parsedUrl = new URL(parsedMessage); + this.emit(parsedUrl); + } catch (err) { + logger.error(LogOrigin.Tx, `HTTP Integration: ${err}`); + return { + success: false, + message: `${err}`, + }; + } + } + }); + } + + async emit(path: URL) { + try { + await got.get(path, { + retry: { limit: 0 }, + }); + } catch (err) { + logger.error(LogOrigin.Tx, `HTTP integration: ${err}`); + } + } + + shutdown() {} +} + +export const httpIntegration = new HttpIntegration(); diff --git a/apps/server/src/services/integration-service/IIntegration.ts b/apps/server/src/services/integration-service/IIntegration.ts index 64f81bf75..4e4d57b15 100644 --- a/apps/server/src/services/integration-service/IIntegration.ts +++ b/apps/server/src/services/integration-service/IIntegration.ts @@ -1,9 +1,9 @@ -import { TimerLifeCycle, OscSubscription } from 'ontime-types'; +import { TimerLifeCycle, Subscription } from 'ontime-types'; export type TimerLifeCycleKey = keyof typeof TimerLifeCycle; -export default interface IIntegration { - subscriptions: OscSubscription; +export default interface IIntegration { + subscriptions: Subscription; init: (config: unknown) => OperationReturn; dispatch: (action: TimerLifeCycleKey, state?: object) => OperationReturn; emit: (...args: unknown[]) => unknown; diff --git a/apps/server/src/services/integration-service/IntegrationService.ts b/apps/server/src/services/integration-service/IntegrationService.ts index aad3296d8..944409eab 100644 --- a/apps/server/src/services/integration-service/IntegrationService.ts +++ b/apps/server/src/services/integration-service/IntegrationService.ts @@ -2,17 +2,17 @@ import IIntegration, { TimerLifeCycleKey } from './IIntegration.js'; import { eventStore } from '../../stores/EventStore.js'; class IntegrationService { - private integrations: IIntegration[]; + private integrations: IIntegration[]; constructor() { this.integrations = []; } - register(integrationService: IIntegration) { + register(integrationService: IIntegration) { this.integrations.push(integrationService); } - unregister(integrationService: IIntegration) { + unregister(integrationService: IIntegration) { this.integrations = this.integrations.filter((int) => int !== integrationService); } diff --git a/apps/server/src/services/integration-service/OscIntegration.ts b/apps/server/src/services/integration-service/OscIntegration.ts index 8c3071cf5..6cf8fe958 100644 --- a/apps/server/src/services/integration-service/OscIntegration.ts +++ b/apps/server/src/services/integration-service/OscIntegration.ts @@ -1,11 +1,11 @@ import { ArgumentType, Client, Message } from 'node-osc'; -import { OSCSettings, OscSubscription } from 'ontime-types'; +import { OSCSettings, OscSubscription, OscSubscriptionOptions } from 'ontime-types'; import IIntegration, { TimerLifeCycleKey } from './IIntegration.js'; import { parseTemplateNested } from './integrationUtils.js'; import { isObject } from '../../utils/varUtils.js'; import { dbModel } from '../../models/dataModel.js'; -import { validateOscObject } from '../../utils/parserFunctions.js'; +import { validateOscSubscriptionObject } from '../../utils/parserFunctions.js'; type Action = TimerLifeCycleKey | string; @@ -13,7 +13,7 @@ type Action = TimerLifeCycleKey | string; * @description Class contains logic towards outgoing OSC communications * @class */ -export class OscIntegration implements IIntegration { +export class OscIntegration implements IIntegration { protected oscClient: null | Client; subscriptions: OscSubscription; @@ -66,7 +66,7 @@ export class OscIntegration implements IIntegration { } initSubscriptions(subscriptionOptions: OscSubscription) { - if (validateOscObject(subscriptionOptions)) { + if (validateOscSubscriptionObject(subscriptionOptions)) { this.subscriptions = { ...subscriptionOptions }; } } diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.js b/apps/server/src/utils/__tests__/parserFunctions.test.js deleted file mode 100644 index b95cbd644..000000000 --- a/apps/server/src/utils/__tests__/parserFunctions.test.js +++ /dev/null @@ -1,91 +0,0 @@ -import { validateOscObject } from '../parserFunctions.ts'; - -test('validateOscSubscription()', () => { - it('should return true when given a valid OscSubscription', () => { - const validSubscription = { - onLoad: [{ id: '1', message: 'test', enabled: true }], - onStart: [{ id: '2', message: 'test', enabled: false }], - onPause: [{ id: '3', message: 'test', enabled: true }], - onStop: [{ id: '4', message: 'test', enabled: false }], - onUpdate: [{ id: '5', message: 'test', enabled: true }], - onFinish: [{ id: '6', message: 'test', enabled: false }], - }; - - const result = validateOscObject(validSubscription); - - expect(result).toBe(true); - }); - - it('should return false when given undefined', () => { - const result = validateOscObject(undefined); - expect(result).toBe(false); - }); - - it('should return false when given null', () => { - const result = validateOscObject(null); - expect(result).toBe(false); - }); - - it('should return false when given an empty object', () => { - const result = validateOscObject({}); - expect(result).toBe(false); - }); - - it('should return false when given an empty array', () => { - const result = validateOscObject([]); - expect(result).toBe(false); - }); - - it('should return false when given an object that is not an OscSubscription', () => { - const invalidObject = { foo: 'bar' }; - - const result = validateOscObject(invalidObject); - - expect(result).toBe(false); - }); - - it('should return false when given an OscSubscription with a missing property', () => { - const invalidSubscription = { - onLoad: [{ id: '1', message: 'test', enabled: true }], - onStart: [{ id: '2', message: 'test', enabled: false }], - onPause: [{ id: '3', message: 'test', enabled: true }], - // Missing onStop - onUpdate: [{ id: '5', message: 'test', enabled: true }], - onFinish: [{ id: '6', message: 'test', enabled: false }], - }; - - const result = validateOscObject(invalidSubscription); - - expect(result).toBe(false); - }); - - it('should return false when given an OscSubscription with an invalid property value', () => { - const invalidSubscription = { - onLoad: [{ id: '1', message: 'test', enabled: true }], - onStart: [{ id: '2', message: 'test', enabled: false }], - onPause: [{ id: '3', message: 'test', enabled: true }], - onStop: [{ id: '4', message: 'test', enabled: false }], - onUpdate: [{ id: '5', message: 'test', enabled: true }], - onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }], - }; - - const result = validateOscObject(invalidSubscription); - - expect(result).toBe(false); - }); - - it('should return true if the message field is empty', () => { - const invalidSubscription = { - onLoad: [{ id: '1', message: 'test', enabled: true }], - onStart: [{ id: '2', message: '', enabled: false }], - onPause: [{ id: '3', message: '', enabled: true }], - onStop: [{ id: '4', message: 'test', enabled: false }], - onUpdate: [{ id: '5', message: 'test', enabled: true }], - onFinish: [{ id: '6', message: 'test', enabled: 'not a boolean' }], - }; - - const result = validateOscObject(invalidSubscription); - - expect(result).toBe(true); - }); -}); diff --git a/apps/server/src/utils/__tests__/parserFunctions.test.ts b/apps/server/src/utils/__tests__/parserFunctions.test.ts new file mode 100644 index 000000000..ba6f456fc --- /dev/null +++ b/apps/server/src/utils/__tests__/parserFunctions.test.ts @@ -0,0 +1,162 @@ +import { HttpSubscription, OscSubscription } from 'ontime-types'; +import { + validateOscSubscriptionObject, + validateOscSubscriptionCycle, + validateHttpSubscriptionCycle, + validateHttpSubscriptionObject, +} from '../parserFunctions.js'; + +describe('validateOscSubscriptionCycle()', () => { + it('should return false when given an OscSubscription with an invalid property value', () => { + const invalidEntry = [{ message: 'test', enabled: 'not a boolean' }]; + + // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught + const result = validateOscSubscriptionCycle(invalidEntry); + expect(result).toBe(false); + }); +}); + +describe('validateOscSubscriptionObject()', () => { + it('should return true when given a valid OscSubscription', () => { + const validSubscription: OscSubscription = { + onLoad: [{ message: 'test', enabled: true }], + onStart: [{ message: 'test', enabled: false }], + onPause: [{ message: 'test', enabled: true }], + onStop: [{ message: 'test', enabled: false }], + onUpdate: [{ message: 'test', enabled: true }], + onFinish: [{ message: 'test', enabled: false }], + }; + + const result = validateOscSubscriptionObject(validSubscription); + expect(result).toBe(true); + }); + + it('should return false when given undefined', () => { + const result = validateOscSubscriptionObject(undefined); + expect(result).toBe(false); + }); + + it('should return false when given null', () => { + const result = validateOscSubscriptionObject(null); + expect(result).toBe(false); + }); + + it('should return false when given an empty object', () => { + // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught + const result = validateOscSubscriptionObject({}); + expect(result).toBe(false); + }); + + it('should return false when given an empty array', () => { + // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught + const result = validateOscSubscriptionObject([]); + expect(result).toBe(false); + }); + + it('should return false when given an object that is not an OscSubscription', () => { + const invalidObject = { foo: 'bar' }; + + // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught + const result = validateOscSubscriptionObject(invalidObject); + expect(result).toBe(false); + }); + + it('should return false when given an OscSubscription with a missing property', () => { + const invalidSubscription = { + onLoad: [{ message: 'test', enabled: true }], + onStart: [{ message: 'test', enabled: false }], + onPause: [{ message: 'test', enabled: true }], + // Missing onStop + onUpdate: [{ message: 'test', enabled: true }], + onFinish: [{ message: 'test', enabled: false }], + }; + + // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught + const result = validateOscSubscriptionObject(invalidSubscription); + expect(result).toBe(false); + }); +}); + +describe('validateHttpSubscriptionCycle()', () => { + it('should return false when given an HttpSubscription with an invalid property value', () => { + const invalidBoolean = [{ message: 'http://', enabled: 'not a boolean' }]; + const invalidHttp = [{ message: 'test', enabled: true }]; + const noFtp = [{ message: 'ftp://test', enabled: true }]; + const noEmpty = [{ message: '', enabled: true }]; + + // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught + expect(validateHttpSubscriptionCycle(invalidBoolean)).toBe(false); + + expect(validateHttpSubscriptionCycle(invalidHttp)).toBe(false); + expect(validateHttpSubscriptionCycle(noFtp)).toBe(false); + expect(validateHttpSubscriptionCycle(noEmpty)).toBe(false); + }); + it('should return true when given an HttpSubscription matches definition', () => { + const validHttp = [{ message: 'http://', enabled: true }]; + const invalidHttps = [{ message: 'https://', enabled: true }]; + + expect(validateHttpSubscriptionCycle(validHttp)).toBe(true); + expect(validateHttpSubscriptionCycle(invalidHttps)).toBe(false); + }); +}); + +describe('validateHttpSubscriptionObject()', () => { + it('should return true when given a valid HttpSubscription', () => { + const validSubscription: HttpSubscription = { + onLoad: [{ message: 'http://', enabled: true }], + onStart: [{ message: 'http://', enabled: false }], + onPause: [{ message: 'http://', enabled: true }], + onStop: [{ message: 'http://', enabled: false }], + onUpdate: [{ message: 'http://', enabled: true }], + onFinish: [{ message: 'http://', enabled: false }], + }; + + const result = validateHttpSubscriptionObject(validSubscription); + expect(result).toBe(true); + }); + + it('should return false when given undefined', () => { + const result = validateHttpSubscriptionObject(undefined); + expect(result).toBe(false); + }); + + it('should return false when given null', () => { + const result = validateHttpSubscriptionObject(null); + expect(result).toBe(false); + }); + + it('should return false when given an empty object', () => { + // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught + const result = validateOscSubscriptionObject({}); + expect(result).toBe(false); + }); + + it('should return false when given an empty array', () => { + // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught + const result = validateHttpSubscriptionObject([]); + expect(result).toBe(false); + }); + + it('should return false when given an object that is not an HttpSubscription', () => { + const invalidObject = { foo: 'bar' }; + + // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught + const result = validateHttpSubscriptionObject(invalidObject); + expect(result).toBe(false); + }); + + it('should return false when given an HttpSubscription with a missing property', () => { + const invalidSubscription = { + onLoad: [{ message: 'http://', enabled: true }], + onStart: [{ message: 'http://', enabled: false }], + onPause: [{ message: 'http://', enabled: true }], + // Missing onStop + onUpdate: [{ message: 'http://', enabled: true }], + onFinish: [{ message: 'http://', enabled: false }], + }; + + // @ts-expect-error -- since this comes from the client, we check things that typescript would have caught + const result = validateHttpSubscriptionObject(invalidSubscription); + expect(result).toBe(false); + }); +}); diff --git a/apps/server/src/utils/parser.ts b/apps/server/src/utils/parser.ts index 4c28acd10..94431631a 100644 --- a/apps/server/src/utils/parser.ts +++ b/apps/server/src/utils/parser.ts @@ -29,6 +29,7 @@ import { parseAliases, parseProject, parseOsc, + parseHttp, parseRundown, parseSettings, parseUserFields, @@ -275,7 +276,7 @@ export const parseJson = async (jsonData): Promise => { // Import OSC settings if any returnData.osc = parseOsc(jsonData) ?? dbModel.osc; // Import HTTP settings if any - // returnData.http = parseHttp(jsonData, enforce); + returnData.http = parseHttp(jsonData) ?? dbModel.http; return returnData as DatabaseModel; }; diff --git a/apps/server/src/utils/parserFunctions.ts b/apps/server/src/utils/parserFunctions.ts index 6ec324eb3..23cdeed93 100644 --- a/apps/server/src/utils/parserFunctions.ts +++ b/apps/server/src/utils/parserFunctions.ts @@ -2,14 +2,17 @@ import { generateId } from 'ontime-utils'; import { Alias, OntimeRundown, + HttpSettings, OSCSettings, - OscSubscription, - OscSubscriptionOptions, ProjectData, Settings, TimerLifeCycle, UserFields, ViewSettings, + OscSubscription, + HttpSubscription, + OscSubscriptionOptions, + HttpSubscriptionOptions, } from 'ontime-types'; import { block as blockDef, delay as delayDef } from '../models/eventsDefinition.js'; @@ -159,12 +162,12 @@ export const parseViewSettings = (data): ViewSettings => { }; /** - * Parses and validates subscription entry + * Parses and validates OSC subscription cycle options * @param data */ -export const validateOscSubscriptionEntry = (data: OscSubscriptionOptions): boolean => { - for (const subscription in data) { - if (typeof data[subscription].message !== 'string' || typeof data[subscription].enabled !== 'boolean') { +export const validateOscSubscriptionCycle = (data: OscSubscriptionOptions[]): boolean => { + for (const subscriptionOption of data) { + if (typeof subscriptionOption.message !== 'string' || typeof subscriptionOption.enabled !== 'boolean') { return false; } } @@ -172,22 +175,23 @@ export const validateOscSubscriptionEntry = (data: OscSubscriptionOptions): bool }; /** - * Parses and validates subscription object + * Parses and validates OSC subscription object * @param data */ -export const validateOscObject = (data: OscSubscription): boolean => { +export const validateOscSubscriptionObject = (data: OscSubscription): boolean => { if (!data) { return false; } + const timerKeys = Object.keys(TimerLifeCycle); for (const key of timerKeys) { + // must contains all keys and be an array if (!(key in data) || !Array.isArray(data[key])) { return false; } - for (const subscription of data[key]) { - if (typeof subscription.message !== 'string' || typeof subscription.enabled !== 'boolean') { - return false; - } + const isValid = validateOscSubscriptionCycle(data[key]); + if (!isValid) { + return false; } } return true; @@ -200,8 +204,9 @@ export const parseOsc = (data: { osc?: Partial }): OSCSettings => { if ('osc' in data) { console.log('Found OSC definition, importing...'); + // TODO: this can be improved by only merging known keys const loadedConfig = data.osc || {}; - const validatedSubscriptions = validateOscObject(loadedConfig.subscriptions) + const validatedSubscriptions = validateOscSubscriptionObject(loadedConfig.subscriptions) ? loadedConfig.subscriptions : dbModel.osc.subscriptions; @@ -216,20 +221,63 @@ export const parseOsc = (data: { osc?: Partial }): OSCSettings => { } }; +/** + * Parses and validates HTTP subscription cycle options + * @param data + */ +export const validateHttpSubscriptionCycle = (data: HttpSubscriptionOptions[]): boolean => { + for (const subscriptionOption of data) { + const isHttp = subscriptionOption.message?.startsWith('http://'); + if (typeof subscriptionOption.message !== 'string' || !isHttp || typeof subscriptionOption.enabled !== 'boolean') { + return false; + } + } + return true; +}; + +/** + * Parses and validates HTTP subscription object + * @param data + */ +export const validateHttpSubscriptionObject = (data: HttpSubscription): boolean => { + if (!data) { + return false; + } + const timerKeys = Object.keys(TimerLifeCycle); + // must contains all keys and be an array + for (const key of timerKeys) { + if (!(key in data) || !Array.isArray(data[key])) { + return false; + } + const isValid = validateHttpSubscriptionCycle(data[key]); + if (!isValid) { + return false; + } + } + return true; +}; + /** * Parse Http portion of an entry * @param {object} data - data object * @param {boolean} enforce - whether to create a definition if one is missing * @returns {object} - event object data */ -export const parseHttp = (data, enforce) => { - const newHttp = {}; +export const parseHttp = (data: { http?: Partial }): HttpSettings => { if ('http' in data) { console.log('Found HTTP definition, importing...'); - } else if (enforce) { - /* Not yet */ + + // TODO: this can be improved by only merging known keys + const loadedConfig = data?.http || {}; + const validatedSubscriptions = validateHttpSubscriptionObject(loadedConfig.subscriptions) + ? loadedConfig.subscriptions + : dbModel.http.subscriptions; + + return { + enabledOut: loadedConfig.enabledOut ?? dbModel.http.enabledOut, + subscriptions: validatedSubscriptions, + }; } - return newHttp; }; /** diff --git a/apps/server/test-db/db.json b/apps/server/test-db/db.json index 3cbd679f6..b60b6d0e9 100644 --- a/apps/server/test-db/db.json +++ b/apps/server/test-db/db.json @@ -259,6 +259,17 @@ "targetIP": "127.0.0.1", "enabled": true }, + "http": { + "enabledOut": false, + "subscriptions": { + "onLoad": [], + "onStart": [], + "onPause": [], + "onStop": [], + "onUpdate": [], + "onFinish": [] + } + }, "aliases": [ { "enabled": true, diff --git a/apps/test-db/db.json b/apps/test-db/db.json index e30ef9560..58e7738dc 100644 --- a/apps/test-db/db.json +++ b/apps/test-db/db.json @@ -29,8 +29,8 @@ "user9": "", "type": "event", "revision": 0, - "id": "aa42f", - "cue": "1" + "cue": "1", + "id": "aa42f" }, { "title": "title 2", @@ -57,8 +57,8 @@ "user9": "", "type": "event", "revision": 0, - "id": "d71bc", - "cue": "2" + "cue": "2", + "id": "d71bc" }, { "title": "title 3", @@ -85,8 +85,8 @@ "user9": "", "type": "event", "revision": 0, - "id": "da5b4", - "cue": "3" + "cue": "3", + "id": "da5b4" } ], "project": { @@ -99,7 +99,7 @@ }, "settings": { "app": "ontime", - "version": "2.0.0", + "version": "2.21.3", "serverPort": 4001, "editorKey": null, "operatorKey": null, @@ -148,5 +148,16 @@ "onUpdate": [], "onFinish": [] } + }, + "http": { + "enabledOut": true, + "subscriptions": { + "onLoad": [], + "onStart": [], + "onPause": [], + "onStop": [], + "onUpdate": [], + "onFinish": [] + } } } \ No newline at end of file diff --git a/demo-db/db.json b/demo-db/db.json index eab76a7dc..6bbc1d0ea 100644 --- a/demo-db/db.json +++ b/demo-db/db.json @@ -468,5 +468,16 @@ ], "onFinish": [] } + }, + "http": { + "enabledOut": true, + "subscriptions": { + "onLoad": [], + "onStart": [], + "onPause": [], + "onStop": [], + "onUpdate": [], + "onFinish": [] + } } } \ No newline at end of file diff --git a/packages/types/src/definitions/DataModel.type.ts b/packages/types/src/definitions/DataModel.type.ts index 71282e904..b72509771 100644 --- a/packages/types/src/definitions/DataModel.type.ts +++ b/packages/types/src/definitions/DataModel.type.ts @@ -5,6 +5,7 @@ import { OSCSettings } from './core/OscSettings.type.js'; import { Settings } from './core/Settings.type.js'; import { UserFields } from './core/UserFields.type.js'; import { ViewSettings } from './core/Views.type.js'; +import { HttpSettings } from '../index.js'; export type DatabaseModel = { rundown: OntimeRundown; @@ -14,4 +15,5 @@ export type DatabaseModel = { aliases: Alias[]; userFields: UserFields; osc: OSCSettings; + http: HttpSettings; }; diff --git a/packages/types/src/definitions/core/HttpSettings.type.ts b/packages/types/src/definitions/core/HttpSettings.type.ts new file mode 100644 index 000000000..39c0898ad --- /dev/null +++ b/packages/types/src/definitions/core/HttpSettings.type.ts @@ -0,0 +1,9 @@ +import { Subscription } from './Subscription.type.js'; + +export type HttpSubscriptionOptions = { message: string; enabled: boolean }; +export type HttpSubscription = Subscription; + +export interface HttpSettings { + enabledOut: boolean; + subscriptions: HttpSubscription; +} diff --git a/packages/types/src/definitions/core/OscSettings.type.ts b/packages/types/src/definitions/core/OscSettings.type.ts index faa518b3a..49e9bd076 100644 --- a/packages/types/src/definitions/core/OscSettings.type.ts +++ b/packages/types/src/definitions/core/OscSettings.type.ts @@ -1,7 +1,7 @@ -import { TimerLifeCycleKey } from './TimerLifecycle.type.js'; +import { Subscription } from './Subscription.type.js'; export type OscSubscriptionOptions = { message: string; enabled: boolean }; -export type OscSubscription = { [key in TimerLifeCycleKey]: OscSubscriptionOptions[] }; +export type OscSubscription = Subscription; export interface OSCSettings { portIn: number; diff --git a/packages/types/src/definitions/core/Subscription.type.ts b/packages/types/src/definitions/core/Subscription.type.ts new file mode 100644 index 000000000..c43f8b30f --- /dev/null +++ b/packages/types/src/definitions/core/Subscription.type.ts @@ -0,0 +1,3 @@ +import { TimerLifeCycleKey } from './TimerLifecycle.type.js'; + +export type Subscription = { [key in TimerLifeCycleKey]: T[] }; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e2afa6fe6..d0b5ef704 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -28,10 +28,14 @@ export type { Alias } from './definitions/core/Alias.type.js'; // ---> User Fields export type { UserFields } from './definitions/core/UserFields.type.js'; +// ---> Integration, Subscription +export type { Subscription } from './definitions/core/Subscription.type.js'; + // ---> OSC export type { OSCSettings, OscSubscription, OscSubscriptionOptions } from './definitions/core/OscSettings.type.js'; // ---> HTTP +export type { HttpSettings, HttpSubscription, HttpSubscriptionOptions } from './definitions/core/HttpSettings.type.js'; // SERVER RESPONSES export type { NetworkInterface, GetInfo } from './api/ontime-controller/BackendResponse.type.js'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0405d819..c1977e673 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -270,6 +270,9 @@ importers: express-validator: specifier: ^6.14.2 version: 6.14.2 + got: + specifier: ^14.0.0 + version: 14.0.0 lowdb: specifier: ^5.0.5 version: 5.0.5 @@ -2572,6 +2575,11 @@ packages: engines: {node: '>=10'} dev: true + /@sindresorhus/is@6.1.0: + resolution: {integrity: sha512-BuvU07zq3tQ/2SIgBsEuxKYDyDjC0n7Zir52bpHy2xnBbW81+po43aLFPLbeV3HRAheFbGud1qgcqSYfhtHMAg==} + engines: {node: '>=16'} + dev: false + /@svgr/babel-plugin-add-jsx-attribute@6.5.1(@babel/core@7.20.12): resolution: {integrity: sha512-9PYGcXrAxitycIjRmZB+Q0JaN07GZIWaTBIGQzfaZv+qr1n8X1XUEJ5rZ/vx6OVD9RRYlrNnXWExQXcmZeD/BQ==} engines: {node: '>=10'} @@ -2704,6 +2712,13 @@ packages: defer-to-connect: 2.0.1 dev: true + /@szmarczak/http-timer@5.0.1: + resolution: {integrity: sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==} + engines: {node: '>=14.16'} + dependencies: + defer-to-connect: 2.0.1 + dev: false + /@tanstack/eslint-plugin-query@5.8.4(eslint@8.53.0)(typescript@5.2.2): resolution: {integrity: sha512-KVgcMc+Bn1qbwkxYVWQoiVSNEIN4IAiLj3cUH/SAHT8m8E59Y97o8ON1syp0Rcw094ItG8pEVZFyQuOaH6PDgQ==} peerDependencies: @@ -2960,6 +2975,10 @@ packages: resolution: {integrity: sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ==} dev: true + /@types/http-cache-semantics@4.0.4: + resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + dev: false + /@types/istanbul-lib-coverage@2.0.4: resolution: {integrity: sha512-z/QT1XN4K4KYuslS23k62yDIDLwLFkzxOuMplDtObz0+y7VqJCaO2o+SPwHCvLFZh7xazvvoor2tA/hPz9ee7g==} dev: true @@ -3035,10 +3054,6 @@ packages: resolution: {integrity: sha512-XAMpaw1s1+6zM+jn2tmw8MyaRDIJfXxqmIQIS0HfoGYPuf7dUWeiUKopwq13KFX9lEp1+THGtlaaYx39Nxr58g==} dev: true - /@types/node@18.15.11: - resolution: {integrity: sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q==} - dev: true - /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} @@ -3965,6 +3980,24 @@ packages: engines: {node: '>=10.6.0'} dev: true + /cacheable-lookup@7.0.0: + resolution: {integrity: sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==} + engines: {node: '>=14.16'} + dev: false + + /cacheable-request@10.2.14: + resolution: {integrity: sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==} + engines: {node: '>=14.16'} + dependencies: + '@types/http-cache-semantics': 4.0.4 + get-stream: 6.0.1 + http-cache-semantics: 4.1.1 + keyv: 4.5.4 + mimic-response: 4.0.0 + normalize-url: 8.0.0 + responselike: 3.0.0 + dev: false + /cacheable-request@7.0.2: resolution: {integrity: sha512-pouW8/FmiPQbuGpkXQ9BAPv/Mo5xDGANgSNXzTzJ8DrKGuXOssM4wIQRjfanNRh3Yu5cfYPvcorqbhg2KIJtew==} engines: {node: '>=8'} @@ -4403,7 +4436,6 @@ packages: engines: {node: '>=10'} dependencies: mimic-response: 3.1.0 - dev: true /deep-eql@4.1.3: resolution: {integrity: sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==} @@ -4469,7 +4501,6 @@ packages: /defer-to-connect@2.0.1: resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} engines: {node: '>=10'} - dev: true /define-lazy-prop@3.0.0: resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==} @@ -5412,6 +5443,11 @@ packages: is-callable: 1.2.7 dev: true + /form-data-encoder@4.0.2: + resolution: {integrity: sha512-KQVhvhK8ZkWzxKxOr56CPulAhH3dobtuQ4+hNQ+HekH/Wp5gSOafqRAeTphQUJAIk0GBvHZgJ2ZGRWd5kphMuw==} + engines: {node: '>= 18'} + dev: false + /form-data@4.0.0: resolution: {integrity: sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==} engines: {node: '>= 6'} @@ -5570,12 +5606,10 @@ packages: /get-stream@6.0.1: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - dev: true /get-stream@8.0.1: resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} engines: {node: '>=16'} - dev: true /get-symbol-description@1.0.0: resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==} @@ -5682,6 +5716,23 @@ packages: responselike: 2.0.1 dev: true + /got@14.0.0: + resolution: {integrity: sha512-X01vTgaX9SwaMq5DfImvS+3GMQFFs5HtrrlS9CuzUSzkxAf/tWGEyynuI+Qy7BjciMczZGjyVSmawYbP4eYhYA==} + engines: {node: '>=20'} + dependencies: + '@sindresorhus/is': 6.1.0 + '@szmarczak/http-timer': 5.0.1 + cacheable-lookup: 7.0.0 + cacheable-request: 10.2.14 + decompress-response: 6.0.0 + form-data-encoder: 4.0.2 + get-stream: 8.0.1 + http2-wrapper: 2.2.1 + lowercase-keys: 3.0.0 + p-cancelable: 4.0.1 + responselike: 3.0.0 + dev: false + /graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} dev: true @@ -5757,7 +5808,6 @@ packages: /http-cache-semantics@4.1.1: resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} - dev: true /http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} @@ -5789,6 +5839,14 @@ packages: resolve-alpn: 1.2.1 dev: true + /http2-wrapper@2.2.1: + resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} + engines: {node: '>=10.19.0'} + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + dev: false + /https-proxy-agent@5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -6284,7 +6342,6 @@ packages: /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - dev: true /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} @@ -6345,7 +6402,6 @@ packages: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} dependencies: json-buffer: 3.0.1 - dev: true /lazy-val@1.0.5: resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==} @@ -6464,6 +6520,11 @@ packages: engines: {node: '>=8'} dev: true + /lowercase-keys@3.0.0: + resolution: {integrity: sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + /lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} dependencies: @@ -6601,7 +6662,11 @@ packages: /mimic-response@3.1.0: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - dev: true + + /mimic-response@4.0.0: + resolution: {integrity: sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false /min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} @@ -6789,6 +6854,11 @@ packages: engines: {node: '>=10'} dev: true + /normalize-url@8.0.0: + resolution: {integrity: sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==} + engines: {node: '>=14.16'} + dev: false + /npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -6949,6 +7019,11 @@ packages: engines: {node: '>=8'} dev: true + /p-cancelable@4.0.1: + resolution: {integrity: sha512-wBowNApzd45EIKdO1LaU+LrMBwAcjfPaYtVzV3lmfM3gf8Z4CHZsiIqlM8TZZ8okYvh5A1cP6gTfCRQtwUpaUg==} + engines: {node: '>=14.16'} + dev: false + /p-limit@3.1.0: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} @@ -7220,7 +7295,6 @@ packages: /quick-lru@5.1.1: resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} engines: {node: '>=10'} - dev: true /random-bytes@1.0.0: resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} @@ -7489,7 +7563,6 @@ packages: /resolve-alpn@1.2.1: resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} - dev: true /resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} @@ -7518,6 +7591,13 @@ packages: lowercase-keys: 2.0.0 dev: true + /responselike@3.0.0: + resolution: {integrity: sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==} + engines: {node: '>=14.16'} + dependencies: + lowercase-keys: 3.0.0 + dev: false + /restore-cursor@4.0.0: resolution: {integrity: sha512-I9fPXU9geO9bHOt9pHHOhOkYerIMsmVaWB0rA2AI9ERh/+x/i7MV5HKBNrg+ljO5eoPVgCcnFuRjJ9uH6I/3eg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -8525,7 +8605,7 @@ packages: - terser dev: true - /vite-node@0.30.1(@types/node@18.15.11)(sass@1.57.1): + /vite-node@0.30.1(@types/node@16.18.23)(sass@1.57.1): resolution: {integrity: sha512-vTikpU/J7e6LU/8iM3dzBo8ZhEiKZEKRznEMm+mJh95XhWaPrJQraT/QsT2NWmuEf+zgAoMe64PKT7hfZ1Njmg==} engines: {node: '>=v14.18.0'} hasBin: true @@ -8535,7 +8615,7 @@ packages: mlly: 1.2.0 pathe: 1.1.0 picocolors: 1.0.0 - vite: 4.3.1(@types/node@18.15.11)(sass@1.57.1) + vite: 4.3.1(@types/node@16.18.23)(sass@1.57.1) transitivePeerDependencies: - '@types/node' - less @@ -8651,7 +8731,7 @@ packages: fsevents: 2.3.3 dev: true - /vite@4.3.1(@types/node@18.15.11)(sass@1.57.1): + /vite@4.3.1(@types/node@16.18.23)(sass@1.57.1): resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -8676,7 +8756,7 @@ packages: terser: optional: true dependencies: - '@types/node': 18.15.11 + '@types/node': 16.18.23 esbuild: 0.17.5 postcss: 8.4.21 rollup: 3.20.7 @@ -8784,7 +8864,7 @@ packages: dependencies: '@types/chai': 4.3.4 '@types/chai-subset': 1.3.3 - '@types/node': 18.15.11 + '@types/node': 16.18.23 '@vitest/expect': 0.30.1 '@vitest/runner': 0.30.1 '@vitest/snapshot': 0.30.1 @@ -8806,8 +8886,8 @@ packages: strip-literal: 1.0.1 tinybench: 2.4.0 tinypool: 0.4.0 - vite: 4.3.1(@types/node@18.15.11)(sass@1.57.1) - vite-node: 0.30.1(@types/node@18.15.11)(sass@1.57.1) + vite: 4.3.1(@types/node@16.18.23)(sass@1.57.1) + vite-node: 0.30.1(@types/node@16.18.23)(sass@1.57.1) why-is-node-running: 2.2.2 transitivePeerDependencies: - less