From c1fcdf706542512915bd5706eb83093059ae12de Mon Sep 17 00:00:00 2001 From: Alex Christoffer Rasmussen Date: Mon, 2 Mar 2026 06:02:29 -0800 Subject: [PATCH] Remove serverport from project file (#1957) * feat: get server port from app satate or env optional startup port from env will always override function for parsing port from env populate default port in app state add test for migration * bump version --- apps/cli/package.json | 2 +- apps/client/package.json | 2 +- apps/client/src/common/api/settings.ts | 17 ++- .../src/common/models/OntimeSettings.ts | 1 - .../panel/settings-panel/GeneralSettings.tsx | 30 ---- .../settings-panel/ServerPortSettings.tsx | 122 +++++++++++++++ .../panel/settings-panel/SettingsPanel.tsx | 8 + .../app-settings/useAppSettingsMenu.tsx | 10 ++ apps/electron/package.json | 2 +- apps/resolver/package.json | 2 +- apps/server/package.json | 2 +- apps/server/src/api-data/db/db.parser.ts | 32 +++- .../api-data/db/migration/db.migration.v3.ts | 5 +- .../api-data/db/migration/db.migration.v4.ts | 35 +++++ .../api-data/db/migration/migration.test.ts | 106 ++++++++++++- .../src/api-data/session/session.service.ts | 6 +- .../__tests__/settings.parser.test.ts | 1 - .../src/api-data/settings/settings.parser.ts | 1 - .../src/api-data/settings/settings.router.ts | 92 ++++++++---- .../api-data/settings/settings.validation.ts | 6 +- apps/server/src/app.ts | 14 +- .../__tests__/DataProvider.utils.test.ts | 2 - .../src/classes/port-manager/PortManager.ts | 140 ++++++++++++++++++ .../classes/port-manager/PortManager.utils.ts | 15 ++ apps/server/src/models/dataModel.ts | 1 - apps/server/src/models/demoProject.ts | 1 - .../app-state-service/AppStateService.ts | 11 ++ .../project-service/ProjectService.ts | 14 +- apps/server/src/setup/config.ts | 1 + apps/server/src/setup/environment.ts | 4 +- apps/server/src/utils/network.ts | 88 ----------- apps/server/test-db/db.json | 1 - e2e/tests/fixtures/e2e-test-db.json | 1 - package.json | 2 +- packages/types/package.json | 2 +- .../ontime-controller/BackendResponse.type.ts | 5 + .../src/definitions/core/Settings.type.ts | 1 - packages/types/src/index.ts | 1 + 38 files changed, 593 insertions(+), 193 deletions(-) create mode 100644 apps/client/src/features/app-settings/panel/settings-panel/ServerPortSettings.tsx create mode 100644 apps/server/src/api-data/db/migration/db.migration.v4.ts create mode 100644 apps/server/src/classes/port-manager/PortManager.ts create mode 100644 apps/server/src/classes/port-manager/PortManager.utils.ts diff --git a/apps/cli/package.json b/apps/cli/package.json index a5519c743..5406a1af2 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "@getontime/cli", - "version": "4.4.2", + "version": "4.5.0", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/apps/client/package.json b/apps/client/package.json index 0bd8d2974..62935224d 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -1,6 +1,6 @@ { "name": "ontime-ui", - "version": "4.4.2", + "version": "4.5.0", "private": true, "type": "module", "dependencies": { diff --git a/apps/client/src/common/api/settings.ts b/apps/client/src/common/api/settings.ts index da37683d1..f3eb9c574 100644 --- a/apps/client/src/common/api/settings.ts +++ b/apps/client/src/common/api/settings.ts @@ -1,5 +1,5 @@ import axios, { AxiosResponse } from 'axios'; -import { Settings } from 'ontime-types'; +import { PortInfo, Settings } from 'ontime-types'; import { apiEntryUrl } from './constants'; import type { RequestOptions } from './requestOptions'; @@ -27,3 +27,18 @@ export async function postSettings(data: Settings): Promise { + const res = await axios.get(`${settingsPath}/serverport`); + return res.data; +} + +/** + * HTTP request to set server port + */ +export async function postServerPort(serverPort: number): Promise> { + return axios.post(`${settingsPath}/serverport`, { serverPort }); +} diff --git a/apps/client/src/common/models/OntimeSettings.ts b/apps/client/src/common/models/OntimeSettings.ts index 662a935c6..26470c7b0 100644 --- a/apps/client/src/common/models/OntimeSettings.ts +++ b/apps/client/src/common/models/OntimeSettings.ts @@ -2,7 +2,6 @@ import { Settings } from 'ontime-types'; export const ontimePlaceholderSettings: Settings = { version: '4.0.0', - serverPort: 4001, editorKey: null, operatorKey: null, timeFormat: '24', diff --git a/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx b/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx index d56cd4789..428c0fad4 100644 --- a/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx +++ b/apps/client/src/features/app-settings/panel/settings-panel/GeneralSettings.tsx @@ -7,12 +7,9 @@ import { postSettings } from '../../../../common/api/settings'; import { maybeAxiosError } from '../../../../common/api/utils'; import Button from '../../../../common/components/buttons/Button'; import Info from '../../../../common/components/info/Info'; -import Input from '../../../../common/components/input/input/Input'; import Select from '../../../../common/components/select/Select'; import useSettings from '../../../../common/hooks-query/useSettings'; import { preventEscape } from '../../../../common/utils/keyEvent'; -import { isOnlyNumbers } from '../../../../common/utils/regex'; -import { isOntimeCloud } from '../../../../externals'; import * as Panel from '../../panel-utils/PanelUtils'; import GeneralPinInput from './composite/GeneralPinInput'; @@ -101,33 +98,6 @@ export default function GeneralSettings() { Changes to the time format and views language do not affect the editor view - - - - ({ + mode: 'onChange', + defaultValues: { serverPort: 4001 }, + }); + + const [pendingRestart, setPendingRestart] = useState(false); + + const setPort = useCallback((info: PortInfo) => { + reset({ serverPort: info.port }); + setPendingRestart(info.pendingRestart); + }, []); + + useEffect(() => { + getServerPort() + .then(setPort) + .catch(() => setError('root', { message: 'Failed to load server port' })); + }, [reset, setError, setPort]); + + const onSubmit = async (formData: ServerPortForm) => { + if (formData.serverPort < 1024 || formData.serverPort > 65535) { + setError('serverPort', { message: 'Port must be within range 1024 - 65535' }); + return; + } + try { + await postServerPort(formData.serverPort); + setPort(await getServerPort()); + } catch (error) { + setError('root', { message: maybeAxiosError(error) }); + } + }; + + const onReset = async () => { + try { + setPort(await getServerPort()); + } catch (error) { + setError('root', { message: 'Failed to load server port' }); + } + }; + + return ( + preventEscape(event, onReset)} + id='server-port-settings' + > + + + Server port + + {pendingRestart && A port change is pending and will happen on the next restart} + + + + + {errors.root && {errors.root.message}} + + + + + + + + + + + + ); +} diff --git a/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx b/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx index 2ffb466e7..8be5198f6 100644 --- a/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx +++ b/apps/client/src/features/app-settings/panel/settings-panel/SettingsPanel.tsx @@ -1,14 +1,17 @@ import useScrollIntoView from '../../../../common/hooks/useScrollIntoView'; +import { isDocker } from '../../../../externals'; import type { PanelBaseProps } from '../../panel-list/PanelList'; import * as Panel from '../../panel-utils/PanelUtils'; import GeneralSettings from './GeneralSettings'; import ProjectData from './ProjectData'; +import ServerPortSettings from './ServerPortSettings'; import ViewSettings from './ViewSettings'; export default function SettingsPanel({ location }: PanelBaseProps) { const dataRef = useScrollIntoView('data', location); const generalRef = useScrollIntoView('general', location); + const portRef = useScrollIntoView('port', location); const viewRef = useScrollIntoView('view', location); return ( @@ -23,6 +26,11 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
+ {!isDocker && ( +
+ +
+ )} ); } diff --git a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx index a3527008a..42d86c8e0 100644 --- a/apps/client/src/features/app-settings/useAppSettingsMenu.tsx +++ b/apps/client/src/features/app-settings/useAppSettingsMenu.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import useAppVersion from '../../common/hooks-query/useAppVersion'; +import { isDocker } from '../../externals'; export type SettingsOption = { id: string; @@ -18,6 +19,7 @@ const staticOptions = [ { id: 'settings__data', label: 'Project data' }, { id: 'settings__general', label: 'General settings' }, { id: 'settings__view', label: 'View settings' }, + { id: 'settings__port', label: 'Server Port' }, ], }, { @@ -102,6 +104,14 @@ export function useAppSettingsMenu() { () => staticOptions.map((option) => ({ ...option, + // if we are in docker don't show the port option + secondary: + 'secondary' in option + ? isDocker && option.id === 'settings' + ? [...option.secondary.filter(({ id }) => id !== 'settings__port')] + : [...option.secondary] + : undefined, + // if there is an update then highlight the about setting highlight: option.id === 'about' && data.hasUpdates ? 'New version available' : undefined, })), [data], diff --git a/apps/electron/package.json b/apps/electron/package.json index b4817ea9e..8504f4d9b 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "ontime-electron", - "version": "4.4.2", + "version": "4.5.0", "author": "Carlos Valente", "description": "Time keeping for live events", "repository": "https://github.com/cpvalente/ontime", diff --git a/apps/resolver/package.json b/apps/resolver/package.json index 15984d605..1f168e296 100644 --- a/apps/resolver/package.json +++ b/apps/resolver/package.json @@ -1,6 +1,6 @@ { "name": "@getontime/resolver", - "version": "4.4.2", + "version": "4.5.0", "type": "module", "repository": "https://github.com/cpvalente/ontime", "types": "./dist/main.d.ts", diff --git a/apps/server/package.json b/apps/server/package.json index 9a8ebe8e5..15de3fb11 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -2,7 +2,7 @@ "name": "ontime-server", "type": "module", "main": "src/index.ts", - "version": "4.4.2", + "version": "4.5.0", "exports": "./src/index.js", "dependencies": { "@googleapis/sheets": "^5.0.5", diff --git a/apps/server/src/api-data/db/db.parser.ts b/apps/server/src/api-data/db/db.parser.ts index a0d1dbcda..1db999604 100644 --- a/apps/server/src/api-data/db/db.parser.ts +++ b/apps/server/src/api-data/db/db.parser.ts @@ -10,6 +10,9 @@ import { parseUrlPresets } from '../url-presets/urlPresets.parser.js'; import { parseViewSettings } from '../view-settings/viewSettings.parser.js'; import { parseCustomFields } from '../custom-fields/customFields.parser.js'; import * as v3 from './migration/db.migration.v3.js'; +import * as v4 from './migration/db.migration.v4.js'; +import { portManager } from '../../classes/port-manager/PortManager.js'; +import { getErrorMessage } from 'ontime-utils'; type ParsingError = { context: string; @@ -21,21 +24,45 @@ type ParsingError = { * @param {object} jsonData - project file to be parsed * @returns {object} parsed object */ -export function parseDatabaseModel(jsonData: Partial): { +export function parseDatabaseModel( + jsonData: Partial, + initialLoad = false, +): { data: DatabaseModel; errors: ParsingError[]; migrated: boolean; } { + let migrated = false; let migratedData = jsonData; + const errors: ParsingError[] = []; + if (v3.shouldUseThisMigration(jsonData)) { try { migrated = true; logger.warning(LogOrigin.Server, 'The imported project is from v3, trying to migrate'); migratedData = v3.migrateAllData(jsonData); - } catch (_error) { + } catch (error) { logger.error(LogOrigin.Server, 'Failed to migrate the data'); + errors.push({ context: 'v3 migration', message: getErrorMessage(error) }); migratedData = jsonData; + }; + + } + + + if (v4.shouldMigrateServerPort(migratedData)) { + try { + migrated = true; + logger.warning(LogOrigin.Server, 'Migrating serverPort from settings to AppState'); + const { db, serverPort } = v4.migrateServerPort(migratedData); + if (initialLoad && serverPort) portManager.migratePortFromProjectFile(serverPort); + migratedData = db; + } catch (error) { + logger.error(LogOrigin.Server, 'Failed to migrate serverPort'); + errors.push({ context: 'v4 migration', message: getErrorMessage(error) }); + migratedData = jsonData; + } } @@ -43,7 +70,6 @@ export function parseDatabaseModel(jsonData: Partial): { // this may throw const settings = parseSettings(migratedData); - const errors: ParsingError[] = []; const makeEmitError = (context: string) => (message: string) => { logger.error(LogOrigin.Server, `Error parsing ${context}: ${message}`); errors.push({ context, message }); diff --git a/apps/server/src/api-data/db/migration/db.migration.v3.ts b/apps/server/src/api-data/db/migration/db.migration.v3.ts index c9e3b9b66..73317f144 100644 --- a/apps/server/src/api-data/db/migration/db.migration.v3.ts +++ b/apps/server/src/api-data/db/migration/db.migration.v3.ts @@ -26,7 +26,6 @@ import { } from 'ontime-utils'; import { is } from '../../../utils/is.js'; -import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js'; import { getPartialProject } from '../../../models/dataModel.js'; // the methodology of the migrations is to just change the necessary keys to match with v4 @@ -69,12 +68,12 @@ type old_Settings = { * migrates a settings from v3 to v4 * - update the version number */ -export function migrateSettings(jsonData: object): Settings | undefined { +export function migrateSettings(jsonData: object): (Settings & { serverPort: number }) | undefined { if (is.objectWithKeys(jsonData, ['settings']) && is.object(jsonData.settings)) { const { serverPort, editorKey, operatorKey, timeFormat, language } = structuredClone( jsonData.settings, ) as old_Settings; - return { version: ONTIME_VERSION, serverPort, editorKey, operatorKey, timeFormat, language }; + return { version: '4.0.0', serverPort, editorKey, operatorKey, timeFormat, language }; } } diff --git a/apps/server/src/api-data/db/migration/db.migration.v4.ts b/apps/server/src/api-data/db/migration/db.migration.v4.ts new file mode 100644 index 000000000..143892cda --- /dev/null +++ b/apps/server/src/api-data/db/migration/db.migration.v4.ts @@ -0,0 +1,35 @@ +import { DatabaseModel, Settings } from 'ontime-types'; +import { is } from '../../../utils/is.js'; + +export function shouldMigrateServerPort(jsonData: object): boolean { + return ( + is.objectWithKeys(jsonData, ['settings']) && + is.object(jsonData.settings) && + is.objectWithKeys(jsonData.settings, ['version', 'serverPort']) && + typeof jsonData.settings.version === 'string' && + jsonData.settings.version.split('.')[0] === '4' && + Number(jsonData.settings.version.split('.')[1]) <= 4 + ); +} + +export function migrateServerPort(jsonData: Partial): { + db: Partial; + serverPort?: number; +} { + const db = structuredClone(jsonData); + const settings = db.settings as Partial; + const editorKey = settings?.editorKey; + const operatorKey = settings?.operatorKey; + const timeFormat = settings?.timeFormat; + const language = settings?.language; + const version = '4.5.0'; + db.settings = { + version, + editorKey, + operatorKey, + timeFormat, + language, + app: 'ontime', + } as Settings; + return { db, serverPort: settings?.serverPort }; +} diff --git a/apps/server/src/api-data/db/migration/migration.test.ts b/apps/server/src/api-data/db/migration/migration.test.ts index 385cba35d..aa674f472 100644 --- a/apps/server/src/api-data/db/migration/migration.test.ts +++ b/apps/server/src/api-data/db/migration/migration.test.ts @@ -1,6 +1,7 @@ import { AutomationSettings, CustomFields, + DatabaseModel, EndAction, OntimeView, ProjectData, @@ -14,8 +15,7 @@ import { ViewSettings, } from 'ontime-types'; import * as v3 from './db.migration.v3.js'; - -import { ONTIME_VERSION } from '../../../ONTIME_VERSION.js'; +import * as v4 from './db.migration.v4.js'; describe('v3 to v4', () => { const oldDb = { @@ -175,8 +175,8 @@ describe('v3 to v4', () => { }; test('migrate settings', () => { - const expectSettings: Settings = { - version: ONTIME_VERSION, + const expectSettings: Settings & { serverPort: number } = { + version: '4.0.0', serverPort: 4001, editorKey: null, operatorKey: null, @@ -493,3 +493,101 @@ describe('v3 to v4', () => { }); }); }); + +describe('v4 remove server port', () => { + const demoDb = { + rundowns: {}, + project: { + title: 'Eurovision Song Contest', + description: 'Turin 2022', + url: 'www.github.com/cpvalente/ontime', + info: 'Rehearsal Schedule - Turin 2022\nAll performers to wear full costumes for 1st rehearsal', + logo: null, + custom: [], + }, + settings: { + app: 'ontime', + version: '4.0.0', + serverPort: 4001, + editorKey: null, + operatorKey: null, + timeFormat: '24', + language: 'en', + }, + viewSettings: { + dangerColor: '#ff7300', + normalColor: '#ffffffcc', + overrideStyles: false, + warningColor: '#ffa528', + }, + customFields: { + song: { + label: 'Song', + type: 'text', + colour: '#339E4E', + }, + artist: { + label: 'Artist', + type: 'text', + colour: '#3E75E8', + }, + }, + urlPresets: [ + { + enabled: true, + alias: 'clock', + target: 'timer', + search: + 'timer?showLeadingZeros=true&timerType=clock&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', + }, + { + enabled: true, + alias: 'minimal', + target: 'timer', + search: + 'timer?showLeadingZeros=true&hideClock=true&hideCards=true&hideProgress=true&hideMessage=true&hideSecondary=true&hideLogo=true', + }, + ], + automation: { + enabledAutomations: false, + enabledOscIn: true, + oscPortIn: 8888, + triggers: [], + automations: {}, + }, + }; + + it('should migrate if server port exists', () => { + expect(v4.shouldMigrateServerPort(demoDb)).toBeTruthy(); + }); + + test('should not migrate if the version is newer', () => { + expect( + v4.shouldMigrateServerPort({ settings: { version: '5.0.0', serverPort: 4001 } } as unknown as DatabaseModel), + ).toBeFalsy(); + }); + + test('should not migrate if there is no server port', () => { + expect(v4.shouldMigrateServerPort({ settings: { version: '4.0.0' } } as DatabaseModel)).toBeFalsy(); + }); + + it('remove server port from project', () => { + const { db: result, serverPort } = v4.migrateServerPort(demoDb as DatabaseModel); + expect(result).not.toHaveProperty('settings.serverPort'); + expect(serverPort).toBe(4001); + expect(result.automation).toMatchObject(demoDb.automation); + expect(result.customFields).toMatchObject(demoDb.customFields); + expect(result.project).toMatchObject(demoDb.project); + expect(result.rundowns).toMatchObject(demoDb.rundowns); + expect(result.settings).toMatchObject({ + app: 'ontime', + version: '4.5.0', + editorKey: null, + operatorKey: null, + timeFormat: '24', + language: 'en', + }); + expect(result.urlPresets).toMatchObject(demoDb.urlPresets); + expect(result.viewSettings).toMatchObject(demoDb.viewSettings); + }); +}); diff --git a/apps/server/src/api-data/session/session.service.ts b/apps/server/src/api-data/session/session.service.ts index a861f8c07..16d2f9f72 100644 --- a/apps/server/src/api-data/session/session.service.ts +++ b/apps/server/src/api-data/session/session.service.ts @@ -11,6 +11,7 @@ import { getTimezoneLabel } from '../../utils/time.js'; import { password, routerPrefix } from '../../externals.js'; import { hashPassword } from '../../utils/hash.js'; import { ONTIME_VERSION } from '../../ONTIME_VERSION.js'; +import { portManager } from '../../classes/port-manager/PortManager.js'; const startedAt = new Date(); @@ -37,7 +38,8 @@ export async function getSessionStats(): Promise { * Adds business logic to gathering data for the info endpoint */ export async function getInfo(): Promise { - const { version, serverPort } = getDataProvider().getSettings(); + const { version } = getDataProvider().getSettings(); + const { port } = portManager.getPort(); // get nif and inject localhost const ni = getNetworkInterfaces(); @@ -46,7 +48,7 @@ export async function getInfo(): Promise { return { networkInterfaces: ni, version, - serverPort, + serverPort: port, publicDir: publicDir.root, }; } diff --git a/apps/server/src/api-data/settings/__tests__/settings.parser.test.ts b/apps/server/src/api-data/settings/__tests__/settings.parser.test.ts index c247b30ed..995bb0af2 100644 --- a/apps/server/src/api-data/settings/__tests__/settings.parser.test.ts +++ b/apps/server/src/api-data/settings/__tests__/settings.parser.test.ts @@ -12,7 +12,6 @@ describe('parseSettings()', () => { expect(result).toBeTypeOf('object'); expect(result).toMatchObject({ version: expect.any(String), - serverPort: 4001, editorKey: null, operatorKey: null, timeFormat: '24', diff --git a/apps/server/src/api-data/settings/settings.parser.ts b/apps/server/src/api-data/settings/settings.parser.ts index 9c8c380ad..c5f3adba8 100644 --- a/apps/server/src/api-data/settings/settings.parser.ts +++ b/apps/server/src/api-data/settings/settings.parser.ts @@ -18,7 +18,6 @@ export function parseSettings(data: Partial): Settings { return { version: defaultSettings.version, - serverPort: data.settings.serverPort ?? defaultSettings.serverPort, editorKey: data.settings.editorKey ?? defaultSettings.editorKey, operatorKey: data.settings.operatorKey ?? defaultSettings.operatorKey, timeFormat: data.settings.timeFormat ?? defaultSettings.timeFormat, diff --git a/apps/server/src/api-data/settings/settings.router.ts b/apps/server/src/api-data/settings/settings.router.ts index df3eb9ade..eaf140f16 100644 --- a/apps/server/src/api-data/settings/settings.router.ts +++ b/apps/server/src/api-data/settings/settings.router.ts @@ -1,25 +1,29 @@ -import express from 'express'; -import { matchedData } from 'express-validator'; -import type { Request, Response } from 'express'; -import { deepEqual } from 'fast-equals'; +import express from "express"; +import { matchedData } from "express-validator"; +import type { Request, Response } from "express"; +import { deepEqual } from "fast-equals"; -import { ErrorResponse, RefetchKey, Settings } from 'ontime-types'; -import { getErrorMessage, obfuscate } from 'ontime-utils'; +import { ErrorResponse, PortInfo, RefetchKey, Settings } from "ontime-types"; +import { getErrorMessage, obfuscate } from "ontime-utils"; -import { validateSettings, validateWelcomeDialog } from './settings.validation.js'; -import { getDataProvider } from '../../classes/data-provider/DataProvider.js'; -import * as appState from '../../services/app-state-service/AppStateService.js'; -import { isDocker } from '../../setup/environment.js'; -import { sendRefetch } from '../../adapters/WebsocketAdapter.js'; +import { + validateSettings, + validateWelcomeDialog, + validateServerPort, +} from "./settings.validation.js"; +import { getDataProvider } from "../../classes/data-provider/DataProvider.js"; +import * as appState from "../../services/app-state-service/AppStateService.js"; +import { sendRefetch } from "../../adapters/WebsocketAdapter.js"; +import { portManager } from "../../classes/port-manager/PortManager.js"; export const router = express.Router(); -router.post('/welcomedialog', validateWelcomeDialog, async (req: Request, res: Response) => { +router.post("/welcomedialog", validateWelcomeDialog, async (req: Request, res: Response) => { const show = await appState.setShowWelcomeDialog(req.body.show); res.status(200).json({ show }); }); -router.get('/', (_req: Request, res: Response) => { +router.get("/", (_req: Request, res: Response) => { const settings = getDataProvider().getSettings(); const obfuscatedSettings = { ...settings }; if (settings.editorKey) { @@ -33,26 +37,52 @@ router.get('/', (_req: Request, res: Response) => { res.status(200).json(obfuscatedSettings); }); -router.post('/', validateSettings, async (req: Request, res: Response) => { +router.post( + "/", + validateSettings, + async (req: Request, res: Response) => { + try { + const data = matchedData(req); + const settings = getDataProvider().getSettings(); + + data.version = settings.version; + + if (!deepEqual(data, settings)) { + await getDataProvider().setSettings(data); + sendRefetch(RefetchKey.Settings); + } + + res.status(200).json(data); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).json({ message }); + } + }, +); + +router.get("/serverport", (_req: Request, res: Response) => { try { - const data = matchedData(req); - const settings = getDataProvider().getSettings(); - - if (isDocker && settings.serverPort !== data.serverPort) { - res.status(403).json({ message: 'Can`t change port when running inside docker' }); - return; - } - - data.version = settings.version; - - if (!deepEqual(data, settings)) { - await getDataProvider().setSettings(data); - sendRefetch(RefetchKey.Settings); - } - - res.status(200).json(data); + const { port, pendingRestart } = portManager.getPort(); + res.status(200).json({ port, pendingRestart }); } catch (error) { const message = getErrorMessage(error); - res.status(400).json({ message }); + res.status(500).json({ message }); } }); + +router.post( + "/serverport", + validateServerPort, + async (req: Request, res: Response) => { + try { + const { serverPort } = matchedData<{ serverPort: number }>(req); + portManager.changePort(serverPort); + const { port, pendingRestart } = portManager.getPort(); + + res.status(200).json({ port, pendingRestart }); + } catch (error) { + const message = getErrorMessage(error); + res.status(400).json({ message }); + } + }, +); diff --git a/apps/server/src/api-data/settings/settings.validation.ts b/apps/server/src/api-data/settings/settings.validation.ts index 8eb471700..0e8881fd9 100644 --- a/apps/server/src/api-data/settings/settings.validation.ts +++ b/apps/server/src/api-data/settings/settings.validation.ts @@ -26,7 +26,11 @@ export const validateSettings = [ pinValidator('operatorKey'), body('timeFormat').isString().isIn(['12', '24']).withMessage('Time format can only be "12" or "24"'), body('language').isString().trim().notEmpty(), - body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(), requestValidationFunction, ]; + +export const validateServerPort = [ + body('serverPort').isPort().withMessage('Invalid value found for server port').toInt(), + requestValidationFunction, +]; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 347774a6a..2a7a9aaeb 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -46,7 +46,8 @@ import { oscServer } from './adapters/OscAdapter.js'; import { clearUploadfolder } from './utils/upload.js'; import { generateCrashReport } from './utils/generateCrashReport.js'; import { timerConfig } from './setup/config.js'; -import { serverTryDesiredPort, getNetworkInterfaces } from './utils/network.js'; +import { getNetworkInterfaces } from './utils/network.js'; +import { portManager } from './classes/port-manager/PortManager.js'; console.log('\n'); consoleHighlight(`Starting Ontime version ${ONTIME_VERSION}`); @@ -176,16 +177,12 @@ export const initAssets = async (escalateErrorFn?: (error: string, unrecoverable */ export const startServer = async (): Promise<{ message: string; serverPort: number }> => { checkStart(OntimeStartOrder.InitServer); - const settings = getDataProvider().getSettings(); - const { serverPort: desiredPort } = settings; - - expressServer = http.createServer(app); // the express server must be started before the socket otherwise the on error event listener will not attach properly - const resultPort = await serverTryDesiredPort(expressServer, desiredPort); - await getDataProvider().setSettings({ ...settings, serverPort: resultPort }); - const showWelcome = await getShowWelcomeDialog(!!restorePoint); + expressServer = http.createServer(app); + const resultPort = await portManager.attachServer(expressServer); + const showWelcome = await getShowWelcomeDialog(!!restorePoint); socket.init(expressServer, showWelcome, prefix); /** @@ -278,6 +275,7 @@ export const shutdown = async (exitCode = 0) => { // 99 means there was a shutdown request from the UI if (exitCode === 0 || exitCode === 99) { await restoreService.clear(); + await portManager.shutdown(); } expressServer?.close(); diff --git a/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts b/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts index 705cc9d40..7a9aa5988 100644 --- a/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts +++ b/apps/server/src/classes/data-provider/__tests__/DataProvider.utils.test.ts @@ -72,14 +72,12 @@ describe('safeMerge', () => { it('merges the settings key', () => { const mergedData = safeMerge(baseDb, { settings: { - serverPort: 3000, language: 'pt', version: 'new', } as Settings, }); expect(mergedData.settings).toStrictEqual({ version: 'new', - serverPort: 3000, operatorKey: null, editorKey: null, timeFormat: baseDb.settings.timeFormat, diff --git a/apps/server/src/classes/port-manager/PortManager.ts b/apps/server/src/classes/port-manager/PortManager.ts new file mode 100644 index 000000000..891e60acf --- /dev/null +++ b/apps/server/src/classes/port-manager/PortManager.ts @@ -0,0 +1,140 @@ +import { Server } from "http"; +import { config } from "../../setup/config.js"; +import { envPort, isDocker, isOntimeCloud } from "../../setup/environment.js"; +import * as appState from "../../services/app-state-service/AppStateService.js"; +import { logger } from "../Logger.js"; +import { LogOrigin, MaybeNumber } from "ontime-types"; +import { isAddressInfo, isPortInUseError } from "./PortManager.utils.js"; +import { shouldCrashDev } from "../../utils/development.js"; + +class PortManager { + private static port: number; + private static pendingRestart = false; + private static newPort: MaybeNumber = null; + + public getPort() { + return { + port: PortManager.port, + pendingRestart: PortManager.pendingRestart, + newPort: PortManager.newPort, + }; + } + + /** + * marks that a port change is requested and will be applied on next restart + * @throws if trying to change port inside docker + * @param newPort + * @returns {void} + */ + public changePort(newPort: number): void { + if (isDocker) throw new Error("Can not change port when running inside docker"); + if (PortManager.port === newPort) return; + PortManager.newPort = newPort; + PortManager.pendingRestart = true; + } + + public migratePortFromProjectFile(port: number) { + shouldCrashDev( + PortManager.port !== undefined, + "this function should not be called after `PortManager.port` has been initialized", + ); + appState.setServerPort(port); + } + + public async shutdown() { + if (PortManager.pendingRestart && PortManager.newPort != null) { + logger.info( + LogOrigin.Server, + `A port change to ${PortManager.newPort} is pending and will take effect on next start`, + ); + await appState.setServerPort(PortManager.newPort); + } + } + + /** + * @description tries to open the server with the desired port, and if getting a `EADDRINUSE` will change to a random port assigned by the OS + * @param {http.Server} server http server object + * @returns {Promise} the resulting port number + * @throws any other server errors will result in a throw + */ + public async attachServer(server: Server): Promise { + if (isOntimeCloud) { + PortManager.port = await this.forceCloudPort(server); + } else { + PortManager.port = + this.parsePort(envPort) || (await appState.getServerPort()) || config.defaultServerPort; + PortManager.port = await this.tryServerPort(server); + } + await appState.setServerPort(PortManager.port); + return PortManager.port; + } + + private parsePort(port: string | undefined) { + if (typeof port !== "string") return null; + if (port === "") return null; + const maybePort = Number(port); + if (isNaN(maybePort)) return null; + return maybePort; + } + + private async tryServerPort(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", (error) => { + // we should only move ports if we are in a desktop environment + if (isDocker) { + reject(error); + return; + } + + if (!isPortInUseError(error)) { + reject(error); + return; + } + + // if we get an address in use error, we will try to open the server in an ephemeral port + // port 0 will assign an ephemeral port + server.listen(0, "0.0.0.0", () => { + const address = server.address(); + if (!isAddressInfo(address)) { + reject(new Error("Unknown port type, unable to proceed")); + return; + } + logger.error( + LogOrigin.Server, + `Failed to open the desired port: ${PortManager.port} \nMoved to an Ephemeral port: ${address.port}`, + true, + ); + + resolve(address.port); + }); + }); + + server.listen(PortManager.port, "0.0.0.0", () => { + const address = server.address(); + if (!isAddressInfo(address)) { + reject(new Error("Unknown port type, unable to proceed")); + return; + } + resolve(address.port); + }); + }); + } + + private forceCloudPort(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", (error) => { + reject(error); + }); + server.listen(config.defaultServerPort, "0.0.0.0", () => { + const address = server.address(); + if (!isAddressInfo(address)) { + reject(new Error("Unknown port type, unable to proceed")); + return; + } + resolve(address.port); + }); + }); + } +} + +export const portManager = new PortManager(); diff --git a/apps/server/src/classes/port-manager/PortManager.utils.ts b/apps/server/src/classes/port-manager/PortManager.utils.ts new file mode 100644 index 000000000..51a14feac --- /dev/null +++ b/apps/server/src/classes/port-manager/PortManager.utils.ts @@ -0,0 +1,15 @@ +import { AddressInfo } from 'net'; + +/** + * Checks whether a given error is a port in use error + */ +export function isPortInUseError(err: Error): boolean { + return typeof err === 'object' && err !== null && 'code' in err && err.code === 'EADDRINUSE'; +} + +/** + * Guard verifies that the given address is a usable AddressInfo object + */ +export function isAddressInfo(address: string | AddressInfo | null): address is AddressInfo { + return typeof address === 'object' && address !== null; +} diff --git a/apps/server/src/models/dataModel.ts b/apps/server/src/models/dataModel.ts index acd59ce6a..fdf3b6f17 100644 --- a/apps/server/src/models/dataModel.ts +++ b/apps/server/src/models/dataModel.ts @@ -25,7 +25,6 @@ const dbModel: DatabaseModel = { }, settings: { version: ONTIME_VERSION, - serverPort: 4001, editorKey: null, operatorKey: null, timeFormat: '24', diff --git a/apps/server/src/models/demoProject.ts b/apps/server/src/models/demoProject.ts index 0b79999e6..9671a754c 100644 --- a/apps/server/src/models/demoProject.ts +++ b/apps/server/src/models/demoProject.ts @@ -336,7 +336,6 @@ export const demoDb: DatabaseModel = { }, settings: { version: '-', - serverPort: 4001, editorKey: null, operatorKey: null, timeFormat: '24', diff --git a/apps/server/src/services/app-state-service/AppStateService.ts b/apps/server/src/services/app-state-service/AppStateService.ts index 2b6b64b25..51bfcf264 100644 --- a/apps/server/src/services/app-state-service/AppStateService.ts +++ b/apps/server/src/services/app-state-service/AppStateService.ts @@ -10,6 +10,7 @@ interface AppState { projectName?: string; rundownId?: string; showWelcomeDialog?: boolean; + serverPort?: number; } const adapter = new JSONFile(publicFiles.appState); @@ -61,3 +62,13 @@ export async function setShowWelcomeDialog(show: boolean): Promise { await config.write(); return show; } + +export async function getServerPort(): Promise { + await config.read(); + return config.data.serverPort; +} + +export async function setServerPort(port: number): Promise { + config.data.serverPort = port; + await config.write(); +} diff --git a/apps/server/src/services/project-service/ProjectService.ts b/apps/server/src/services/project-service/ProjectService.ts index 50d685921..46547dda7 100644 --- a/apps/server/src/services/project-service/ProjectService.ts +++ b/apps/server/src/services/project-service/ProjectService.ts @@ -183,7 +183,10 @@ export async function initialiseProject(): Promise { } try { - const projectName = await loadProjectFile(lastLoaded.projectName, lastLoaded.rundownId); + const projectName = await loadProjectFile(lastLoaded.projectName, { + rundownId: lastLoaded.rundownId, + initialLoad: true, + }); return projectName; } catch (error) { // if we are here, most likely the json parsing failed and the file is corrupt @@ -207,7 +210,10 @@ export async function initialiseProject(): Promise { * @throws * @param fileName file name of the project including the extension */ -export async function loadProjectFile(fileName: string, rundownId?: string): Promise { +export async function loadProjectFile( + fileName: string, + options?: { rundownId?: string; initialLoad?: boolean }, +): Promise { const filePath = doesProjectExist(fileName); if (filePath === null) { throw new Error('Project file not found'); @@ -215,7 +221,7 @@ export async function loadProjectFile(fileName: string, rundownId?: string): Pro // when loading a project file, we allow parsing to fail and interrupt the process const fileData = await parseJsonFile(filePath); - const result = parseDatabaseModel(fileData); + const result = parseDatabaseModel(fileData, options?.initialLoad); let parsedFileName = fileName; if (result.migrated) { @@ -226,7 +232,7 @@ export async function loadProjectFile(fileName: string, rundownId?: string): Pro parsedFileName = await handleCorruptedFile(filePath, parsedFileName); } - const projectName = await loadProject(result.data, parsedFileName, rundownId); + const projectName = await loadProject(result.data, parsedFileName, options?.rundownId); return projectName; } diff --git a/apps/server/src/setup/config.ts b/apps/server/src/setup/config.ts index 23fa2e9af..895201a7c 100644 --- a/apps/server/src/setup/config.ts +++ b/apps/server/src/setup/config.ts @@ -15,6 +15,7 @@ export const config = { crash: 'crash logs', demoProject: 'demo project.json', newProject: 'new project.json', + defaultServerPort: 4001, database: { directory: 'db', filename: 'db.json', diff --git a/apps/server/src/setup/environment.ts b/apps/server/src/setup/environment.ts index 7e0c62e80..84ab5ba9c 100644 --- a/apps/server/src/setup/environment.ts +++ b/apps/server/src/setup/environment.ts @@ -5,4 +5,6 @@ export const isTest = Boolean(process.env.IS_TEST); export const environment = isTest ? 'test' : env; export const isDocker = env === 'docker'; export const isProduction = isDocker || (env === 'production' && !isTest); -export const isOntimeCloud = Boolean(process.env.IS_CLOUD); \ No newline at end of file +export const isOntimeCloud = Boolean(process.env.IS_CLOUD); + +export const envPort = process.env.PORT; diff --git a/apps/server/src/utils/network.ts b/apps/server/src/utils/network.ts index cf8aab7ab..138db62c9 100644 --- a/apps/server/src/utils/network.ts +++ b/apps/server/src/utils/network.ts @@ -1,11 +1,4 @@ -import { LogOrigin } from 'ontime-types'; - -import type { Server } from 'http'; import { networkInterfaces } from 'os'; -import type { AddressInfo } from 'net'; - -import { isDocker, isOntimeCloud, isProduction } from '../setup/environment.js'; -import { logger } from '../classes/Logger.js'; /** * @description Gets information on IPV4 non-internal interfaces @@ -33,84 +26,3 @@ export function getNetworkInterfaces(): { name: string; address: string }[] { return results; } - -/** - * @description tries to open the server with the desired port, and if getting a `EADDRINUSE` will change to an random port assigned by the OS - * @param {http.Server} server http server object - * @param {number} desiredPort the desired port - * @returns {number} the resulting port number - * @throws any other server errors will result in a throw - */ -export function serverTryDesiredPort(server: Server, desiredPort: number): Promise { - if (isOntimeCloud) { - return forceCloudPort(server); - } - - return new Promise((resolve, reject) => { - server.once('error', (error) => { - // we should only move ports if we are in a desktop environment - if (isDocker || !isProduction) { - reject(error); - return; - } - - if (!isPortInUseError(error)) { - reject(error); - return; - } - - // if we get an address in use error, we will try to open the server in an ephemeral port - // port 0 will assign an ephemeral port - server.listen(0, '0.0.0.0', () => { - const address = server.address(); - if (!isAddressInfo(address)) { - reject(new Error('Unknown port type, unable to proceed')); - return; - } - logger.error( - LogOrigin.Server, - `Failed open the desired port: ${desiredPort} \nMoved to an Ephemeral port: ${address.port}`, - true, - ); - - resolve(address.port); - }); - }); - - server.listen(desiredPort, '0.0.0.0', () => { - const address = server.address(); - if (!isAddressInfo(address)) { - reject(new Error('Unknown port type, unable to proceed')); - return; - } - resolve(address.port); - }); - }); -} - -function forceCloudPort(server: Server): Promise { - return new Promise((resolve, reject) => { - server.listen(4001, '0.0.0.0', () => { - const address = server.address(); - if (!isAddressInfo(address)) { - reject(new Error('Unknown port type, unable to proceed')); - return; - } - resolve(address.port); - }); - }); -} - -/** - * Guard verifies that the given address is a usable AddressInfo object - */ -function isAddressInfo(address: string | AddressInfo | null): address is AddressInfo { - return typeof address === 'object' && address !== null; -} - -/** - * Checks whether a given error is a port in use error - */ -function isPortInUseError(err: Error): boolean { - return typeof err === 'object' && err !== null && 'code' in err && err.code === 'EADDRINUSE'; -} diff --git a/apps/server/test-db/db.json b/apps/server/test-db/db.json index 4b7d9e084..235d046f1 100644 --- a/apps/server/test-db/db.json +++ b/apps/server/test-db/db.json @@ -460,7 +460,6 @@ "settings": { "app": "ontime", "version": "-", - "serverPort": 4001, "editorKey": null, "operatorKey": null, "timeFormat": "24", diff --git a/e2e/tests/fixtures/e2e-test-db.json b/e2e/tests/fixtures/e2e-test-db.json index 04ff2d118..95f014115 100644 --- a/e2e/tests/fixtures/e2e-test-db.json +++ b/e2e/tests/fixtures/e2e-test-db.json @@ -477,7 +477,6 @@ "settings": { "app": "ontime", "version": "-", - "serverPort": 4001, "editorKey": null, "operatorKey": null, "timeFormat": "24", diff --git a/package.json b/package.json index ec1bff1aa..9fcf92953 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ontime", - "version": "4.4.2", + "version": "4.5.0", "description": "Time keeping for live events", "keywords": [ "ontime", diff --git a/packages/types/package.json b/packages/types/package.json index ace970a8a..4a84ae4a0 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,5 +1,5 @@ { - "version": "4.4.2", + "version": "4.5.0", "name": "ontime-types", "type": "module", "main": "./src/index.ts", diff --git a/packages/types/src/api/ontime-controller/BackendResponse.type.ts b/packages/types/src/api/ontime-controller/BackendResponse.type.ts index 633d9acb5..313a9d5b1 100644 --- a/packages/types/src/api/ontime-controller/BackendResponse.type.ts +++ b/packages/types/src/api/ontime-controller/BackendResponse.type.ts @@ -6,6 +6,11 @@ export type NetworkInterface = { address: string; }; +export type PortInfo = { + port: number; + pendingRestart: boolean; +}; + export interface SessionStats { startedAt: string; connectedClients: number; diff --git a/packages/types/src/definitions/core/Settings.type.ts b/packages/types/src/definitions/core/Settings.type.ts index eb423b41c..56230f345 100644 --- a/packages/types/src/definitions/core/Settings.type.ts +++ b/packages/types/src/definitions/core/Settings.type.ts @@ -2,7 +2,6 @@ import type { TimeFormat } from './TimeFormat.type.js'; export type Settings = { version: string; - serverPort: number; editorKey: null | string; operatorKey: null | string; timeFormat: TimeFormat; diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index a91fbb5ed..719a754d3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -79,6 +79,7 @@ export type { MessageResponse, SessionStats, ProjectLogoResponse, + PortInfo, } from './api/ontime-controller/BackendResponse.type.js'; export type { EventPostPayload,