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,