From b6717e0cb108330b100c9041c85c64b139fdb750 Mon Sep 17 00:00:00 2001 From: Alex Zinn Date: Sun, 31 Aug 2025 16:52:20 -0400 Subject: [PATCH] initial commit --- .gitignore | 24 + .npmrc | 3 + .nvmrc | 1 + .prettierrc | 12 + README.md | 69 + bunfig.toml | 10 + eslint.config.js | 28 + index.html | 18 + package.json | 50 + scripts/generate-version.js | 21 + src/App.tsx | 47 + .../FlexContainer/FlexContainerComponent.tsx | 16 + src/components/FlexContainer/index.ts | 1 + src/components/InputField/InputField.tsx | 34 + src/components/InputField/index.ts | 1 + src/components/LoginForm/LoginForm.tsx | 61 + src/components/LoginForm/index.ts | 1 + .../WebSocketStatusViewComponent.tsx | 9 + src/components/WebSocketStatusView/index.ts | 1 + src/components/index.ts | 4 + src/config/index.ts | 6 + src/config/version.json | 3 + src/hooks/index.ts | 1 + src/hooks/store.ts | 10 + src/index.css | 74 + src/lang/Strings.ts | 11 + src/lang/assertUnreachable.ts | 3 + src/main.tsx | 11 + src/routes/index.ts | 32 + src/services/authentication.service.ts | 49 + src/services/logger/Appenders.ts | 26 + src/services/logger/ConsoleAppender.ts | 29 + src/services/logger/IAppender.ts | 8 + src/services/logger/Logger.ts | 197 ++ src/services/logger/LoggerDefaults.ts | 51 + src/services/logger/LoggerFactory.ts | 86 + src/services/logger/LoggerInterface.ts | 27 + src/services/logger/LoggingLevelMapping.ts | 56 + src/services/logger/LoggingThreshold.ts | 17 + src/services/messaging/analytics.proto.json | 391 ++++ src/services/messaging/pcast.proto.json | 1777 +++++++++++++++++ src/services/net/PhenixWebSocket.ts | 140 ++ src/services/platform-detection.service.ts | 165 ++ src/services/telemetry/TelemetryApender.ts | 25 + .../telemetry/TelemetryConfiguration.ts | 75 + src/services/telemetry/TelemetryService.ts | 137 ++ src/services/url.service.ts | 76 + src/store/index.ts | 1 + .../middlewares/PhenixWebSocket.middleware.ts | 12 + src/store/reducer.ts | 8 + src/store/slices/Authentication.slice.ts | 110 + src/store/slices/index.ts | 1 + src/store/store.ts | 7 + src/types/phenix-web-proto.d.ts | 22 + src/vite-env.d.ts | 1 + tsconfig.app.json | 46 + tsconfig.app.json.orig | 27 + tsconfig.app.tsbuildinfo | 1 + tsconfig.json | 4 + tsconfig.node.json | 25 + vite.config.ts | 33 + 61 files changed, 4192 insertions(+) create mode 100644 .gitignore create mode 100644 .npmrc create mode 100644 .nvmrc create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 bunfig.toml create mode 100644 eslint.config.js create mode 100644 index.html create mode 100644 package.json create mode 100644 scripts/generate-version.js create mode 100644 src/App.tsx create mode 100644 src/components/FlexContainer/FlexContainerComponent.tsx create mode 100644 src/components/FlexContainer/index.ts create mode 100644 src/components/InputField/InputField.tsx create mode 100644 src/components/InputField/index.ts create mode 100644 src/components/LoginForm/LoginForm.tsx create mode 100644 src/components/LoginForm/index.ts create mode 100644 src/components/WebSocketStatusView/WebSocketStatusViewComponent.tsx create mode 100644 src/components/WebSocketStatusView/index.ts create mode 100644 src/components/index.ts create mode 100644 src/config/index.ts create mode 100644 src/config/version.json create mode 100644 src/hooks/index.ts create mode 100644 src/hooks/store.ts create mode 100644 src/index.css create mode 100644 src/lang/Strings.ts create mode 100644 src/lang/assertUnreachable.ts create mode 100644 src/main.tsx create mode 100644 src/routes/index.ts create mode 100644 src/services/authentication.service.ts create mode 100644 src/services/logger/Appenders.ts create mode 100644 src/services/logger/ConsoleAppender.ts create mode 100644 src/services/logger/IAppender.ts create mode 100644 src/services/logger/Logger.ts create mode 100644 src/services/logger/LoggerDefaults.ts create mode 100644 src/services/logger/LoggerFactory.ts create mode 100644 src/services/logger/LoggerInterface.ts create mode 100644 src/services/logger/LoggingLevelMapping.ts create mode 100644 src/services/logger/LoggingThreshold.ts create mode 100644 src/services/messaging/analytics.proto.json create mode 100644 src/services/messaging/pcast.proto.json create mode 100644 src/services/net/PhenixWebSocket.ts create mode 100644 src/services/platform-detection.service.ts create mode 100644 src/services/telemetry/TelemetryApender.ts create mode 100644 src/services/telemetry/TelemetryConfiguration.ts create mode 100644 src/services/telemetry/TelemetryService.ts create mode 100644 src/services/url.service.ts create mode 100644 src/store/index.ts create mode 100644 src/store/middlewares/PhenixWebSocket.middleware.ts create mode 100644 src/store/reducer.ts create mode 100644 src/store/slices/Authentication.slice.ts create mode 100644 src/store/slices/index.ts create mode 100644 src/store/store.ts create mode 100644 src/types/phenix-web-proto.d.ts create mode 100644 src/vite-env.d.ts create mode 100644 tsconfig.app.json create mode 100644 tsconfig.app.json.orig create mode 100644 tsconfig.app.tsbuildinfo create mode 100644 tsconfig.json create mode 100644 tsconfig.node.json create mode 100644 vite.config.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.npmrc b/.npmrc new file mode 100644 index 0000000..60d85b5 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +save-exact=true +package-lock=false +@techniker-me:registry=https://registry-node.techniker.me diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..caa814d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,12 @@ +{ + "arrowParens": "avoid", + "bracketSameLine": true, + "bracketSpacing": false, + "printWidth": 160, + "semi": true, + "singleAttributePerLine": false, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..220d11f --- /dev/null +++ b/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname + } + // other options... + } + } +]); +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x'; +import reactDom from 'eslint-plugin-react-dom'; + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname + } + // other options... + } + } +]); +``` diff --git a/bunfig.toml b/bunfig.toml new file mode 100644 index 0000000..0aa5546 --- /dev/null +++ b/bunfig.toml @@ -0,0 +1,10 @@ +[install] +exact = true +saveTextLockfile = false +frozenLockfile = true + +[install.lockfile] +save = false + +[install.scopes] +"@techniker-me"="https://registry-node.techniker.me" diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..667a401 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,28 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import tseslint from 'typescript-eslint'; +import {globalIgnores} from 'eslint/config'; + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [js.configs.recommended, tseslint.configs.recommended, reactHooks.configs['recommended-latest'], reactRefresh.configs.vite], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'error', + { + 'argsIgnorePattern': '^ignored', + 'varsIgnorePattern': '^ignored', + 'caughtErrorsIgnorePattern': '^ignored' + } + ] + } + } +]); diff --git a/index.html b/index.html new file mode 100644 index 0000000..a52eeb4 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + Phenix Customer Portal + + +
+ + + \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..8996a05 --- /dev/null +++ b/package.json @@ -0,0 +1,50 @@ +{ + "name": "webcontrolcenter", + "private": true, + "version": "2025.2.0", + "type": "module", + "scripts": { + "generate-version": "node scripts/generate-version.js", + "dev": "vite", + "format": "prettier --write ./", + "prelint": "bun run format", + "lint": "eslint .", + "lint:fix": "eslint --fix .", + "build": "tsc -b && vite build", + "preview": "vite preview" + }, + "dependencies": { + "@redux-devtools/extension": "3.3.0", + "@reduxjs/toolkit": "2.8.2", + "@techniker-me/pcast-api": "2025.1.4", + "@techniker-me/tools": "2025.0.16", + "@types/node": "24.3.0", + "phenix-web-closest-endpoint-resolver": "2020.0.3", + "phenix-web-detect-browser": "2021.0.1", + "phenix-web-proto": "2020.0.3", + "react": "19.1.1", + "react-dom": "19.1.1", + "react-redux": "9.2.0" + }, + "devDependencies": { + "@eslint/js": "9.34.0", + "@types/react": "19.1.12", + "@types/react-dom": "19.1.9", + "@vitejs/plugin-react-swc": "4.0.1", + "babel-plugin-transform-amd-to-commonjs": "1.6.0", + "commander": "14.0.0", + "eslint": "9.34.0", + "eslint-plugin-react-hooks": "5.2.0", + "eslint-plugin-react-refresh": "0.4.20", + "globals": "16.3.0", + "prettier": "3.6.2", + "typescript": "5.9.2", + "typescript-eslint": "8.41.0", + "vite": "7.1.3", + "vite-plugin-babel": "1.3.2", + "vite-plugin-commonjs": "0.10.4" + }, + "trustedDependencies": [ + "@swc/core" + ] +} diff --git a/scripts/generate-version.js b/scripts/generate-version.js new file mode 100644 index 0000000..66427b4 --- /dev/null +++ b/scripts/generate-version.js @@ -0,0 +1,21 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {writeFileSync} from 'fs'; +import {join} from 'path'; +import {program} from 'commander'; +import packageJson from './../package.json' with {type: 'json'}; + +program.option('-e --environment ', 'Specific environment (optional)'); + +program.parse(process.argv); + +const {environment} = program.opts(); +const prefix = environment ? `${environment}-` : 'local-'; +const version = `${prefix}${new Date().toISOString()} (${packageJson.version})`; +const fileLocation = join(process.cwd(), 'src', 'config', 'version.json'); +const controlVersion = {version}; + +writeFileSync(fileLocation, JSON.stringify(controlVersion, null, 2)); + +console.log(`Generated control center version [${version}]`); diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..2085e33 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,47 @@ +import {useAppSelector, useAppDispatch} from 'hooks/store'; +import {WebSocketStatusViewComponent, LoginForm} from './components'; +import AuthenticationService from './services/authentication.service'; +import {useEffect, useState} from 'react'; +import {authenticationActions, selectError} from 'store/slices/Authentication.slice'; +import hostService from 'services/url.service'; +import LoggerFactory from './services/logger/LoggerFactory'; + +export default function App() { + const dispatch = useAppDispatch(); + const [isInitialized, setIsInitialized] = useState(false); + + useEffect(() => { + // Initialize logger after all modules are loaded to avoid circular dependencies + LoggerFactory.applyLoggerConfig(); + + // Set WebSocket URI only once during initialization + if (!isInitialized) { + AuthenticationService.setWebSocketUri(hostService.getWebSocketUrl()); + setIsInitialized(true); + } + + // Subscribe to WebSocket status changes + AuthenticationService.status?.subscribe(status => + dispatch(authenticationActions.setStatus(status)) + ); + }, [dispatch, isInitialized]); + + const error = useAppSelector(selectError); + + return ( + <> + {error &&
{error}
} +
+ + +
+ + ); +} diff --git a/src/components/FlexContainer/FlexContainerComponent.tsx b/src/components/FlexContainer/FlexContainerComponent.tsx new file mode 100644 index 0000000..e832798 --- /dev/null +++ b/src/components/FlexContainer/FlexContainerComponent.tsx @@ -0,0 +1,16 @@ +import {PropsWithChildren} from 'react'; + +export interface IFlexContainerProps { + style?: Record; + children: PropsWithChildren['children']; +} + +export function FlexContainer({style, children}: IFlexContainerProps) { + const flexContainerStyle = { + display: 'flex', + width: '100%', + height: '100%', + ...style + }; + return
{children}
; +} diff --git a/src/components/FlexContainer/index.ts b/src/components/FlexContainer/index.ts new file mode 100644 index 0000000..1524312 --- /dev/null +++ b/src/components/FlexContainer/index.ts @@ -0,0 +1 @@ +export * from './FlexContainerComponent'; diff --git a/src/components/InputField/InputField.tsx b/src/components/InputField/InputField.tsx new file mode 100644 index 0000000..2cf50bc --- /dev/null +++ b/src/components/InputField/InputField.tsx @@ -0,0 +1,34 @@ +import {JSX} from 'react'; + +export interface IInputFieldProps { + label?: string; + type: 'text' | 'password' | 'number'; + placeholder?: string; + style?: Record; + disabled?: boolean; + value: string; + onValueChange: (value: string) => void; + isRequired?: boolean; +} + +export function InputField({label, type, value, onValueChange, placeholder, style, disabled, isRequired}: IInputFieldProps): JSX.Element { + return ( + <> + + + ); +} diff --git a/src/components/InputField/index.ts b/src/components/InputField/index.ts new file mode 100644 index 0000000..9cd7022 --- /dev/null +++ b/src/components/InputField/index.ts @@ -0,0 +1 @@ +export * from './InputField'; diff --git a/src/components/LoginForm/LoginForm.tsx b/src/components/LoginForm/LoginForm.tsx new file mode 100644 index 0000000..f4ef22f --- /dev/null +++ b/src/components/LoginForm/LoginForm.tsx @@ -0,0 +1,61 @@ +import {JSX, useState} from 'react'; +import {useAppSelector, useAppDispatch} from '../../hooks'; +import {authenticateCredentialsThunk, selectIsAuthenticating, selectStatus} from '../../store/slices/Authentication.slice'; +import {InputField} from '../InputField'; + +export function LoginForm(): JSX.Element { + const dispatch = useAppDispatch(); + const [applicationId, setApplicationId] = useState('phenixrts.com-alex.zinn'); + const [secret, setSecret] = useState('AMAsDzr.dIuGMZ.Zu52Dt~MQvP!DZwYg'); + + const isAuthenticating = useAppSelector(selectIsAuthenticating); + const connectionStatus = useAppSelector(selectStatus); + + // Only allow authentication when WebSocket is Online + const isConnectionReady = connectionStatus === 'Online'; + const isDisabled = isAuthenticating || !isConnectionReady; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + + if (!isConnectionReady) { + alert('Please wait for WebSocket connection to be established before attempting to authenticate.'); + return; + } + + dispatch(authenticateCredentialsThunk({applicationId, secret})); + setApplicationId(''); + setSecret(''); + }; + + return ( + <> +
+ setApplicationId(value)} + disabled={isDisabled} + isRequired={true} + /> + setSecret(value)} + disabled={isDisabled} + isRequired={true} + /> + + {!isConnectionReady && ( +
+ Waiting for WebSocket connection... +
+ )} + + + ); +} diff --git a/src/components/LoginForm/index.ts b/src/components/LoginForm/index.ts new file mode 100644 index 0000000..8026749 --- /dev/null +++ b/src/components/LoginForm/index.ts @@ -0,0 +1 @@ +export * from './LoginForm'; diff --git a/src/components/WebSocketStatusView/WebSocketStatusViewComponent.tsx b/src/components/WebSocketStatusView/WebSocketStatusViewComponent.tsx new file mode 100644 index 0000000..8305b0d --- /dev/null +++ b/src/components/WebSocketStatusView/WebSocketStatusViewComponent.tsx @@ -0,0 +1,9 @@ +import {useAppSelector} from 'hooks/store'; +import {selectStatus} from 'store/slices/Authentication.slice'; + +export function WebSocketStatusViewComponent() { + const storeStatus = useAppSelector(selectStatus); + const displayStatus = storeStatus ?? 'unknown'; + + return <>Status: {displayStatus}; +} diff --git a/src/components/WebSocketStatusView/index.ts b/src/components/WebSocketStatusView/index.ts new file mode 100644 index 0000000..26cf3ca --- /dev/null +++ b/src/components/WebSocketStatusView/index.ts @@ -0,0 +1 @@ +export * from './WebSocketStatusViewComponent'; diff --git a/src/components/index.ts b/src/components/index.ts new file mode 100644 index 0000000..b8464ae --- /dev/null +++ b/src/components/index.ts @@ -0,0 +1,4 @@ +export * from './InputField'; +export * from './LoginForm'; +export * from './FlexContainer'; +export * from './WebSocketStatusView'; diff --git a/src/config/index.ts b/src/config/index.ts new file mode 100644 index 0000000..f428ed3 --- /dev/null +++ b/src/config/index.ts @@ -0,0 +1,6 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import controlVersion from 'config/version.json'; + +export default {controlCenterVersion: controlVersion['version']}; diff --git a/src/config/version.json b/src/config/version.json new file mode 100644 index 0000000..b909f16 --- /dev/null +++ b/src/config/version.json @@ -0,0 +1,3 @@ +{ + "version": "local-2025-08-31T19:58:27.686Z (2025.2.0)" +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts new file mode 100644 index 0000000..d406816 --- /dev/null +++ b/src/hooks/index.ts @@ -0,0 +1 @@ +export * from './store'; diff --git a/src/hooks/store.ts b/src/hooks/store.ts new file mode 100644 index 0000000..9805158 --- /dev/null +++ b/src/hooks/store.ts @@ -0,0 +1,10 @@ +import {useDispatch, useSelector} from 'react-redux'; +import {store} from '../store'; +// Use throughout the app instead of plain `useDispatch` and `useSelector` +// Infer the `RootState`, `AppDispatch`, and `AppStore` types from the store itself +export type RootState = ReturnType; +// Inferred type: {posts: PostsState, comments: C +export type AppDispatch = typeof store.dispatch; +export type AppStore = typeof store; +export const useAppDispatch = useDispatch.withTypes(); +export const useAppSelector = useSelector.withTypes(); diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000..0ee9476 --- /dev/null +++ b/src/index.css @@ -0,0 +1,74 @@ +:root { + font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + color-scheme: light dark; + color: rgba(255, 255, 255, 0.87); + background-color: #242424; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + font-weight: 500; + color: #646cff; + text-decoration: inherit; +} +a:hover { + color: #535bf2; +} + +#root { + width: 100vw; + height: 100vh; +} + +body { + margin: 0; + display: flex; + place-items: center; + min-width: 320px; + min-height: 100vh; +} + +h1 { + font-size: 3.2em; + line-height: 1.1; +} + +button { + text-align: 'center'; + border-radius: 8px; + border: 1px solid transparent; + padding: 0.6em 1.2em; + font-size: 1em; + font-weight: 500; + font-family: inherit; + background-color: #1a1a1a; + cursor: pointer; + transition: border-color 0.25s; +} +button:hover { + border-color: #646cff; +} +button:focus, +button:focus-visible { + outline: 4px auto -webkit-focus-ring-color; +} + +@media (prefers-color-scheme: light) { + :root { + color: #213547; + background-color: #ffffff; + } + a:hover { + color: #747bff; + } + button { + background-color: #f9f9f9; + } +} diff --git a/src/lang/Strings.ts b/src/lang/Strings.ts new file mode 100644 index 0000000..57f54ac --- /dev/null +++ b/src/lang/Strings.ts @@ -0,0 +1,11 @@ +export default class Strings { + public static randomString(length: number): string { + return Math.random() + .toString(36) + .substring(2, 2 + length); + } + + private constructor() { + throw new Error('Strings is a static class that may not be instantiated'); + } +} diff --git a/src/lang/assertUnreachable.ts b/src/lang/assertUnreachable.ts new file mode 100644 index 0000000..77e30d9 --- /dev/null +++ b/src/lang/assertUnreachable.ts @@ -0,0 +1,3 @@ +export default function assertUnreachable(x: never): never { + throw new Error(`Error: Reached un-reachable code with [${x}]`); +} diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..d0bd37a --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,11 @@ +import {createRoot} from 'react-dom/client'; +import {Provider} from 'react-redux'; +import {store} from './store'; +import App from './App'; +import './index.css'; + +createRoot(document.getElementById('root')!).render( + + + +); diff --git a/src/routes/index.ts b/src/routes/index.ts new file mode 100644 index 0000000..b6dcd3d --- /dev/null +++ b/src/routes/index.ts @@ -0,0 +1,32 @@ +export default { + fetchChannelsUrl: () => '/pcast/channels', + createChannelUrl: () => '/pcast/channel', + deleteChannelUrl: () => '/pcast/channel', + fetchChannelUrl: (channelId: string): string => `/pcast/channel/${channelId}`, + fetchChannelMembersUrl: (channelId: string): string => `/pcast/channel/${channelId}/members`, + fetchRoomsUrl: () => '/pcast/rooms', + deleteRoomUrl: () => '/pcast/room', + createRoomUrl: () => '/pcast/room', + fetchRoomUrl: (roomId: string): string => `/pcast/room/${roomId}`, + fetchRoomMembersUrl: (roomId: string): string => `/pcast/room/${roomId}/members`, + stateOfChannelUrl: (channelId: string): string => `/pcast/channel/${encodeURIComponent(channelId)}/publishers/count`, + stateOfRoomUrl: (roomId: string): string => `/pcast/room/${encodeURIComponent(roomId)}/publishers/count`, + sendChannelMessageUrl: (channelId: string): string => `/pcast/channel/${encodeURIComponent(channelId)}/message`, + getChannelMessagesUrl: (channelId: string): string => `/pcast/channel/${encodeURIComponent(channelId)}/messages`, + getRoomMessagesUrl: (roomId: string): string => `/pcast/room/${encodeURIComponent(roomId)}/messages`, + playlistInformation: () => '/pcast/stream/playlist', + killChannel: (channelId: string): string => `/pcast/channel/${encodeURIComponent(channelId)}/kill`, + forkChannel: (destinationChannelId: string, sourceChannelId: string): string => + `/pcast/channel/${encodeURIComponent(destinationChannelId)}/fork/${encodeURIComponent(sourceChannelId)}`, + viewReporting: () => '/pcast/reporting/viewing', + forkHistoryReporting: () => '/pcast/analytics/fork/history', + publishReporting: () => '/pcast/reporting/publishing', + concurrentViewers: () => '/pcast/streams/concurrent', + ingestReports: () => '/pcast/ingest/buffer', + terminateStream: () => '/pcast/stream', + edgeAuth: () => '/pcast/edgeAuth', + authTokenUrl: () => '/pcast/auth', + publishUri: (tenancy: string, publicationType: string): string => `/pcast/${tenancy}/stream/publish/uri/${publicationType}`, + networkThroughput: () => '/pcast/network/throughput', + fetchPlaylist: () => '/pcast/stream/playlist' +}; diff --git a/src/services/authentication.service.ts b/src/services/authentication.service.ts new file mode 100644 index 0000000..f1a2aaf --- /dev/null +++ b/src/services/authentication.service.ts @@ -0,0 +1,49 @@ +import PlatformDetectionService from './platform-detection.service'; +import {Strings, Subject, ReadOnlySubject} from '@techniker-me/tools'; +import {PhenixWebSocket, PhenixWebSocketStatus, PhenixWebSocketMessage} from 'services/net/PhenixWebSocket'; +import config from 'config'; + +class AuthenticationService { + private static readonly _instance: AuthenticationService = new AuthenticationService(); + private readonly _phenixWebSocket: Subject = new Subject(null); + private readonly _sessionId: string = Strings.random(10); + + public static getInstance(): AuthenticationService { + return AuthenticationService._instance; + } + + get status(): ReadOnlySubject | undefined { + return this._phenixWebSocket.value?.status || undefined; + } + + public setWebSocketUri(webSocketUri: string): void { + this._phenixWebSocket.value?.dispose(); + this._phenixWebSocket.value = new PhenixWebSocket(webSocketUri); + } + + public async authenticateCredentials(applicationId: string, secret: string): Promise<{error: unknown; response: unknown}> { + const authenicate = { + applicationId, + secret, + clientVersion: config.controlCenterVersion, + deviceId: '', + platform: PlatformDetectionService.platform, + platformVersion: PlatformDetectionService.platformVersion, + sessionId: this._sessionId, + authenticationToken: secret + }; + + return this._phenixWebSocket.value?.sendMessage(PhenixWebSocketMessage.Authenticate, authenicate) as Promise<{error: unknown; response: unknown}>; + } + + public async signout(): Promise<{error: unknown; response: unknown}> { + const {error, response} = await (this._phenixWebSocket.value?.sendMessage(PhenixWebSocketMessage.Bye, {sessionId: this._sessionId}) as Promise<{ + error: unknown; + response: unknown; + }>); + + return {error, response}; + } +} + +export default AuthenticationService.getInstance(); diff --git a/src/services/logger/Appenders.ts b/src/services/logger/Appenders.ts new file mode 100644 index 0000000..5339cbb --- /dev/null +++ b/src/services/logger/Appenders.ts @@ -0,0 +1,26 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {IAppender} from './IAppender'; + +export default class Appenders { + private _appenders: Array = []; + + get value(): Array { + return this._appenders; + } + + add(appender: IAppender): void { + this._appenders.push(appender); + } + + remove(appender: IAppender): void { + this._appenders = this._appenders.reduce((items, item) => { + if (!(item === appender)) { + items.push(item); + } + + return items; + }, [] as Array); + } +} diff --git a/src/services/logger/ConsoleAppender.ts b/src/services/logger/ConsoleAppender.ts new file mode 100644 index 0000000..c375fe8 --- /dev/null +++ b/src/services/logger/ConsoleAppender.ts @@ -0,0 +1,29 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {IAppender} from './IAppender'; +import {LoggingLevel} from './Logger'; + +export default class ConsoleAppender implements IAppender { + private readonly _threshold: LoggingLevel; + + log(logLevel: LoggingLevel, message: string, category: string, date: Date): void { + if (logLevel < this._threshold) { + return; + } + + const fullMessage = `${date.toISOString()} [${category}] [${LoggingLevel[logLevel]}] ${message}`; + + if (logLevel < LoggingLevel.Warn) { + console.log(fullMessage); + + return; + } + + console.error(fullMessage); + } + + constructor(threshold: LoggingLevel) { + this._threshold = threshold; + } +} diff --git a/src/services/logger/IAppender.ts b/src/services/logger/IAppender.ts new file mode 100644 index 0000000..8ec65fb --- /dev/null +++ b/src/services/logger/IAppender.ts @@ -0,0 +1,8 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {LoggingLevel} from './Logger'; + +export interface IAppender { + log: (logLevel: LoggingLevel, message: string, category: string, date: Date) => void; +} diff --git a/src/services/logger/Logger.ts b/src/services/logger/Logger.ts new file mode 100644 index 0000000..8fcc4f4 --- /dev/null +++ b/src/services/logger/Logger.ts @@ -0,0 +1,197 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import {IAppender} from './IAppender'; +import Appenders from './Appenders'; +import LoggingThreshold from './LoggingThreshold'; + +export enum LoggingLevel { + All = -1, + Trace = 10, + Debug = 20, + Info = 30, + Warn = 40, + Error = 50, + Fatal = 60, + Off = 100 +} + +export type LoggingLevelType = 'Off' | 'Trace' | 'Debug' | 'Info' | 'Warn' | 'Error' | 'Fatal' | 'All'; + +export default class Logger { + private readonly _category: string; + private readonly _appenders: Appenders; + private readonly _threshold: LoggingThreshold; + + get category(): string { + return this._category; + } + + get appenders(): Appenders { + return this._appenders; + } + + get threshold(): LoggingThreshold { + return this._threshold; + } + + trace(...args: any): void { + if (!this._threshold.value || this._threshold.value > LoggingLevel.Trace) { + return; + } + + this.log(LoggingLevel.Trace, args); + } + + debug(...args: any): void { + if (!this._threshold.value || this._threshold.value > LoggingLevel.Debug) { + return; + } + + this.log(LoggingLevel.Debug, args); + } + + info(...args: any): void { + if (!this._threshold.value || this._threshold.value > LoggingLevel.Info) { + return; + } + + this.log(LoggingLevel.Info, args); + } + + warn(...args: any): void { + if (!this._threshold.value || this._threshold.value > LoggingLevel.Warn) { + return; + } + + this.log(LoggingLevel.Warn, args); + } + + error(...args: any): void { + if (!this._threshold.value || this._threshold.value > LoggingLevel.Error) { + return; + } + + this.log(LoggingLevel.Error, args); + } + + fatal(...args: any): void { + if (!this._threshold.value || this._threshold.value > LoggingLevel.Fatal) { + return; + } + + this.log(LoggingLevel.Fatal, args); + } + + private log(level: number, args: any): void { + const date = new Date(); + const message = this.replacePlaceholders(args); + + this._appenders.value.forEach((appender: IAppender) => { + appender.log(level, message, this.category, date); + }); + } + + private replacePlaceholders(args: any): string { + let replacePlaceholdersString = args[0]; + let index = 0; + + while (replacePlaceholdersString.indexOf && args.length > 1 && index >= 0) { + index = replacePlaceholdersString.indexOf('%', index); + + if (index > 0) { + const type = replacePlaceholdersString.substring(index + 1, index + 2); + + switch (type) { + case '%': + // Escaped '%%' turns into '%' + replacePlaceholdersString = replacePlaceholdersString.substring(0, index) + replacePlaceholdersString.substring(index + 1); + index++; + + break; + case 's': + case 'd': + // Replace '%d' or '%s' with the argument + args[0] = replacePlaceholdersString = this.replaceArgument(this.toString(args[1]), replacePlaceholdersString, index); + args.splice(1, 1); + + break; + case 'j': + // Replace %j' with the argument + args[0] = replacePlaceholdersString = this.replaceArgument(this.stringify(args[1]), replacePlaceholdersString, index); + + args.splice(1, 1); + + break; + default: + return args.toString(); + } + } + } + + if (args.length > 1) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + args = args.reduce((accumulator: any, currentValue: any, index: number, array: any[]) => { + if (index + 1 === array.length && currentValue instanceof Error) { + return accumulator + '\n' + this.toString(currentValue.stack); + } + + return accumulator + `[${this.toString(currentValue)}]`; + }); + } + + return args.toString(); + } + + private stringify(arg: any): string { + try { + return JSON.stringify(arg instanceof Error ? this.toString(arg) : arg, null, 2); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return '[object invalid JSON.stringify]'; + } + } + + private replaceArgument(argument: any, replacePlaceholdersString: string, index: number): string { + return replacePlaceholdersString.substring(0, index) + this.toString(argument) + replacePlaceholdersString.substring(index + 2); + } + + private toString(data: any): string { + if (typeof data === 'string') { + return data; + } + + if (typeof data === 'boolean') { + return data ? 'true' : 'false'; + } + + if (typeof data === 'number') { + return data.toString(); + } + + let toStringStr = ''; + + if (data) { + if (typeof data === 'function') { + toStringStr = data.toString(); + } else if (data instanceof Object) { + try { + toStringStr = data.toString(); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + toStringStr = '[object invalid toString()]'; + } + } + } + + return toStringStr; + } + + constructor(category: string, appenders: Appenders, threshold: LoggingThreshold) { + this._category = category; + this._appenders = appenders; + this._threshold = threshold; + } +} diff --git a/src/services/logger/LoggerDefaults.ts b/src/services/logger/LoggerDefaults.ts new file mode 100644 index 0000000..3d2becc --- /dev/null +++ b/src/services/logger/LoggerDefaults.ts @@ -0,0 +1,51 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {LoggingLevel, LoggingLevelType} from '../logger/Logger'; + +declare const __FEATURES__: { + sendLogs: LoggingLevelType; + logToConsole: LoggingLevelType; +}; + +export class BuildFeatures { + private static _sendLogs: LoggingLevelType; + private static _logToConsole: LoggingLevelType; + + static get sendLogs(): LoggingLevelType { + return this._sendLogs; + } + + static get logToConsole(): LoggingLevelType { + return this._logToConsole; + } + + static applyFeatures(): void { + try { + const features = __FEATURES__; + + this._sendLogs = 'sendLogs' in features ? features.sendLogs : 'All'; + this._logToConsole = 'logToConsole' in features ? features.logToConsole : 'All'; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + this._sendLogs = 'All'; + this._logToConsole = 'All'; + } + } +} + +BuildFeatures.applyFeatures(); + +export default class LoggerDefaults { + static get defaultLoggingLevel(): LoggingLevel { + return LoggingLevel[BuildFeatures.sendLogs]; + } + + static get defaultConsoleLoggingLevel(): LoggingLevel { + return LoggingLevel[BuildFeatures.logToConsole]; + } + + static get defaultTelemetryLoggingLevel(): LoggingLevel { + return LoggingLevel.Info; + } +} diff --git a/src/services/logger/LoggerFactory.ts b/src/services/logger/LoggerFactory.ts new file mode 100644 index 0000000..57ce4b4 --- /dev/null +++ b/src/services/logger/LoggerFactory.ts @@ -0,0 +1,86 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import PlatformDetectionService from 'services/platform-detection.service'; +import hostService from 'services/url.service'; +import TelemetryConfiguration from 'services/telemetry/TelemetryConfiguration'; +import TelemetryAppender from 'services/telemetry/TelemetryApender'; +// import userStore from 'services/user-store'; +import {ILogger} from './LoggerInterface'; +import Logger, {LoggingLevel} from './Logger'; +import Appenders from './Appenders'; +import LoggingThreshold from './LoggingThreshold'; +import ConsoleAppender from './ConsoleAppender'; +import LoggerDefaults from './LoggerDefaults'; + +export default class LoggerFactory { + private static _loggers: {[category: string]: ILogger} = {}; + private static _appenders: Appenders = new Appenders(); + private static _threshold: LoggingThreshold = new LoggingThreshold(); + private static _telemetryConfiguration: TelemetryConfiguration = new TelemetryConfiguration(); + private static _initialized: boolean = false; + + static get telemetryConfiguration(): TelemetryConfiguration { + return this._telemetryConfiguration; + } + + static applyLoggerConfig(): void { + if (this._initialized) return; + + LoggerFactory.applyConsoleLogger(LoggingLevel['All']); + LoggerFactory.applyLoggingLevel(); + LoggerFactory.applyTelemetryLogger(); + this._initialized = true; + } + + static getLogger(category: string): ILogger { + if (typeof category !== 'string') { + category = 'portal'; + } + + const logger = LoggerFactory._loggers[category]; + + if (logger) { + return logger; + } + + return (LoggerFactory._loggers[category] = new Logger(category, this._appenders, this._threshold)); + } + + static applyLoggingLevel(): void { + this._threshold.setThreshold(LoggingLevel['All']); + } + + static applyConsoleLogger(level: LoggingLevel): void { + this._appenders.add(new ConsoleAppender(level || LoggerDefaults.defaultConsoleLoggingLevel)); + } + + static async applyTelemetryConfiguration(level: LoggingLevel): Promise { + this._telemetryConfiguration.threshold = level || LoggerDefaults.defaultTelemetryLoggingLevel; + this._telemetryConfiguration.url = hostService.getTelemetryUrl(); + this._telemetryConfiguration.environment = hostService.getBaseUrlOrigin(); + // this._telemetryConfiguration.tenancy = await userStore.get('applicationId'); + // this._telemetryConfiguration.userId = await userStore.get('applicationId'); + // this._telemetryConfiguration.sessionId = await userStore.get('sessionId'); + + // Lazy access to PlatformDetectionService to avoid circular dependency + try { + this._telemetryConfiguration.browser = PlatformDetectionService.browser + ? `${PlatformDetectionService.browser}/${PlatformDetectionService.version}` + : 'unknown'; + } catch (error) { + console.warn('Failed to get platform detection info for telemetry:', error); + this._telemetryConfiguration.browser = 'unknown'; + } + } + + private static applyTelemetryLogger(): void { + LoggerFactory.applyTelemetryConfiguration(LoggingLevel['Info']); + + this._appenders.add(new TelemetryAppender(this._telemetryConfiguration)); + } + + private constructor() { + throw new Error('LoggerFactory is a static class that may not be instantiated'); + } +} diff --git a/src/services/logger/LoggerInterface.ts b/src/services/logger/LoggerInterface.ts new file mode 100644 index 0000000..d61c4d3 --- /dev/null +++ b/src/services/logger/LoggerInterface.ts @@ -0,0 +1,27 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import Appenders from './Appenders'; +import LoggingThreshold from './LoggingThreshold'; + +export interface ILogger { + readonly category: string; + + readonly appenders: Appenders; + + readonly threshold: LoggingThreshold; + + trace: (...args: any) => void; + + debug: (...args: any) => void; + + info: (...args: any) => void; + + warn: (...args: any) => void; + + error: (...args: any) => void; + + fatal: (...args: any) => void; +} +/* eslint-enable */ diff --git a/src/services/logger/LoggingLevelMapping.ts b/src/services/logger/LoggingLevelMapping.ts new file mode 100644 index 0000000..ec90dd7 --- /dev/null +++ b/src/services/logger/LoggingLevelMapping.ts @@ -0,0 +1,56 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {LoggingLevel, LoggingLevelType} from './Logger'; + +function assertUnreachable(x: never): never { + throw new Error(`Unexpected value [${x}]. This should never be reached`); +} + +export default class LoggingLevelMapping { + static convertLoggingLevelToLoggingLevelType(loggingLevel: LoggingLevel): LoggingLevelType { + switch (loggingLevel) { + case LoggingLevel.Off: + return 'Off'; + case LoggingLevel.Trace: + return 'Trace'; + case LoggingLevel.Debug: + return 'Debug'; + case LoggingLevel.Info: + return 'Trace'; + case LoggingLevel.Warn: + return 'Warn'; + case LoggingLevel.Error: + return 'Error'; + case LoggingLevel.Fatal: + return 'Fatal'; + case LoggingLevel.All: + return 'All'; + default: + assertUnreachable(loggingLevel); + } + } + + static convertLoggingLevelTypeToLoggingLevel(loggingLevelType: LoggingLevelType): LoggingLevel { + switch (loggingLevelType) { + case 'Off': + return LoggingLevel.Off; + case 'Trace': + return LoggingLevel.Trace; + case 'Debug': + return LoggingLevel.Debug; + case 'Info': + return LoggingLevel.Info; + case 'Warn': + return LoggingLevel.Warn; + case 'Error': + return LoggingLevel.Error; + case 'Fatal': + return LoggingLevel.Fatal; + case 'All': + return LoggingLevel.All; + default: + assertUnreachable(loggingLevelType); + } + } +} diff --git a/src/services/logger/LoggingThreshold.ts b/src/services/logger/LoggingThreshold.ts new file mode 100644 index 0000000..d441f83 --- /dev/null +++ b/src/services/logger/LoggingThreshold.ts @@ -0,0 +1,17 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import LoggerDefaults from './LoggerDefaults'; +import {LoggingLevel} from './Logger'; + +export default class LoggingThreshold { + private _threshold: LoggingLevel = LoggerDefaults.defaultLoggingLevel; + + get value(): LoggingLevel { + return this._threshold; + } + + setThreshold(threshold: LoggingLevel): void { + this._threshold = threshold; + } +} diff --git a/src/services/messaging/analytics.proto.json b/src/services/messaging/analytics.proto.json new file mode 100644 index 0000000..17725c1 --- /dev/null +++ b/src/services/messaging/analytics.proto.json @@ -0,0 +1,391 @@ +{ + "package": "analytics", + "messages": [ + { + "name": "Usage", + "fields": [ + { + "rule": "required", + "type": "uint64", + "name": "streams", + "id": 1 + }, + { + "rule": "required", + "type": "uint64", + "name": "users", + "id": 2 + }, + { + "rule": "required", + "type": "uint64", + "name": "devices", + "id": 3 + }, + { + "rule": "required", + "type": "uint64", + "name": "minutes", + "id": 4 + } + ] + }, + { + "name": "UsageByType", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "type", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "subtype", + "id": 2 + }, + { + "rule": "required", + "type": "Usage", + "name": "usage", + "id": 3 + } + ] + }, + { + "name": "UsageByCountry", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "continent", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "country", + "id": 2 + }, + { + "rule": "repeated", + "type": "UsageByType", + "name": "usageByType", + "id": 3 + } + ] + }, + { + "name": "GetGeographicUsage", + "fields": [ + { + "rule": "repeated", + "type": "string", + "name": "applicationIds", + "id": 1 + }, + { + "rule": "required", + "type": "uint64", + "name": "start", + "id": 2 + }, + { + "rule": "required", + "type": "uint64", + "name": "end", + "id": 3 + }, + { + "rule": "optional", + "type": "string", + "name": "continuationId", + "id": 4 + }, + { + "rule": "optional", + "type": "string", + "name": "routeKey", + "id": 5 + } + ] + }, + { + "name": "GetGeographicUsageResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "Usage", + "name": "usage", + "id": 2 + }, + { + "rule": "repeated", + "type": "UsageByType", + "name": "usageByType", + "id": 3 + }, + { + "rule": "repeated", + "type": "UsageByCountry", + "name": "usageByCountry", + "id": 4 + }, + { + "rule": "optional", + "type": "string", + "name": "continuationId", + "id": 5 + }, + { + "rule": "optional", + "type": "string", + "name": "routeKey", + "id": 6 + } + ] + }, + { + "name": "CDF", + "fields": [ + { + "rule": "repeated", + "type": "double", + "name": "data", + "id": 1 + } + ] + }, + { + "name": "GetTimeToFirstFrameCDF", + "fields": [ + { + "rule": "repeated", + "type": "string", + "name": "applicationIds", + "id": 1 + }, + { + "rule": "required", + "type": "uint64", + "name": "start", + "id": 2 + }, + { + "rule": "required", + "type": "uint64", + "name": "end", + "id": 3 + }, + { + "rule": "required", + "type": "Kind", + "name": "kind", + "id": 4 + }, + { + "rule": "optional", + "type": "string", + "name": "continuationId", + "id": 5 + }, + { + "rule": "optional", + "type": "string", + "name": "routeKey", + "id": 6 + } + ], + "enums": [ + { + "name": "Kind", + "values": [ + { + "name": "All", + "id": 0 + }, + { + "name": "RealTime", + "id": 1 + }, + { + "name": "Live", + "id": 2 + }, + { + "name": "Dash", + "id": 3 + }, + { + "name": "Hls", + "id": 4 + }, + { + "name": "PeerAssisted", + "id": 5 + } + ] + } + ] + }, + { + "name": "GetTimeToFirstFrameCDFResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "uint64", + "name": "count", + "id": 2 + }, + { + "rule": "optional", + "type": "double", + "name": "average", + "id": 3 + }, + { + "rule": "optional", + "type": "CDF", + "name": "cdf", + "id": 4 + }, + { + "rule": "optional", + "type": "string", + "name": "continuationId", + "id": 5 + }, + { + "rule": "optional", + "type": "string", + "name": "routeKey", + "id": 6 + } + ] + }, + { + "name": "GetActiveUsers", + "fields": [ + { + "rule": "repeated", + "type": "string", + "name": "applicationIds", + "id": 1 + }, + { + "rule": "required", + "type": "uint64", + "name": "snapshotTime", + "id": 2 + }, + { + "rule": "optional", + "type": "string", + "name": "continuationId", + "id": 3 + }, + { + "rule": "optional", + "type": "string", + "name": "routeKey", + "id": 4 + } + ] + }, + { + "name": "UsersAndSessionsGrouped", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "groupName", + "id": 1 + }, + { + "rule": "required", + "type": "uint64", + "name": "users", + "id": 2 + }, + { + "rule": "required", + "type": "uint64", + "name": "sessions", + "id": 3 + } + ] + }, + { + "name": "GetActiveUsersResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "uint64", + "name": "users", + "id": 2 + }, + { + "rule": "optional", + "type": "uint64", + "name": "sessions", + "id": 3 + }, + { + "rule": "repeated", + "type": "UsersAndSessionsGrouped", + "name": "byPlatform", + "id": 4 + }, + { + "rule": "repeated", + "type": "UsersAndSessionsGrouped", + "name": "byManufacturer", + "id": 5 + }, + { + "rule": "repeated", + "type": "UsersAndSessionsGrouped", + "name": "byCity", + "id": 6 + }, + { + "rule": "repeated", + "type": "UsersAndSessionsGrouped", + "name": "byCountry", + "id": 7 + }, + { + "rule": "optional", + "type": "string", + "name": "continuationId", + "id": 8 + }, + { + "rule": "optional", + "type": "string", + "name": "routeKey", + "id": 9 + } + ] + } + ] +} diff --git a/src/services/messaging/pcast.proto.json b/src/services/messaging/pcast.proto.json new file mode 100644 index 0000000..d3fdd3b --- /dev/null +++ b/src/services/messaging/pcast.proto.json @@ -0,0 +1,1777 @@ +{ + "package": "pcast", + "messages": [ + { + "name": "Authenticate", + "fields": [ + { + "rule": "optional", + "type": "uint32", + "name": "apiVersion", + "id": 9, + "options": { + "default": 0 + } + }, + { + "rule": "required", + "type": "string", + "name": "clientVersion", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "device", + "id": 12 + }, + { + "rule": "required", + "type": "string", + "name": "deviceId", + "id": 2 + }, + { + "rule": "optional", + "type": "string", + "name": "manufacturer", + "id": 13 + }, + { + "rule": "required", + "type": "string", + "name": "platform", + "id": 3 + }, + { + "rule": "required", + "type": "string", + "name": "platformVersion", + "id": 4 + }, + { + "rule": "required", + "type": "string", + "name": "authenticationToken", + "id": 5 + }, + { + "rule": "optional", + "type": "string", + "name": "connectionId", + "id": 6 + }, + { + "rule": "optional", + "type": "string", + "name": "connectionRouteKey", + "id": 10 + }, + { + "rule": "optional", + "type": "string", + "name": "remoteAddress", + "id": 11 + }, + { + "rule": "optional", + "type": "string", + "name": "sessionId", + "id": 7 + }, + { + "rule": "optional", + "type": "string", + "name": "applicationId", + "id": 8 + } + ] + }, + { + "name": "AuthenticateResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "sessionId", + "id": 2 + }, + { + "rule": "optional", + "type": "string", + "name": "redirect", + "id": 3 + }, + { + "rule": "repeated", + "type": "string", + "name": "roles", + "id": 4 + } + ] + }, + { + "name": "Bye", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "reason", + "id": 2 + } + ] + }, + { + "name": "ByeResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + } + ] + }, + { + "name": "SessionDescription", + "fields": [ + { + "rule": "required", + "type": "Type", + "name": "type", + "id": 1, + "options": { + "default": "Offer" + } + }, + { + "rule": "required", + "type": "string", + "name": "sdp", + "id": 2 + } + ], + "enums": [ + { + "name": "Type", + "values": [ + { + "name": "Offer", + "id": 0 + }, + { + "name": "Answer", + "id": 1 + } + ] + } + ] + }, + { + "name": "CreateStream", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "originStreamId", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 3 + }, + { + "rule": "optional", + "type": "string", + "name": "connectUri", + "id": 8 + }, + { + "rule": "repeated", + "type": "string", + "name": "connectOptions", + "id": 9 + }, + { + "rule": "repeated", + "type": "string", + "name": "tags", + "id": 4 + }, + { + "rule": "optional", + "type": "SetRemoteDescription", + "name": "setRemoteDescription", + "id": 5 + }, + { + "rule": "optional", + "type": "CreateOfferDescription", + "name": "createOfferDescription", + "id": 6 + }, + { + "rule": "optional", + "type": "CreateAnswerDescription", + "name": "createAnswerDescription", + "id": 7 + } + ] + }, + { + "name": "IceServer", + "fields": [ + { + "rule": "repeated", + "type": "string", + "name": "urls", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "username", + "id": 2 + }, + { + "rule": "optional", + "type": "string", + "name": "credential", + "id": 3 + } + ] + }, + { + "name": "RtcConfiguration", + "fields": [ + { + "rule": "optional", + "type": "BundlePolicy", + "name": "bundlePolicy", + "id": 1 + }, + { + "rule": "optional", + "type": "uint32", + "name": "iceCandidatePoolSize", + "id": 3 + }, + { + "rule": "repeated", + "type": "IceServer", + "name": "iceServers", + "id": 4 + }, + { + "rule": "optional", + "type": "IceTransportPolicy", + "name": "iceTransportPolicy", + "id": 5 + }, + { + "rule": "optional", + "type": "string", + "name": "peerIdentity", + "id": 6 + }, + { + "rule": "optional", + "type": "RtcpMuxPolicy", + "name": "rtcpMuxPolicy", + "id": 7 + } + ], + "enums": [ + { + "name": "BundlePolicy", + "values": [ + { + "name": "BundlePolicyBalanced", + "id": 1 + }, + { + "name": "BundlePolicyMaxCompat", + "id": 2 + }, + { + "name": "BundlePolicyMaxBundle", + "id": 3 + } + ] + }, + { + "name": "IceTransportPolicy", + "values": [ + { + "name": "IceTransportPolicyAll", + "id": 1 + }, + { + "name": "IceTransportPolicyPublic", + "id": 2 + }, + { + "name": "IceTransportPolicyRelay", + "id": 3 + } + ] + }, + { + "name": "RtcpMuxPolicy", + "values": [ + { + "name": "RtcpMuxPolicyNegotiate", + "id": 1 + }, + { + "name": "RtcpMuxPolicyRequire", + "id": 2 + } + ] + } + ] + }, + { + "name": "CreateStreamResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "streamId", + "id": 2 + }, + { + "rule": "optional", + "type": "string", + "name": "instanceRouteKey", + "id": 5 + }, + { + "rule": "repeated", + "type": "string", + "name": "streamUris", + "id": 8 + }, + { + "rule": "optional", + "type": "RtcConfiguration", + "name": "rtcConfiguration", + "id": 9 + }, + { + "rule": "optional", + "type": "SetRemoteDescriptionResponse", + "name": "setRemoteDescriptionResponse", + "id": 3 + }, + { + "rule": "optional", + "type": "CreateOfferDescriptionResponse", + "name": "createOfferDescriptionResponse", + "id": 4 + }, + { + "rule": "optional", + "type": "CreateAnswerDescriptionResponse", + "name": "createAnswerDescriptionResponse", + "id": 6 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 7 + }, + { + "rule": "optional", + "type": "uint64", + "name": "offset", + "id": 10, + "options": { + "default": 0 + } + } + ] + }, + { + "name": "SetLocalDescription", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 1 + }, + { + "rule": "required", + "type": "SessionDescription", + "name": "sessionDescription", + "id": 2 + }, + { + "rule": "optional", + "type": "uint32", + "name": "apiVersion", + "id": 3, + "options": { + "default": 0 + } + } + ] + }, + { + "name": "SetLocalDescriptionResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 2 + } + ] + }, + { + "name": "SetRemoteDescription", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 1 + }, + { + "rule": "required", + "type": "SessionDescription", + "name": "sessionDescription", + "id": 2 + }, + { + "rule": "optional", + "type": "uint32", + "name": "apiVersion", + "id": 3, + "options": { + "default": 0 + } + } + ] + }, + { + "name": "SetRemoteDescriptionResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "SessionDescription", + "name": "sessionDescription", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 3 + } + ] + }, + { + "name": "CreateOfferDescription", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 1 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 2 + }, + { + "rule": "optional", + "type": "uint32", + "name": "apiVersion", + "id": 3, + "options": { + "default": 0 + } + } + ] + }, + { + "name": "CreateOfferDescriptionResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "SessionDescription", + "name": "sessionDescription", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 3 + } + ] + }, + { + "name": "CreateAnswerDescription", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 1 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 2 + }, + { + "rule": "optional", + "type": "uint32", + "name": "apiVersion", + "id": 3, + "options": { + "default": 0 + } + } + ] + }, + { + "name": "CreateAnswerDescriptionResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "SessionDescription", + "name": "sessionDescription", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 3 + } + ] + }, + { + "name": "IceCandidate", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "candidate", + "id": 1 + }, + { + "rule": "required", + "type": "uint32", + "name": "sdpMLineIndex", + "id": 2 + }, + { + "rule": "required", + "type": "string", + "name": "sdpMid", + "id": 3 + } + ] + }, + { + "name": "AddIceCandidates", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 1 + }, + { + "rule": "repeated", + "type": "IceCandidate", + "name": "candidates", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 3 + }, + { + "rule": "optional", + "type": "uint32", + "name": "apiVersion", + "id": 4, + "options": { + "default": 0 + } + } + ] + }, + { + "name": "AddIceCandidatesResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 2 + } + ] + }, + { + "name": "UpdateStreamState", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "signalingState", + "id": 2 + }, + { + "rule": "required", + "type": "string", + "name": "iceGatheringState", + "id": 3 + }, + { + "rule": "required", + "type": "string", + "name": "iceConnectionState", + "id": 4 + }, + { + "rule": "optional", + "type": "uint32", + "name": "apiVersion", + "id": 5, + "options": { + "default": 0 + } + } + ] + }, + { + "name": "UpdateStreamStateResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 2 + } + ] + }, + { + "name": "DestroyStream", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "reason", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 3 + } + ] + }, + { + "name": "DestroyStreamResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + } + ] + }, + { + "name": "ConnectionDisconnected", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "connectionId", + "id": 1 + }, + { + "rule": "required", + "type": "uint32", + "name": "reasonCode", + "id": 2 + }, + { + "rule": "optional", + "type": "string", + "name": "description", + "id": 3 + } + ] + }, + { + "name": "ConnectionDisconnectedResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + } + ] + }, + { + "name": "StreamStarted", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "tags", + "id": 3 + } + ] + }, + { + "name": "SourceStreamStarted", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "capabilities", + "id": 3 + }, + { + "rule": "repeated", + "type": "string", + "name": "tags", + "id": 4 + } + ] + }, + { + "name": "StreamEnded", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 2 + }, + { + "rule": "required", + "type": "string", + "name": "reason", + "id": 3 + }, + { + "rule": "repeated", + "type": "string", + "name": "tags", + "id": 4 + }, + { + "rule": "optional", + "type": "string", + "name": "continuationId", + "id": 5 + }, + { + "rule": "optional", + "type": "string", + "name": "routeKey", + "id": 6 + } + ] + }, + { + "name": "SourceStreamEnded", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 2 + }, + { + "rule": "required", + "type": "string", + "name": "reason", + "id": 3 + }, + { + "rule": "repeated", + "type": "string", + "name": "capabilities", + "id": 4 + }, + { + "rule": "repeated", + "type": "string", + "name": "tags", + "id": 5 + } + ] + }, + { + "name": "StreamEndedResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "continuationId", + "id": 2 + }, + { + "rule": "optional", + "type": "string", + "name": "routeKey", + "id": 3 + } + ] + }, + { + "name": "StreamIdle", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 3 + }, + { + "rule": "repeated", + "type": "string", + "name": "tags", + "id": 4 + } + ] + }, + { + "name": "StreamArchived", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 2 + }, + { + "rule": "required", + "type": "uint64", + "name": "startTime", + "id": 4 + }, + { + "rule": "required", + "type": "string", + "name": "uri", + "id": 3 + } + ] + }, + { + "name": "SessionEnded", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "reason", + "id": 2 + }, + { + "rule": "required", + "type": "float", + "name": "duration", + "id": 3 + } + ] + }, + { + "name": "ResourceIdle", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "routeKey", + "id": 2 + } + ] + }, + { + "name": "ResourceIdleResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + } + ] + }, + { + "name": "StreamPlaylist", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 2 + }, + { + "rule": "required", + "type": "PlaylistType", + "name": "playlistType", + "id": 3 + }, + { + "rule": "required", + "type": "string", + "name": "uri", + "id": 4 + } + ], + "enums": [ + { + "name": "PlaylistType", + "values": [ + { + "name": "Live", + "id": 0 + }, + { + "name": "OnDemand", + "id": 1 + } + ] + } + ] + }, + { + "name": "SendEventToClient", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "connectionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "type", + "id": 2 + }, + { + "rule": "required", + "type": "bytes", + "name": "payload", + "id": 3 + } + ] + }, + { + "name": "SendEventToClientResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + } + ] + }, + { + "name": "SendRequestToClient", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "connectionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "type", + "id": 2 + }, + { + "rule": "required", + "type": "bytes", + "name": "payload", + "id": 3 + } + ] + }, + { + "name": "SendRequestToClientResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "type", + "id": 2 + }, + { + "rule": "optional", + "type": "bytes", + "name": "payload", + "id": 3 + } + ] + }, + { + "name": "SetupStream", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "streamToken", + "id": 1 + }, + { + "rule": "required", + "type": "CreateStream", + "name": "createStream", + "id": 2 + } + ] + }, + { + "name": "SetupStreamResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "CreateStreamResponse", + "name": "createStreamResponse", + "id": 2 + } + ] + }, + { + "name": "SetupPlaylistStream", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "streamToken", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 3 + }, + { + "rule": "repeated", + "type": "string", + "name": "tags", + "id": 4 + } + ] + }, + { + "name": "PlaylistStreamManifest", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "manifest", + "id": 1 + }, + { + "rule": "required", + "type": "bool", + "name": "isProtectedContent", + "id": 2 + } + ] + }, + { + "name": "SetupPlaylistStreamResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "repeated", + "type": "PlaylistStreamManifest", + "name": "manifests", + "id": 2 + }, + { + "rule": "optional", + "type": "uint64", + "name": "offset", + "id": 3, + "options": { + "default": 0 + } + } + ] + }, + { + "name": "StreamDataQuality", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 2 + }, + { + "rule": "required", + "type": "uint64", + "name": "timestamp", + "id": 3 + }, + { + "rule": "required", + "type": "DataQualityStatus", + "name": "status", + "id": 4 + }, + { + "rule": "required", + "type": "DataQualityReason", + "name": "reason", + "id": 5 + } + ], + "enums": [ + { + "name": "DataQualityStatus", + "values": [ + { + "name": "NoData", + "id": 0 + }, + { + "name": "AudioOnly", + "id": 1 + }, + { + "name": "All", + "id": 2 + } + ] + }, + { + "name": "DataQualityReason", + "values": [ + { + "name": "None", + "id": 0 + }, + { + "name": "UploadLimited", + "id": 1 + }, + { + "name": "DownloadLimited", + "id": 2 + }, + { + "name": "PublisherLimited", + "id": 3 + }, + { + "name": "NetworkLimited", + "id": 4 + } + ] + } + ] + }, + { + "name": "StreamDataQualityResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + } + ] + }, + { + "name": "CallbackEvent", + "fields": [ + { + "rule": "optional", + "type": "uint32", + "name": "apiVersion", + "id": 1, + "options": { + "default": 0 + } + }, + { + "rule": "required", + "type": "string", + "name": "entity", + "id": 2 + }, + { + "rule": "required", + "type": "string", + "name": "what", + "id": 3 + }, + { + "rule": "required", + "type": "string", + "name": "data", + "id": 4 + }, + { + "rule": "optional", + "type": "string", + "name": "sessionId", + "id": 5 + } + ] + }, + { + "name": "Uri", + "fields": [ + { + "rule": "optional", + "type": "string", + "name": "protocol", + "id": 1, + "options": { + "default": "http" + } + }, + { + "rule": "required", + "type": "string", + "name": "host", + "id": 2 + }, + { + "rule": "optional", + "type": "uint32", + "name": "port", + "id": 3, + "options": { + "default": 80 + } + }, + { + "rule": "optional", + "type": "string", + "name": "method", + "id": 4, + "options": { + "default": "POST" + } + }, + { + "rule": "optional", + "type": "string", + "name": "path", + "id": 5, + "options": { + "default": "/" + } + } + ] + }, + { + "name": "SetApplicationCallback", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "applicationId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "secret", + "id": 2 + }, + { + "rule": "required", + "type": "Uri", + "name": "callback", + "id": 3 + } + ] + }, + { + "name": "SetApplicationCallbackResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + } + ] + }, + { + "name": "IssueAuthenticationToken", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "applicationId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "secret", + "id": 2 + }, + { + "rule": "repeated", + "type": "string", + "name": "capabilities", + "id": 3 + } + ] + }, + { + "name": "IssueAuthenticationTokenResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "authenticationToken", + "id": 2 + } + ] + }, + { + "name": "IssueStreamToken", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "applicationId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "secret", + "id": 2 + }, + { + "rule": "required", + "type": "string", + "name": "sessionId", + "id": 3 + }, + { + "rule": "optional", + "type": "string", + "name": "originStreamId", + "id": 4 + }, + { + "rule": "repeated", + "type": "string", + "name": "capabilities", + "id": 5 + } + ] + }, + { + "name": "IssueStreamTokenResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "streamToken", + "id": 2 + } + ] + }, + { + "name": "IssueDrmToken", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "applicationId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "secret", + "id": 2 + }, + { + "rule": "optional", + "type": "string", + "name": "sessionId", + "id": 3 + }, + { + "rule": "required", + "type": "string", + "name": "originStreamId", + "id": 4 + }, + { + "rule": "repeated", + "type": "string", + "name": "capabilities", + "id": 5 + } + ] + }, + { + "name": "IssueDrmTokenResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "drmToken", + "id": 2 + } + ] + }, + { + "name": "TerminateStream", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "applicationId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "secret", + "id": 2 + }, + { + "rule": "optional", + "type": "string", + "name": "streamId", + "id": 3, + "oneof": "streamOrToken" + }, + { + "rule": "optional", + "type": "string", + "name": "streamToken", + "id": 5, + "oneof": "streamOrToken" + }, + { + "rule": "optional", + "type": "string", + "name": "reason", + "id": 4 + } + ], + "oneofs": { + "streamOrToken": [3, 5] + } + }, + { + "name": "TerminateStreamResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + } + ] + }, + { + "name": "Stream", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "streamId", + "id": 1 + } + ] + }, + { + "name": "ListStreams", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "applicationId", + "id": 1 + }, + { + "rule": "required", + "type": "string", + "name": "secret", + "id": 2 + }, + { + "rule": "optional", + "type": "string", + "name": "start", + "id": 3 + }, + { + "rule": "required", + "type": "uint32", + "name": "length", + "id": 4 + }, + { + "rule": "repeated", + "type": "string", + "name": "options", + "id": 5 + } + ] + }, + { + "name": "ListStreamsResponse", + "fields": [ + { + "rule": "required", + "type": "string", + "name": "status", + "id": 1 + }, + { + "rule": "optional", + "type": "string", + "name": "start", + "id": 2 + }, + { + "rule": "optional", + "type": "uint32", + "name": "length", + "id": 3 + }, + { + "rule": "repeated", + "type": "Stream", + "name": "streams", + "id": 4 + } + ] + } + ] +} diff --git a/src/services/net/PhenixWebSocket.ts b/src/services/net/PhenixWebSocket.ts new file mode 100644 index 0000000..08e375c --- /dev/null +++ b/src/services/net/PhenixWebSocket.ts @@ -0,0 +1,140 @@ +import {MQWebSocket} from 'phenix-web-proto'; +import {ILogger} from 'services/logger/LoggerInterface'; +import LoggerFactory from 'services/logger/LoggerFactory'; +import assertUnreachable from 'lang/assertUnreachable'; +import {Subject, ReadOnlySubject, IDisposable} from '@techniker-me/tools'; +import pcastProtocol from 'services/messaging/pcast.proto.json'; +import analyticsProtocol from 'services/messaging/analytics.proto.json'; + +export enum PhenixWebSocketStatus { + Offline = 0, + Connecting = 1, + Online = 2, + Reconnecting = 3, + Error = 4 +} + +export type PhenixWebSocketStatusType = 'Offline' | 'Connecting' | 'Online' | 'Reconnecting' | 'Error'; + +export class PhenixWebSocketStatusMapping { + public static convertPhenixWebSocketStatusToPhenixWebSocketStatusType(status: PhenixWebSocketStatus): PhenixWebSocketStatusType { + switch (status) { + case PhenixWebSocketStatus.Error: + return 'Error'; + case PhenixWebSocketStatus.Offline: + return 'Offline'; + case PhenixWebSocketStatus.Connecting: + return 'Connecting'; + case PhenixWebSocketStatus.Online: + return 'Online'; + case PhenixWebSocketStatus.Reconnecting: + return 'Reconnecting'; + default: + assertUnreachable(status); + } + } + + public static convertPhenixWebSocketStatusTypeToPhenixWebSocketStatus(statusType: PhenixWebSocketStatusType): PhenixWebSocketStatus { + switch (statusType) { + case 'Error': + return PhenixWebSocketStatus.Error; + case 'Offline': + return PhenixWebSocketStatus.Offline; + case 'Connecting': + return PhenixWebSocketStatus.Connecting; + case 'Online': + return PhenixWebSocketStatus.Online; + case 'Reconnecting': + return PhenixWebSocketStatus.Reconnecting; + default: + assertUnreachable(statusType); + } + } +} + +export enum PhenixWebSocketMessage { + Authenticate = 'pcast.Authenticate', + Bye = 'pcast.Bye' +} + +export class PhenixWebSocket implements IDisposable { + private readonly _logger: ILogger = LoggerFactory.getLogger('PhenixWebSocket'); + private readonly _status: Subject = new Subject(PhenixWebSocketStatus.Offline); + private readonly _readOnlyStatus: ReadOnlySubject = new ReadOnlySubject(this._status); + private readonly _websocketMQ: MQWebSocket; + + constructor(uri: string) { + console.log('PhenixWebSocket constructor', uri); + this._websocketMQ = new MQWebSocket(uri, this._logger, [pcastProtocol, analyticsProtocol]); + this.initialize(); + } + + get status(): ReadOnlySubject { + return this._readOnlyStatus; + } + + public isConnected(): boolean { + return this._status.value === PhenixWebSocketStatus.Online; + } + + public isConnecting(): boolean { + return this._status.value === PhenixWebSocketStatus.Connecting; + } + + public isReconnecting(): boolean { + return this._status.value === PhenixWebSocketStatus.Reconnecting; + } + + public sendMessage(message: PhenixWebSocketMessage, data: T): Promise { + if (!Object.values(PhenixWebSocketMessage).includes(message)) { + throw new Error(`Invalid message: ${message}`); + } + + // Check if WebSocket is in a valid state for sending messages + const currentStatus = this._status.value; + if (currentStatus !== PhenixWebSocketStatus.Online) { + return Promise.reject(new Error(`WebSocket is not ready. Current status: ${PhenixWebSocketStatus[currentStatus]}`)); + } + + return new Promise((resolve, reject) => { + return this._websocketMQ.sendRequest(message, data, (error: unknown, response: unknown) => { + if (error) { + reject(error); + } else { + resolve({error, response}); + } + }); + }); + } + + public dispose(): void { + this._websocketMQ.disconnect(); + } + + private initialize(): void { + this._websocketMQ.onEvent('connecting', () => { + this._logger.info('WebSocket connecting...'); + this._status.value = PhenixWebSocketStatus.Connecting; + }); + + this._websocketMQ.onEvent('connected', () => { + this._logger.info('WebSocket connected successfully'); + this._status.value = PhenixWebSocketStatus.Online; + }); + + this._websocketMQ.onEvent('disconnected', () => { + this._logger.info('WebSocket disconnected'); + this._status.value = PhenixWebSocketStatus.Offline; + }); + + this._websocketMQ.onEvent('reconnecting', () => { + this._logger.info('WebSocket reconnecting...'); + this._status.value = PhenixWebSocketStatus.Reconnecting; + }); + + this._websocketMQ.onEvent('error', (error: unknown) => { + this._logger.error('WebSocket error occurred:', error); + this._status.value = PhenixWebSocketStatus.Error; + }); + } +} diff --git a/src/services/platform-detection.service.ts b/src/services/platform-detection.service.ts new file mode 100644 index 0000000..643a6d1 --- /dev/null +++ b/src/services/platform-detection.service.ts @@ -0,0 +1,165 @@ +export default class PlatformDetectionService { + private static _userAgent: string = navigator.userAgent; + private static _areClientHintsSupported: boolean = 'userAgentData' in navigator; + private static _platform: string = '?'; + private static _platformVersion: string = '?'; + private static _browser: string = 'Unknown'; + private static _version: string | number = '?'; + private static _isWebview: boolean = false; + private static _initialized: boolean = false; + + private constructor() { + throw new Error('PlatformDetectionService is a static class that may not be instantiated'); + } + + static get platform(): string { + this.initializeIfNeeded(); + return this._platform; + } + + static get platformVersion(): string { + this.initializeIfNeeded(); + return this._platformVersion; + } + + static get userAgent(): string { + return this._userAgent; + } + + static get browser(): string { + this.initializeIfNeeded(); + return this._browser; + } + + static get version(): string | number { + this.initializeIfNeeded(); + return this._version; + } + + static get isWebview(): boolean { + this.initializeIfNeeded(); + return this._isWebview; + } + + static get areClientHintsSupported(): boolean { + return this._areClientHintsSupported; + } + + private static initializeIfNeeded(): void { + if (this._initialized) return; + this.initialize(); + } + + private static initialize(): void { + try { + const browserVersionMatch = this._userAgent.match(/(Chrome|Chromium|Firefox|Opera|Safari|Edge|OPR)\/([0-9]+)/); + + if (browserVersionMatch) { + const [, browser, version] = browserVersionMatch; + PlatformDetectionService._browser = browser === 'OPR' ? 'Opera' : browser; + PlatformDetectionService._version = parseInt(version, 10)?.toString() || '?'; + } else if (this._userAgent.match(/^\(?Mozilla/)) { + PlatformDetectionService._browser = 'Mozilla'; + + // Check for IE/Edge + if (this._userAgent.match(/MSIE/) || this._userAgent.match(/; Trident\/.*rv:[0-9]+/)) { + PlatformDetectionService._browser = 'IE'; + const ieVersionMatch = this._userAgent.match(/rv:([0-9]+)/); + + if (ieVersionMatch) { + PlatformDetectionService._version = parseInt(ieVersionMatch[1], 10)?.toString() || '?'; + } + } else if (this._userAgent.match(/Edge\//)) { + PlatformDetectionService._browser = 'Edge'; + const edgeVersionMatch = this._userAgent.match(/Edge\/([0-9]+)/); + + if (edgeVersionMatch) { + PlatformDetectionService._version = parseInt(edgeVersionMatch[1], 10)?.toString() || '?'; + } + } + } + + // Handle Opera masquerading as other browsers + if (this._userAgent.match(/OPR\//)) { + PlatformDetectionService._browser = 'Opera'; + const operaVersionMatch = this._userAgent.match(/OPR\/([0-9]+)/); + if (operaVersionMatch) { + PlatformDetectionService._version = parseInt(operaVersionMatch[1], 10)?.toString() || '?'; + } + } + + // Safari and iOS webviews + if (this._userAgent.match(/AppleWebKit/i)) { + if (this._userAgent.match(/iphone|ipod|ipad/i)) { + PlatformDetectionService._browser = 'Safari'; + PlatformDetectionService._isWebview = true; + const iosVersionMatch = this._userAgent.match(/OS\s([0-9]+)/); + if (iosVersionMatch) { + PlatformDetectionService._version = parseInt(iosVersionMatch[1], 10)?.toString() || '?'; + } + } else if (this._userAgent.match(/Safari\//) && !this._userAgent.match(/Chrome/)) { + PlatformDetectionService._browser = 'Safari'; + const safariVersionMatch = this._userAgent.match(/Version\/([0-9]+)/); + if (safariVersionMatch) { + PlatformDetectionService._version = parseInt(safariVersionMatch[1], 10)?.toString() || '?'; + } + } + } + + // Android webviews + if (this._userAgent.match(/; wv/) || (this._userAgent.match(/Android/) && this._userAgent.match(/Version\/[0-9].[0-9]/))) { + PlatformDetectionService._isWebview = true; + } + + // React Native + if (globalThis.navigator.product === 'ReactNative') { + PlatformDetectionService._browser = 'ReactNative'; + PlatformDetectionService._version = navigator.productSub || '?'; + } + + // platform information + if (this._userAgent.match(/Windows/)) { + PlatformDetectionService._platform = 'Windows'; + const windowsVersionMatch = this._userAgent.match(/Windows NT ([0-9.]+)/); + + if (windowsVersionMatch) { + PlatformDetectionService._platformVersion = windowsVersionMatch[1]; + } + } else if (this._userAgent.match(/Mac OS X/)) { + PlatformDetectionService._platform = 'macOS'; + const macVersionMatch = this._userAgent.match(/Mac OS X ([0-9._]+)/); + + if (macVersionMatch) { + PlatformDetectionService._platformVersion = macVersionMatch[1].replace(/_/g, '.'); + } + } else if (this._userAgent.match(/Linux/)) { + PlatformDetectionService._platform = 'Linux'; + } else if (this._userAgent.match(/Android/)) { + PlatformDetectionService._platform = 'Android'; + const androidVersionMatch = this._userAgent.match(/Android ([0-9.]+)/); + + if (androidVersionMatch) { + PlatformDetectionService._platformVersion = androidVersionMatch[1]; + } + } else if (this._userAgent.match(/iPhone|iPad|iPod/)) { + PlatformDetectionService._platform = 'iOS'; + const iosVersionMatch = this._userAgent.match(/OS ([0-9_]+)/); + + if (iosVersionMatch) { + PlatformDetectionService._platformVersion = iosVersionMatch[1].replace(/_/g, '.'); + } + } + + this._initialized = true; + } catch (error) { + console.warn('Failed to initialize PlatformDetectionService:', error); + // fallback values + this._browser = 'Unknown'; + this._version = '?'; + this._platform = '?'; + this._platformVersion = '?'; + this._isWebview = false; + this._initialized = true; + } + } +} diff --git a/src/services/telemetry/TelemetryApender.ts b/src/services/telemetry/TelemetryApender.ts new file mode 100644 index 0000000..746c22b --- /dev/null +++ b/src/services/telemetry/TelemetryApender.ts @@ -0,0 +1,25 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import TelemetryService from './TelemetryService'; +import TelemetryConfiguration from './TelemetryConfiguration'; +import {IAppender} from '../logger/IAppender'; +import {LoggingLevel} from '../logger/Logger'; + +export default class TelemetryAppender implements IAppender { + private readonly _telemetryService: TelemetryService; + private readonly _threshold: LoggingLevel; + + constructor(telemetryConfiguration: TelemetryConfiguration) { + this._threshold = telemetryConfiguration.threshold; + this._telemetryService = new TelemetryService(telemetryConfiguration); + } + + async log(logLevel: LoggingLevel, message: string, category: string, date: Date): Promise { + if (logLevel < this._threshold) { + return; + } + + this._telemetryService.push(logLevel, message, category, date); + } +} diff --git a/src/services/telemetry/TelemetryConfiguration.ts b/src/services/telemetry/TelemetryConfiguration.ts new file mode 100644 index 0000000..844b82d --- /dev/null +++ b/src/services/telemetry/TelemetryConfiguration.ts @@ -0,0 +1,75 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import {LoggingLevel} from '../logger/Logger'; +import LoggerDefaults from '../logger/LoggerDefaults'; + +export default class TelemetryConfiguration { + private _url = 'https://telemetry.phenixrts.com/telemetry/logs'; + private _tenancy = ''; + private _userId = ''; + private _sessionId = ''; + private _environment = ''; + private _threshold = LoggerDefaults.defaultTelemetryLoggingLevel; + private _browser = ''; + + get url(): string { + return this._url; + } + + set url(url: string) { + const telemetryUrl = new URL(url); + + telemetryUrl.pathname = telemetryUrl.pathname + '/logs'; + + this._url = telemetryUrl.toString(); + } + + get environment(): string { + return this._environment; + } + + set environment(environment: string) { + this._environment = environment; + } + + get browser(): string { + return this._browser; + } + + set browser(browser: string) { + this._browser = browser; + } + + get tenancy(): string { + return this._tenancy; + } + + set tenancy(tenancy: string) { + this._tenancy = tenancy; + } + + get userId(): string { + return this._userId; + } + + set userId(userId: string) { + this._userId = userId; + } + + get sessionId(): string { + return this._sessionId; + } + + set sessionId(sessionId: string) { + this._sessionId = sessionId; + } + + get threshold(): LoggingLevel { + return this._threshold; + } + + set threshold(threshold: LoggingLevel) { + this._threshold = threshold; + } +} diff --git a/src/services/telemetry/TelemetryService.ts b/src/services/telemetry/TelemetryService.ts new file mode 100644 index 0000000..724f700 --- /dev/null +++ b/src/services/telemetry/TelemetryService.ts @@ -0,0 +1,137 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ +import config from 'config'; +import {LoggingLevel} from '../logger/Logger'; +import TelemetryConfiguration from './TelemetryConfiguration'; + +// Extend Window interface to include custom properties +declare global { + interface Window { + __phenixPageLoadTime?: number; + __pageLoadTime?: number; + } +} + +const requestSizeLimit = 8192; +const pageLoadTime = window.__phenixPageLoadTime || window.__pageLoadTime || Date.now(); + +interface ILogItem { + timestamp: string; + tenancy: string; + level: string; + category: string; + message: string; + sessionId: string; + userId: string; + version: string; + environment: string; + fullQualifiedName: string; + source: string; + runtime: number; +} + +export default class TelemetryService { + private readonly _telemetryConfiguration: TelemetryConfiguration; + + private _logs: Array = []; + private _isSending: boolean = false; + private _domain = location.hostname; + + constructor(telemetryConfiguration: TelemetryConfiguration) { + this._telemetryConfiguration = telemetryConfiguration; + } + + push(logLevel: LoggingLevel, message: string, category: string, timestamp: Date): void { + const now = Date.now(); + const runtime = (now - pageLoadTime) / 1000; + const logRecord = { + timestamp: timestamp.toISOString(), + tenancy: this._telemetryConfiguration.tenancy, + userId: this._telemetryConfiguration.userId, + level: LoggingLevel[logLevel], + runtime, + category, + message, + sessionId: this._telemetryConfiguration.sessionId, + version: config.controlCenterVersion, + environment: this._telemetryConfiguration.environment, + fullQualifiedName: this._domain, + source: `Portal (${this._telemetryConfiguration.browser})` + } as ILogItem; + + if (logLevel < LoggingLevel.Error) { + this._logs.push(logRecord); + } else { + this._logs.unshift(logRecord); + } + + + + // @ts-expect-error: Unused variable intentionally + const ignored = this.sendLogsIfAble(); + } + + private async sendLogs(logMessages: Array): Promise { + const formData = new FormData(); + + formData.append('jsonBody', JSON.stringify({records: logMessages})); + + return await fetch(this._telemetryConfiguration.url, { + method: 'POST', + body: formData + }); + } + + private async sendLogsIfAble(): Promise { + if (this._logs.length <= 0 || this._isSending) { + return; + } + + let numberOfLogsToSend = 0; + let sizeOfLogsToSend = 0; + + this._isSending = true; + + const getLogSize = (log: ILogItem): number => Object.values(log).reduce((sum, item) => sum + (item ? `${item}`.length : 0), 0); + + while (this._logs.length > numberOfLogsToSend && getLogSize(this._logs[numberOfLogsToSend]) + sizeOfLogsToSend < requestSizeLimit) { + sizeOfLogsToSend += getLogSize(this._logs[numberOfLogsToSend]); + numberOfLogsToSend++; + } + + if (!numberOfLogsToSend) { + this._logs[numberOfLogsToSend].message = this._logs[numberOfLogsToSend].message.substring( + 0, + getLogSize(this._logs[numberOfLogsToSend]) + (requestSizeLimit - getLogSize(this._logs[numberOfLogsToSend])) + ); + numberOfLogsToSend = 1; + } + + const logMessages = this._logs.slice(0, numberOfLogsToSend); + + this._logs = this._logs.slice(numberOfLogsToSend); + + return this.sendLogs(logMessages) + .then(response => { + this._isSending = false; + + + + // @ts-expect-error: Unused variable intentionally + + const ignored = this.sendLogsIfAble(); + + return response; + }) + .catch(() => { + this._isSending = false; + + + + // @ts-expect-error: Unused variable intentionally + + const ignored = this.sendLogsIfAble(); + }); + } +} diff --git a/src/services/url.service.ts b/src/services/url.service.ts new file mode 100644 index 0000000..7d8c632 --- /dev/null +++ b/src/services/url.service.ts @@ -0,0 +1,76 @@ +/** + * Copyright 2024 Phenix Real Time Solutions, Inc. Confidential and Proprietary. All Rights Reserved. + */ + +export type UrlType = 'pcast' | 'telemetry' | 'websocket'; + +export default class UrlService { + private static getBaseUrlOrigin(url: string = window?.location?.href, urlType: UrlType = 'pcast'): string { + if (!url) { + throw new Error('Invalid URL'); + } + + const baseURL = new URL(url); + const backendUrlParam = baseURL.searchParams.get('backend'); + + if (backendUrlParam) { + const backendUrl = new URL(backendUrlParam); + if (urlType === 'websocket') { + backendUrl.protocol = 'wss:'; + backendUrl.pathname = '/ws'; + } + + return backendUrl.toString(); + } + + const segments = baseURL.hostname.split('.'); + const prefix = urlType === 'telemetry' ? 'telemetry' : 'pcast'; + + // Normalize WebSocket protocols to HTTP + switch (baseURL.protocol) { + case 'ws:': + baseURL.protocol = 'http:'; + break; + case 'wss:': + baseURL.protocol = 'https:'; + break; + default: + break; + } + + // Apply routing logic based on URL structure + if (segments.length === 2 || (segments.length === 3 && segments[segments.length - 2].length <= 2 && segments[segments.length - 1].length <= 3)) { + segments.unshift(prefix); + } else if (segments[0].startsWith('stg-') || segments[0].endsWith('-stg') || segments[0].includes('-stg-') || segments[0] === 'stg') { + segments[0] = `${prefix}-stg`; + } else if (segments[0].startsWith('local') || segments[0].endsWith('-local')) { + // Leave URL unchanged for local development + } else { + segments[0] = prefix; + } + + baseURL.hostname = segments.join('.'); + + + + return baseURL.origin; + } + + public static getPcastBaseUrlOrigin(url: string = window?.location?.href): string { + return this.getBaseUrlOrigin(url, 'pcast'); + } + + public static getTelemetryUrl(url: string = window?.location?.href): string { + try { + const baseUrlOrigin = this.getBaseUrlOrigin(url, 'telemetry'); + return `${baseUrlOrigin}/telemetry`; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + } catch (e) { + return url; + } + } + + public static getWebSocketUrl(url: string = window?.location?.href): string { + return this.getBaseUrlOrigin(url, 'websocket'); + } +} diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 0000000..d406816 --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1 @@ +export * from './store'; diff --git a/src/store/middlewares/PhenixWebSocket.middleware.ts b/src/store/middlewares/PhenixWebSocket.middleware.ts new file mode 100644 index 0000000..7197b9e --- /dev/null +++ b/src/store/middlewares/PhenixWebSocket.middleware.ts @@ -0,0 +1,12 @@ +import { Middleware } from '@reduxjs/toolkit'; + +const phenixWebSocketMiddleware: Middleware = (storeApi) => (next) => (action) => { + console.log('phenixWebSocketMiddleware', action); + if (typeof action === 'function') { + return action(storeApi.getState(), storeApi.dispatch); + } + + return next(action); +}; + +export default phenixWebSocketMiddleware; \ No newline at end of file diff --git a/src/store/reducer.ts b/src/store/reducer.ts new file mode 100644 index 0000000..b4daa09 --- /dev/null +++ b/src/store/reducer.ts @@ -0,0 +1,8 @@ +import {combineReducers} from '@reduxjs/toolkit'; +import {authenticationReducer} from './slices'; + +const rootReducer = combineReducers({ + authentication: authenticationReducer +}); + +export default rootReducer; diff --git a/src/store/slices/Authentication.slice.ts b/src/store/slices/Authentication.slice.ts new file mode 100644 index 0000000..09ea1bc --- /dev/null +++ b/src/store/slices/Authentication.slice.ts @@ -0,0 +1,110 @@ +import {createSlice, createAsyncThunk, createSelector} from '@reduxjs/toolkit'; +import AuthenticationService from 'services/authentication.service'; +import {PhenixWebSocketStatusMapping, PhenixWebSocketStatusType} from 'services/net/PhenixWebSocket'; + +export interface IAuthenticationState { + applicationId: string | null; + secret: string | null; + isAuthenticated: boolean; + isAuthenticating: boolean; + error: string | null; + status: PhenixWebSocketStatusType; +} + +const initialAuthenticationState: IAuthenticationState = { + applicationId: null, + secret: null, + isAuthenticated: false, + isAuthenticating: false, + error: null, + status: 'Offline' +}; + +// Memoized selectors +export const selectAuthentication = (state: {authentication: IAuthenticationState}) => state.authentication; + +export const selectIsAuthenticating = createSelector([selectAuthentication], authentication => authentication.isAuthenticating); + +export const selectIsAuthenticated = createSelector([selectAuthentication], authentication => authentication.isAuthenticated); + +export const selectError = createSelector([selectAuthentication], authentication => authentication.error); + +export const selectStatus = createSelector([selectAuthentication], authentication => authentication.status); + +export const selectCredentials = createSelector([selectAuthentication], authentication => ({ + applicationId: authentication.applicationId, + secret: authentication.secret +})); + +const authenticateCredentialsThunk = createAsyncThunk( + 'authentication/authenticate', + async (credentials: {applicationId: string; secret: string}, {rejectWithValue}) => { + return AuthenticationService.authenticateCredentials(credentials.applicationId, credentials.secret).catch(rejectWithValue); + } +); + +const authenticationSlice = createSlice({ + name: 'authentication', + initialState: {...initialAuthenticationState}, + reducers: { + setStatus: (state, action) => { + state.status = PhenixWebSocketStatusMapping.convertPhenixWebSocketStatusToPhenixWebSocketStatusType(action.payload); + }, + signout: state => { + state.isAuthenticated = false; + state.isAuthenticating = false; + state.applicationId = null; + state.secret = null; + state.error = null; + }, + error: (state, action) => { + // Store only the error message string, not the Error object + if (typeof action.payload === 'string') { + state.error = action.payload; + } else if (action.payload instanceof Error) { + state.error = action.payload.message; + } else if (action.payload && typeof action.payload === 'object' && 'message' in action.payload) { + state.error = String((action.payload as {message: unknown}).message); + } else { + state.error = action.payload ? String(action.payload) : null; + } + } + }, + extraReducers: builder => { + builder + .addCase(authenticateCredentialsThunk.pending, state => { + state.isAuthenticating = true; + state.error = null; + }) + .addCase(authenticateCredentialsThunk.fulfilled, state => { + state.isAuthenticating = false; + state.error = null; + state.isAuthenticated = true; + }) + .addCase(authenticateCredentialsThunk.rejected, (state, action) => { + state.isAuthenticating = false; + // Extract error message string instead of creating new Error object + let errorMessage = 'Authentication failed'; + + if (action.payload) { + if (action.payload instanceof Error) { + errorMessage = action.payload.message; + } else if (typeof action.payload === 'object' && action.payload !== null && 'message' in action.payload) { + errorMessage = String((action.payload as {message: unknown}).message); + } else if (typeof action.payload === 'string') { + errorMessage = action.payload; + } else { + errorMessage = String(action.payload); + } + } + + state.error = errorMessage; + state.isAuthenticated = false; + state.applicationId = null; + state.secret = null; + }); + } +}); + +export const {reducer: authenticationReducer, actions: authenticationActions} = authenticationSlice; +export {authenticateCredentialsThunk}; diff --git a/src/store/slices/index.ts b/src/store/slices/index.ts new file mode 100644 index 0000000..2c7d54f --- /dev/null +++ b/src/store/slices/index.ts @@ -0,0 +1 @@ +export * from './Authentication.slice'; diff --git a/src/store/store.ts b/src/store/store.ts new file mode 100644 index 0000000..4fc30f1 --- /dev/null +++ b/src/store/store.ts @@ -0,0 +1,7 @@ +import {configureStore} from '@reduxjs/toolkit'; +import reducer from './reducer'; +import phenixWebSocketMiddleware from './middlewares/PhenixWebSocket.middleware'; + +export const store = configureStore({reducer, middleware: getDefaultMiddleware => getDefaultMiddleware().concat(phenixWebSocketMiddleware)}); + +export type RootState = ReturnType; diff --git a/src/types/phenix-web-proto.d.ts b/src/types/phenix-web-proto.d.ts new file mode 100644 index 0000000..fb6a54b --- /dev/null +++ b/src/types/phenix-web-proto.d.ts @@ -0,0 +1,22 @@ +declare module 'phenix-web-proto' { + import { ILogger } from 'services/logger/LoggerInterface'; + + export class MQWebSocket { + constructor(uri: string, logger: ILogger, protocols: any[], apiVersion?: string); + onEvent(eventName: string, handler: (event: unknown) => void): void; + onRequest(requestName: string, handler: (request: unknown) => void): void; + sendRequest(type: string, message: unknown, callback?: (error: unknown, response: unknown) => void, settings?: unknown): void; + sendResponse(requestId: string, type: string, message: unknown, callback?: (error: unknown, response: unknown) => void): void; + disconnect(): void; + disposeOfPendingRequests(): void; + getApiVersion(): string; + } + + export class BatchHttpProto { + // Add methods as needed + } + + export class MQService { + // Add methods as needed + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..026f2da --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,46 @@ +{ + "compilerOptions": { + "target": "es2022", + "module": "esnext", + "moduleResolution": "bundler", + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "allowSyntheticDefaultImports": true, + "downlevelIteration": true, + "sourceMap": true, + "noEmit": true, + "noEmitHelpers": true, + "importHelpers": true, + "strictNullChecks": true, + "noUnusedParameters": true, + "noUnusedLocals": true, + "noImplicitAny": true, // TODO(AZ): Fix types to enable setting this to true + "lib": ["dom", "dom.iterable", "es2022"], + "baseUrl": "./src", + "paths": { + // Relative to baseUrl https://www.typescriptlang.org/tsconfig/#paths + "assets/*": ["./assets/*"], + "components/*": ["./components/*"], + "config/*": ["./config/*"], + "constant-data/*": ["./constant-data/*"], + "hooks/*": ["./hooks/*"], + "interfaces/*": ["./interfaces/*"], + "routers/*": ["./routers/*"], + "store/*": ["./store/*"], + "services/*": ["./services/*"], + "lang/*": ["./lang/*"], + "utility/*": ["./utility/*"], + "views/*": ["./views/*"] + }, + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "jsx": "react-jsx", + "noFallthroughCasesInSwitch": true + }, + "exclude": ["node_modules", "public"], + "include": ["src", "test"] +} diff --git a/tsconfig.app.json.orig b/tsconfig.app.json.orig new file mode 100644 index 0000000..227a6c6 --- /dev/null +++ b/tsconfig.app.json.orig @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/tsconfig.app.tsbuildinfo b/tsconfig.app.tsbuildinfo new file mode 100644 index 0000000..ccd0f6f --- /dev/null +++ b/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/index.ts","./src/components/flexcontainer/flexcontainercomponent.tsx","./src/components/flexcontainer/index.ts","./src/components/inputfield/inputfield.tsx","./src/components/inputfield/index.ts","./src/components/loginform/loginform.tsx","./src/components/loginform/index.ts","./src/components/websocketstatusview/websocketstatusviewcomponent.tsx","./src/components/websocketstatusview/index.ts","./src/config/index.ts","./src/hooks/index.ts","./src/hooks/store.ts","./src/lang/strings.ts","./src/lang/assertunreachable.ts","./src/routes/index.ts","./src/services/authentication.service.ts","./src/services/host-url.service.ts","./src/services/platform-detection.service.ts","./src/services/logger/appenders.ts","./src/services/logger/consoleappender.ts","./src/services/logger/iappender.ts","./src/services/logger/logger.ts","./src/services/logger/loggerdefaults.ts","./src/services/logger/loggerfactory.ts","./src/services/logger/loggerinterface.ts","./src/services/logger/logginglevelmapping.ts","./src/services/logger/loggingthreshold.ts","./src/services/net/phenixwebsocket.ts","./src/services/telemetry/telemetryapender.ts","./src/services/telemetry/telemetryconfiguration.ts","./src/services/telemetry/telemetryservice.ts","./src/store/index.ts","./src/store/reducer.ts","./src/store/store.ts","./src/store/middlewares/phenixwebsocket.middleware.ts","./src/store/slices/authentication.slice.ts","./src/store/slices/index.ts","./src/types/phenix-web-proto.d.ts"],"version":"5.9.2"} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..2340846 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{"path": "./tsconfig.app.json"}, {"path": "./tsconfig.node.json"}] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..ad740a5 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,33 @@ +import path from 'path'; +import {defineConfig} from 'vite'; +import react from '@vitejs/plugin-react-swc'; +import babel from 'vite-plugin-babel'; + +console.log('process.cwd()', process.cwd()); +// https://vite.dev/config/ +export default defineConfig({ + plugins: [ + react(), + babel({ + babelConfig: { + plugins: ['transform-amd-to-commonjs'] + } + }) + ], + resolve: { + alias: { + assets: path.resolve(process.cwd(), 'src', 'assets'), + components: path.resolve(process.cwd(), 'src/components'), + config: path.resolve(process.cwd(), 'src', 'config'), + 'constant-data': path.resolve(process.cwd(), 'src', 'constant-data'), + hooks: path.resolve(process.cwd(), 'src', 'hooks'), + interfaces: path.resolve(process.cwd(), 'src', 'interfaces'), + routers: path.resolve(process.cwd(), 'src', 'routers'), + store: path.resolve(process.cwd(), 'src', 'store'), + services: path.resolve(process.cwd(), 'src', 'services'), + lang: path.resolve(process.cwd(), 'src', 'lang'), + utility: path.resolve(process.cwd(), 'src', 'utility'), + views: path.resolve(process.cwd(), 'src', 'views') + } + } +});