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
This commit is contained in:
Alex Christoffer Rasmussen
2026-03-02 06:02:29 -08:00
committed by GitHub
parent 1fe58e21be
commit c1fcdf7065
38 changed files with 593 additions and 193 deletions
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime-ui",
"version": "4.4.2",
"version": "4.5.0",
"private": true,
"type": "module",
"dependencies": {
+16 -1
View File
@@ -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<AxiosResponse<Settin
export async function postShowWelcomeDialog(show: boolean) {
axios.post(`${settingsPath}/welcomedialog`, { show });
}
/**
* HTTP request to retrieve server port
*/
export async function getServerPort(): Promise<PortInfo> {
const res = await axios.get(`${settingsPath}/serverport`);
return res.data;
}
/**
* HTTP request to set server port
*/
export async function postServerPort(serverPort: number): Promise<AxiosResponse<PortInfo>> {
return axios.post(`${settingsPath}/serverport`, { serverPort });
}
@@ -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',
@@ -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() {
<Info>Changes to the time format and views language do not affect the editor view</Info>
<Panel.Loader isLoading={isLoading} />
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description={
isOntimeCloud
? 'Server port disabled for Ontime Cloud'
: 'Port ontime server listens in. Defaults to 4001 (needs app restart)'
}
error={errors.serverPort?.message}
/>
<Input
id='serverPort'
type='number'
maxLength={5}
style={{ width: '75px' }}
disabled={isOntimeCloud}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
<Panel.ListItem>
<Panel.Field
title='Editor pin code'
@@ -0,0 +1,122 @@
import { useCallback, useEffect, useState } from 'react';
import { useForm } from 'react-hook-form';
import { PortInfo } from 'ontime-types';
import { getServerPort, postServerPort } from '../../../../common/api/settings';
import { maybeAxiosError } from '../../../../common/api/utils';
import Button from '../../../../common/components/buttons/Button';
import Input from '../../../../common/components/input/input/Input';
import Tag from '../../../../common/components/tag/Tag';
import { preventEscape } from '../../../../common/utils/keyEvent';
import { isOnlyNumbers } from '../../../../common/utils/regex';
import * as Panel from '../../panel-utils/PanelUtils';
interface ServerPortForm {
serverPort: number;
}
export default function ServerPortSettings() {
const {
handleSubmit,
register,
reset,
setError,
formState: { isSubmitting, isDirty, isValid, errors },
} = useForm<ServerPortForm>({
mode: 'onChange',
defaultValues: { serverPort: 4001 },
});
const [pendingRestart, setPendingRestart] = useState<boolean>(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 (
<Panel.Section
as='form'
onSubmit={handleSubmit(onSubmit)}
onKeyDown={(event) => preventEscape(event, onReset)}
id='server-port-settings'
>
<Panel.Card>
<Panel.SubHeader>
Server port
<Panel.InlineElements>
{pendingRestart && <Tag>A port change is pending and will happen on the next restart</Tag>}
<Button disabled={!isDirty || isSubmitting} variant='ghosted' onClick={onReset}>
Revert to saved
</Button>
<Button
type='submit'
form='server-port-settings'
name='server-port-settings-submit'
loading={isSubmitting}
disabled={!isDirty || !isValid || isSubmitting}
variant='primary'
>
Save
</Button>
</Panel.InlineElements>
</Panel.SubHeader>
{errors.root && <Panel.Error>{errors.root.message}</Panel.Error>}
<Panel.Divider />
<Panel.Section>
<Panel.ListGroup>
<Panel.ListItem>
<Panel.Field
title='Ontime server port'
description='Port ontime server listens in. Defaults to 4001 (needs app restart)'
error={errors.serverPort?.message}
/>
<Input
id='serverPort'
type='number'
maxLength={5}
style={{ width: '75px' }}
{...register('serverPort', {
required: { value: true, message: 'Required field' },
max: { value: 65535, message: 'Port must be within range 1024 - 65535' },
min: { value: 1024, message: 'Port must be within range 1024 - 65535' },
pattern: {
value: isOnlyNumbers,
message: 'Value should be numeric',
},
})}
/>
</Panel.ListItem>
</Panel.ListGroup>
</Panel.Section>
</Panel.Card>
</Panel.Section>
);
}
@@ -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<HTMLDivElement>('data', location);
const generalRef = useScrollIntoView<HTMLDivElement>('general', location);
const portRef = useScrollIntoView<HTMLDivElement>('port', location);
const viewRef = useScrollIntoView<HTMLDivElement>('view', location);
return (
@@ -23,6 +26,11 @@ export default function SettingsPanel({ location }: PanelBaseProps) {
<div ref={viewRef}>
<ViewSettings />
</div>
{!isDocker && (
<div ref={portRef}>
<ServerPortSettings />
</div>
)}
</>
);
}
@@ -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],
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+29 -3
View File
@@ -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<DatabaseModel>): {
export function parseDatabaseModel(
jsonData: Partial<DatabaseModel>,
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<DatabaseModel>): {
// 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 });
@@ -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 };
}
}
@@ -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<DatabaseModel>): {
db: Partial<DatabaseModel>;
serverPort?: number;
} {
const db = structuredClone(jsonData);
const settings = db.settings as Partial<Settings & { serverPort: number }>;
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 };
}
@@ -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);
});
});
@@ -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<SessionStats> {
* Adds business logic to gathering data for the info endpoint
*/
export async function getInfo(): Promise<GetInfo> {
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<GetInfo> {
return {
networkInterfaces: ni,
version,
serverPort,
serverPort: port,
publicDir: publicDir.root,
};
}
@@ -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',
@@ -18,7 +18,6 @@ export function parseSettings(data: Partial<DatabaseModel>): 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,
@@ -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<Settings>) => {
router.get("/", (_req: Request, res: Response<Settings>) => {
const settings = getDataProvider().getSettings();
const obfuscatedSettings = { ...settings };
if (settings.editorKey) {
@@ -33,26 +37,52 @@ router.get('/', (_req: Request, res: Response<Settings>) => {
res.status(200).json(obfuscatedSettings);
});
router.post('/', validateSettings, async (req: Request, res: Response<Settings | ErrorResponse>) => {
router.post(
"/",
validateSettings,
async (req: Request, res: Response<Settings | ErrorResponse>) => {
try {
const data = matchedData<Settings>(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<PortInfo | ErrorResponse>) => {
try {
const data = matchedData<Settings>(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<PortInfo | ErrorResponse>) => {
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 });
}
},
);
@@ -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,
];
+6 -8
View File
@@ -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();
@@ -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,
@@ -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<number>} the resulting port number
* @throws any other server errors will result in a throw
*/
public async attachServer(server: Server): Promise<number> {
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<number> {
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<number> {
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();
@@ -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;
}
-1
View File
@@ -25,7 +25,6 @@ const dbModel: DatabaseModel = {
},
settings: {
version: ONTIME_VERSION,
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
-1
View File
@@ -336,7 +336,6 @@ export const demoDb: DatabaseModel = {
},
settings: {
version: '-',
serverPort: 4001,
editorKey: null,
operatorKey: null,
timeFormat: '24',
@@ -10,6 +10,7 @@ interface AppState {
projectName?: string;
rundownId?: string;
showWelcomeDialog?: boolean;
serverPort?: number;
}
const adapter = new JSONFile<AppState>(publicFiles.appState);
@@ -61,3 +62,13 @@ export async function setShowWelcomeDialog(show: boolean): Promise<boolean> {
await config.write();
return show;
}
export async function getServerPort(): Promise<number | undefined> {
await config.read();
return config.data.serverPort;
}
export async function setServerPort(port: number): Promise<void> {
config.data.serverPort = port;
await config.write();
}
@@ -183,7 +183,10 @@ export async function initialiseProject(): Promise<string> {
}
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<string> {
* @throws
* @param fileName file name of the project including the extension
*/
export async function loadProjectFile(fileName: string, rundownId?: string): Promise<string> {
export async function loadProjectFile(
fileName: string,
options?: { rundownId?: string; initialLoad?: boolean },
): Promise<string> {
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;
}
+1
View File
@@ -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',
+3 -1
View File
@@ -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);
export const isOntimeCloud = Boolean(process.env.IS_CLOUD);
export const envPort = process.env.PORT;
-88
View File
@@ -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<number> {
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<number> {
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';
}
-1
View File
@@ -460,7 +460,6 @@
"settings": {
"app": "ontime",
"version": "-",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
"timeFormat": "24",
-1
View File
@@ -477,7 +477,6 @@
"settings": {
"app": "ontime",
"version": "-",
"serverPort": 4001,
"editorKey": null,
"operatorKey": null,
"timeFormat": "24",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "ontime",
"version": "4.4.2",
"version": "4.5.0",
"description": "Time keeping for live events",
"keywords": [
"ontime",
+1 -1
View File
@@ -1,5 +1,5 @@
{
"version": "4.4.2",
"version": "4.5.0",
"name": "ontime-types",
"type": "module",
"main": "./src/index.ts",
@@ -6,6 +6,11 @@ export type NetworkInterface = {
address: string;
};
export type PortInfo = {
port: number;
pendingRestart: boolean;
};
export interface SessionStats {
startedAt: string;
connectedClients: number;
@@ -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;
+1
View File
@@ -79,6 +79,7 @@ export type {
MessageResponse,
SessionStats,
ProjectLogoResponse,
PortInfo,
} from './api/ontime-controller/BackendResponse.type.js';
export type {
EventPostPayload,