From 29fb52df20d68aae27b108222bd6a438976a6e02 Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 5 Feb 2025 19:00:28 +0000 Subject: [PATCH 1/3] wip: trpc boilerplate --- companion/lib/Registry.ts | 2 ++ companion/lib/UI/Express.ts | 19 +++++++++++++ companion/lib/UI/Handler.ts | 7 ----- companion/lib/UI/TRPC.ts | 42 +++++++++++++++++++++++++++ companion/lib/UI/Update.ts | 14 +++++++++ companion/package.json | 1 + shared-lib/lib/SocketIO.ts | 2 -- webui/package.json | 2 ++ webui/src/Layout/Header.tsx | 57 ++++++++++++++++++++++--------------- webui/src/TRPC.tsx | 29 +++++++++++++++++++ webui/src/routes/__root.tsx | 25 +++++++++------- webui/vite.config.js | 4 +++ yarn.lock | 36 +++++++++++++++++++++++ 13 files changed, 198 insertions(+), 42 deletions(-) create mode 100644 companion/lib/UI/TRPC.ts create mode 100644 webui/src/TRPC.tsx diff --git a/companion/lib/Registry.ts b/companion/lib/Registry.ts index 8d1fbfbfda..99fefdff90 100644 --- a/companion/lib/Registry.ts +++ b/companion/lib/Registry.ts @@ -33,6 +33,7 @@ import { ImportExportController } from './ImportExport/Controller.js' import { ServiceOscSender } from './Service/OscSender.js' import type { ControlCommonEvents } from './Controls/ControlDependencies.js' import type { PackageJson } from 'type-fest' +import { createTrpcRouter } from './UI/TRPC.js' const pkgInfoStr = await fs.readFile(new URL('../package.json', import.meta.url)) const pkgInfo: PackageJson = JSON.parse(pkgInfoStr.toString()) @@ -313,6 +314,7 @@ export class Registry { await this.instance.initInstances(extraModulePath) // Instances are loaded, start up http + this.ui.express.bindTrpcRouter(createTrpcRouter(this)) this.rebindHttp(bindIp, bindPort) // Startup has completed, run triggers diff --git a/companion/lib/UI/Express.ts b/companion/lib/UI/Express.ts index cc39b769ad..234b0ce646 100644 --- a/companion/lib/UI/Express.ts +++ b/companion/lib/UI/Express.ts @@ -23,6 +23,8 @@ import fs from 'fs' // @ts-ignore import serveZip from 'express-serve-zip' import { fileURLToPath } from 'url' +import * as trpcExpress from '@trpc/server/adapters/express' +import { AppRouter, createTrpcContext } from './TRPC.js' /** * Create a zip serve app @@ -63,6 +65,7 @@ export class UIExpress { #apiRouter = Express.Router() #legacyApiRouter = Express.Router() #connectionApiRouter = Express.Router() + #trpcRouter = Express.Router() constructor(internalApiRouter: Express.Router) { this.app.use(cors()) @@ -92,6 +95,9 @@ export class UIExpress { // Use the router #apiRouter to add API routes dynamically, this router can be redefined at runtime with setter this.app.use('/api', (r, s, n) => this.#apiRouter(r, s, n)) + // Use the router #apiRouter to add API routes dynamically, this router can be redefined at runtime with setter + this.app.use('/trpc', (r, s, n) => this.#trpcRouter(r, s, n)) + // Use the router #legacyApiRouter to add API routes dynamically, this router can be redefined at runtime with setter this.app.use((r, s, n) => this.#legacyApiRouter(r, s, n)) @@ -146,4 +152,17 @@ export class UIExpress { set connectionApiRouter(router: Express.Router) { this.#connectionApiRouter = router } + + #boundTrpcRouter = false + bindTrpcRouter(trpcRouter: AppRouter) { + if (this.#boundTrpcRouter) throw new Error('tRPC router already bound') + this.#boundTrpcRouter = true + + this.#trpcRouter.use( + trpcExpress.createExpressMiddleware({ + router: trpcRouter, + createContext: createTrpcContext, + }) + ) + } } diff --git a/companion/lib/UI/Handler.ts b/companion/lib/UI/Handler.ts index 29a0ae94f0..0937d0fa25 100644 --- a/companion/lib/UI/Handler.ts +++ b/companion/lib/UI/Handler.ts @@ -156,13 +156,6 @@ export class UIHandler extends EventEmitter { const client = new ClientSocket(rawClient, this.#logger) - client.onPromise('app-version-info', () => { - return { - appVersion: this.#appInfo.appVersion, - appBuild: this.#appInfo.appBuild, - } - }) - this.emit('clientConnect', client) client.on('disconnect', () => { diff --git a/companion/lib/UI/TRPC.ts b/companion/lib/UI/TRPC.ts new file mode 100644 index 0000000000..1fd8c86898 --- /dev/null +++ b/companion/lib/UI/TRPC.ts @@ -0,0 +1,42 @@ +import { initTRPC } from '@trpc/server' +import type { Registry } from '../Registry.js' +import type * as trpcExpress from '@trpc/server/adapters/express' + +// created for each request +export const createTrpcContext = ({} /* req, res */ : trpcExpress.CreateExpressContextOptions) => ({}) // no context +type Context = Awaited> + +/** + * Initialization of tRPC backend + * Should be done only once per backend! + */ +const t = initTRPC.context().create() + +/** + * Export reusable router and procedure helpers + * that can be used throughout the router + */ +export const router = t.router +export const publicProcedure = t.procedure +export const protectedProcedure = t.procedure + +/** + * Create the root TRPC router + * @param registry + * @returns + */ +export function createTrpcRouter(registry: Registry) { + return router({ + // ... + + userList: publicProcedure.query(async () => { + return [1, 2, 3] + }), + + appInfo: registry.ui.update.createTrpcRouter(), + }) +} + +// Export type router type signature, +// NOT the router itself. +export type AppRouter = ReturnType diff --git a/companion/lib/UI/Update.ts b/companion/lib/UI/Update.ts index d2edb074c0..f0d4d10a25 100644 --- a/companion/lib/UI/Update.ts +++ b/companion/lib/UI/Update.ts @@ -21,6 +21,7 @@ import type { UIHandler } from './Handler.js' import type { ClientSocket } from './Handler.js' import type { AppUpdateInfo } from '@companion-app/shared/Model/Common.js' import { compileUpdatePayload } from './UpdatePayload.js' +import { publicProcedure, router } from './TRPC.js' export class UIUpdate { readonly #logger = LogController.createLogger('UI/Update') @@ -84,4 +85,17 @@ export class UIUpdate { this.#logger.verbose('update server said something unexpected!', e) }) } + + createTrpcRouter() { + return router({ + // TODO + + version: publicProcedure.query(() => { + return { + appVersion: this.#appInfo.appVersion, + appBuild: this.#appInfo.appBuild, + } + }), + }) + } } diff --git a/companion/package.json b/companion/package.json index 624793cf16..d548a4dd70 100644 --- a/companion/package.json +++ b/companion/package.json @@ -59,6 +59,7 @@ "@loupedeck/node": "^1.2.0", "@napi-rs/canvas": "^0.1.66", "@sentry/node": "^8.54.0", + "@trpc/server": "^11.0.0-rc.730", "archiver": "^7.0.1", "better-sqlite3": "^11.8.1", "bufferutil": "^4.0.9", diff --git a/shared-lib/lib/SocketIO.ts b/shared-lib/lib/SocketIO.ts index 9232a3e3a4..934336c5ad 100644 --- a/shared-lib/lib/SocketIO.ts +++ b/shared-lib/lib/SocketIO.ts @@ -3,7 +3,6 @@ import type { UserConfigModel } from './Model/UserConfigModel.js' import type { ClientLogLine } from './Model/LogLine.js' import type { AppUpdateInfo, - AppVersionInfo, ClientBonjourService, ClientEditConnectionConfig, ClientEventDefinition, @@ -50,7 +49,6 @@ export interface ClientToBackendEventsMap { disconnect: () => never // Hack because type is missing 'app-update-info': () => never - 'app-version-info': () => AppVersionInfo set_userconfig_key(key: keyof UserConfigModel, value: any): never reset_userconfig_key(key: keyof UserConfigModel): never diff --git a/webui/package.json b/webui/package.json index 0f10d8b228..722ae562b8 100644 --- a/webui/package.json +++ b/webui/package.json @@ -21,6 +21,8 @@ "@tanstack/router-devtools": "^1.99.0", "@tanstack/router-plugin": "^1.99.3", "@tanstack/virtual-file-routes": "^1.99.0", + "@trpc/client": "^11.0.0-rc.730", + "@trpc/react-query": "^11.0.0-rc.730", "@types/react": "^18.3.18", "@types/react-copy-to-clipboard": "^5.0.7", "@types/react-dom": "^18.3.5", diff --git a/webui/src/Layout/Header.tsx b/webui/src/Layout/Header.tsx index d151a40a66..e47b2cd776 100644 --- a/webui/src/Layout/Header.tsx +++ b/webui/src/Layout/Header.tsx @@ -1,11 +1,21 @@ import React, { useContext, useEffect, useState } from 'react' -import { CHeader, CHeaderBrand, CHeaderNav, CNavItem, CNavLink, CHeaderToggler, CContainer } from '@coreui/react' +import { + CHeader, + CHeaderBrand, + CHeaderNav, + CNavItem, + CNavLink, + CHeaderToggler, + CContainer, + CSpinner, +} from '@coreui/react' import { faBars, faLock, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import type { AppUpdateInfo, AppVersionInfo } from '@companion-app/shared/Model/Common.js' import { RootAppStoreContext } from '../Stores/RootAppStore.js' import { observer } from 'mobx-react-lite' import { useSidebarState } from './Sidebar.js' +import { trpc } from '../TRPC.js' interface MyHeaderProps { canLock: boolean @@ -17,7 +27,6 @@ export const MyHeader = observer(function MyHeader({ canLock, setLocked }: MyHea const { showToggle, clickToggle } = useSidebarState() - const [versionInfo, setVersionInfo] = useState(null) const [updateData, setUpdateData] = useState(null) useEffect(() => { @@ -26,27 +35,11 @@ export const MyHeader = observer(function MyHeader({ canLock, setLocked }: MyHea const unsubAppInfo = socket.on('app-update-info', setUpdateData) socket.emit('app-update-info') - socket - .emitPromise('app-version-info', []) - .then((info) => { - setVersionInfo(info) - }) - .catch((e) => { - console.error('Failed to load version info', e) - }) - return () => { unsubAppInfo() } }, [socket]) - const versionString = versionInfo - ? versionInfo.appBuild.includes('stable') - ? `v${versionInfo.appVersion}` - : `v${versionInfo.appBuild}` - : '' - const buildString = versionInfo ? `Build ${versionInfo.appBuild}` : '' - return ( @@ -64,11 +57,7 @@ export const MyHeader = observer(function MyHeader({ canLock, setLocked }: MyHea {userConfig.properties?.installName} )} - - - {versionString} - - + {updateData?.message ? ( @@ -95,3 +84,25 @@ export const MyHeader = observer(function MyHeader({ canLock, setLocked }: MyHea ) }) + +function HeaderVersion() { + const versionInfo = trpc.appInfo.version.useQuery() + + const versionString = versionInfo.data + ? versionInfo.data.appBuild.includes('stable') + ? `v${versionInfo.data.appVersion}` + : `v${versionInfo.data.appBuild}` + : '' + const buildString = versionInfo.data ? `Build ${versionInfo.data.appBuild}` : '' + + return ( + + {versionInfo.isLoading ? : null} + {versionInfo.data ? ( + + {versionString} + + ) : null} + + ) +} diff --git a/webui/src/TRPC.tsx b/webui/src/TRPC.tsx new file mode 100644 index 0000000000..709bae975f --- /dev/null +++ b/webui/src/TRPC.tsx @@ -0,0 +1,29 @@ +// import { createTRPCClient, httpBatchLink } from '@trpc/client' +import { createTRPCReact } from '@trpc/react-query' +import type { AppRouter } from '../../companion/lib/UI/TRPC.js' // Type only import the router +import { httpBatchLink } from '@trpc/client' +// const trpc = createTRPCClient({ +// links: [ +// httpBatchLink({ +// url: '/trpc', +// }), +// ], +// }) + +export const trpc = createTRPCReact() + +export const trpcClient = trpc.createClient({ + links: [ + httpBatchLink({ + url: '/trpc', + // You can pass any HTTP headers you wish here + // async headers() { + // return { + // authorization: getAuthCookie(), + // } + // }, + }), + ], +}) + +// export { trpc } diff --git a/webui/src/routes/__root.tsx b/webui/src/routes/__root.tsx index 1d963b0237..28da676a02 100644 --- a/webui/src/routes/__root.tsx +++ b/webui/src/routes/__root.tsx @@ -2,20 +2,25 @@ import React, { Suspense } from 'react' import { createRootRoute, Outlet } from '@tanstack/react-router' import { ErrorFallback } from '../util.js' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { trpc, trpcClient } from '../TRPC.js' const queryClient = new QueryClient() export const Route = createRootRoute({ - component: () => ( - <> - - - - - - - - ), + component: () => { + return ( + <> + + + + + + + + + + ) + }, errorComponent: ({ error, reset }) => { return }, diff --git a/webui/vite.config.js b/webui/vite.config.js index 182f7b873e..3a7b91b305 100644 --- a/webui/vite.config.js +++ b/webui/vite.config.js @@ -23,6 +23,10 @@ export default defineConfig({ target: `ws://${upstreamUrl}`, ws: true, }, + '/trpc': { + target: `ws://${upstreamUrl}`, + ws: true, + }, }, }, plugins: [ diff --git a/yarn.lock b/yarn.lock index 5fdc8a1a71..f267b00108 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1290,6 +1290,8 @@ __metadata: "@tanstack/router-devtools": "npm:^1.99.0" "@tanstack/router-plugin": "npm:^1.99.3" "@tanstack/virtual-file-routes": "npm:^1.99.0" + "@trpc/client": "npm:^11.0.0-rc.730" + "@trpc/react-query": "npm:^11.0.0-rc.730" "@types/react": "npm:^18.3.18" "@types/react-copy-to-clipboard": "npm:^5.0.7" "@types/react-dom": "npm:^18.3.5" @@ -4880,6 +4882,39 @@ __metadata: languageName: node linkType: hard +"@trpc/client@npm:^11.0.0-rc.730": + version: 11.0.0-rc.730 + resolution: "@trpc/client@npm:11.0.0-rc.730" + peerDependencies: + "@trpc/server": 11.0.0-rc.730+776d07336 + typescript: ">=5.7.2" + checksum: 10c0/1700833d57d0413b972daa0704c54ba6f14cd11f31ddedd0f257f6cb26ffa7ab98a37bfbf666ecf4fae6fbb74f90d1aecc2d688ea829a5960c264eda5ad1261b + languageName: node + linkType: hard + +"@trpc/react-query@npm:^11.0.0-rc.730": + version: 11.0.0-rc.730 + resolution: "@trpc/react-query@npm:11.0.0-rc.730" + peerDependencies: + "@tanstack/react-query": ^5.62.8 + "@trpc/client": 11.0.0-rc.730+776d07336 + "@trpc/server": 11.0.0-rc.730+776d07336 + react: ">=18.2.0" + react-dom: ">=18.2.0" + typescript: ">=5.7.2" + checksum: 10c0/f388383b64b6a49d82c7181d795c14902e4a8c981ef0dce42f895e94bbe404a886a53fb03f75c5ee16dea6815eea5c5a118fc3d1ec83bb115027598efb8525fc + languageName: node + linkType: hard + +"@trpc/server@npm:^11.0.0-rc.730": + version: 11.0.0-rc.730 + resolution: "@trpc/server@npm:11.0.0-rc.730" + peerDependencies: + typescript: ">=5.7.2" + checksum: 10c0/c730f73bb9db63f2fd388bc62032286f12a8abbe09c0c4ce711d429430d58859ff8ba0ec86535637c0c037f425ded68fefd19d6dcdea8c83baff1fc39de8787e + languageName: node + linkType: hard + "@types/ag-auth@npm:*": version: 1.0.3 resolution: "@types/ag-auth@npm:1.0.3" @@ -7563,6 +7598,7 @@ asn1@evs-broadcast/node-asn1: "@napi-rs/canvas": "npm:^0.1.66" "@sentry/node": "npm:^8.54.0" "@sentry/webpack-plugin": "npm:^3.1.1" + "@trpc/server": "npm:^11.0.0-rc.730" "@types/archiver": "npm:^6.0.3" "@types/better-sqlite3": "npm:^7.6.12" "@types/cors": "npm:^2.8.17" From b3cacba68577432e9513fd9c8caa53f59b14d98f Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 5 Feb 2025 19:58:30 +0000 Subject: [PATCH 2/3] hack: ws --- companion/lib/Registry.ts | 6 +++- companion/lib/UI/Controller.ts | 4 +++ companion/lib/UI/Express.ts | 39 +++++++++++++++++++++++ companion/lib/UI/Handler.ts | 56 ++++++++++++++++++++++++++++++++++ webui/src/TRPC.tsx | 26 ++++++++++------ 5 files changed, 121 insertions(+), 10 deletions(-) diff --git a/companion/lib/Registry.ts b/companion/lib/Registry.ts index 99fefdff90..1193b3a0a8 100644 --- a/companion/lib/Registry.ts +++ b/companion/lib/Registry.ts @@ -314,7 +314,9 @@ export class Registry { await this.instance.initInstances(extraModulePath) // Instances are loaded, start up http - this.ui.express.bindTrpcRouter(createTrpcRouter(this)) + const router = createTrpcRouter(this) + this.ui.express.bindTrpcRouter(router) + this.ui.io.bindTrpcRouter(router) this.rebindHttp(bindIp, bindPort) // Startup has completed, run triggers @@ -364,6 +366,8 @@ export class Registry { Promise.resolve().then(async () => { this.#logger.info('somewhere, the system wants to exit. kthxbai') + this.ui.close() + // Save the db to disk this.db.close() this.#data.cache.close() diff --git a/companion/lib/UI/Controller.ts b/companion/lib/UI/Controller.ts index b61e3b5620..f44e3d51de 100644 --- a/companion/lib/UI/Controller.ts +++ b/companion/lib/UI/Controller.ts @@ -24,4 +24,8 @@ export class UIController { clientConnect(client: ClientSocket): void { this.update.clientConnect(client) } + + close() { + this.io.close() + } } diff --git a/companion/lib/UI/Express.ts b/companion/lib/UI/Express.ts index 234b0ce646..70c46a9801 100644 --- a/companion/lib/UI/Express.ts +++ b/companion/lib/UI/Express.ts @@ -164,5 +164,44 @@ export class UIExpress { createContext: createTrpcContext, }) ) + + // const wss = new WebSocketServer({ + // noServer: true, + // }) + + // // TODO - this shouldnt be here like this.. + // const handler = applyWSSHandler({ + // wss, + // router: trpcRouter, + // createTrpcContext, + // // Enable heartbeat messages to keep connection open (disabled by default) + // keepAlive: { + // enabled: true, + // // server ping message interval in milliseconds + // pingMs: 30000, + // // connection is terminated if pong message is not received in this many milliseconds + // pongWaitMs: 5000, + // }, + // }) + + // this.app.on("upgrade", (request, socket, head) => { + // wss.handleUpgrade(request, socket, head, (websocket) => { + // wss.emit("connection", websocket, request); + // }); + // }); + + // wss.on('connection', (ws) => { + // console.log(`➕➕ Connection (${wss.clients.size})`) + // ws.once('close', () => { + // console.log(`➖➖ Connection (${wss.clients.size})`) + // }) + // }) + // console.log('✅ WebSocket Server listening on ws://localhost:3001') + + // process.on('SIGTERM', () => { + // console.log('SIGTERM') + // handler.broadcastReconnectNotification() + // wss.close() + // }) } } diff --git a/companion/lib/UI/Handler.ts b/companion/lib/UI/Handler.ts index 0937d0fa25..67795375d0 100644 --- a/companion/lib/UI/Handler.ts +++ b/companion/lib/UI/Handler.ts @@ -21,6 +21,9 @@ import type { AppInfo } from '../Registry.js' import type { Server as HttpServer } from 'http' import type { Server as HttpsServer } from 'https' import { EventEmitter } from 'events' +import { applyWSSHandler } from '@trpc/server/adapters/ws' +import { WebSocketServer } from 'ws' +import { AppRouter, createTrpcContext } from './TRPC.js' type IOListenEvents = import('@companion-app/shared/SocketIO.js').ClientToBackendEventsListenMap type IOEmitEvents = import('@companion-app/shared/SocketIO.js').BackendToClientEventsMap @@ -127,6 +130,14 @@ export class UIHandler extends EventEmitter { readonly #httpIO: IOServerType #httpsIO: IOServerType | undefined + #http: HttpServer + + #wss = new WebSocketServer({ + noServer: true, + path: '/trpc', + }) + #broadcastDisconnect?: () => void + constructor(appInfo: AppInfo, http: HttpServer) { super() @@ -143,6 +154,7 @@ export class UIHandler extends EventEmitter { }, } + this.#http = http this.#httpIO = new SocketIOServer(http, this.#socketIOOptions) this.#httpIO.on('connect', this.#clientConnect.bind(this)) @@ -212,4 +224,48 @@ export class UIHandler extends EventEmitter { this.#httpsIO.on('connect', this.#clientConnect.bind(this)) } } + + #boundTrpcRouter = false + bindTrpcRouter(trpcRouter: AppRouter) { + if (this.#boundTrpcRouter) throw new Error('tRPC router already bound') + this.#boundTrpcRouter = true + + // TODO - this shouldnt be here like this.. + const handler = applyWSSHandler({ + wss: this.#wss, + router: trpcRouter, + createTrpcContext, + // Enable heartbeat messages to keep connection open (disabled by default) + keepAlive: { + enabled: true, + // server ping message interval in milliseconds + pingMs: 30000, + // connection is terminated if pong message is not received in this many milliseconds + pongWaitMs: 5000, + }, + }) + + this.#broadcastDisconnect = handler.broadcastReconnectNotification + + this.#http.on('upgrade', (request, socket, head) => { + // TODO - is this guard needed? + if (request.url === '/trpc') { + this.#wss.handleUpgrade(request, socket, head, (websocket) => { + this.#wss.emit('connection', websocket, request) + }) + } + }) + + this.#wss.on('connection', (ws) => { + console.log(`➕➕ Connection (${this.#wss.clients.size})`) + ws.once('close', () => { + console.log(`➖➖ Connection (${this.#wss.clients.size})`) + }) + }) + } + + close(): void { + this.#broadcastDisconnect?.() + this.#wss.close() + } } diff --git a/webui/src/TRPC.tsx b/webui/src/TRPC.tsx index 709bae975f..06c60ba770 100644 --- a/webui/src/TRPC.tsx +++ b/webui/src/TRPC.tsx @@ -1,7 +1,7 @@ // import { createTRPCClient, httpBatchLink } from '@trpc/client' import { createTRPCReact } from '@trpc/react-query' import type { AppRouter } from '../../companion/lib/UI/TRPC.js' // Type only import the router -import { httpBatchLink } from '@trpc/client' +import { createWSClient, httpBatchLink, loggerLink, wsLink } from '@trpc/client' // const trpc = createTRPCClient({ // links: [ // httpBatchLink({ @@ -12,17 +12,25 @@ import { httpBatchLink } from '@trpc/client' export const trpc = createTRPCReact() +const wsClient = createWSClient({ + url: `/trpc`, +}) + export const trpcClient = trpc.createClient({ links: [ - httpBatchLink({ - url: '/trpc', - // You can pass any HTTP headers you wish here - // async headers() { - // return { - // authorization: getAuthCookie(), - // } - // }, + loggerLink(), + wsLink({ + client: wsClient, }), + // httpBatchLink({ + // url: '/trpc', + // // You can pass any HTTP headers you wish here + // // async headers() { + // // return { + // // authorization: getAuthCookie(), + // // } + // // }, + // }), ], }) From bc0cb5cf9088a1be7803029d622075a9b03e5a6c Mon Sep 17 00:00:00 2001 From: Julian Waller Date: Wed, 5 Feb 2025 20:19:32 +0000 Subject: [PATCH 3/3] wip: more trpc --- companion/lib/Registry.ts | 1 - companion/lib/UI/Controller.ts | 11 ++------- companion/lib/UI/TRPC.ts | 9 ++++++++ companion/lib/UI/Update.ts | 41 +++++++++++++++++----------------- shared-lib/lib/SocketIO.ts | 4 ---- webui/src/Layout/Header.tsx | 24 +++++--------------- 6 files changed, 38 insertions(+), 52 deletions(-) diff --git a/companion/lib/Registry.ts b/companion/lib/Registry.ts index 1193b3a0a8..92ecb82865 100644 --- a/companion/lib/Registry.ts +++ b/companion/lib/Registry.ts @@ -280,7 +280,6 @@ export class Registry { this.ui.io.on('clientConnect', (client) => { LogController.clientConnect(client) - this.ui.clientConnect(client) this.#data.clientConnect(client) this.page.clientConnect(client) this.controls.clientConnect(client) diff --git a/companion/lib/UI/Controller.ts b/companion/lib/UI/Controller.ts index f44e3d51de..fd32e51c5e 100644 --- a/companion/lib/UI/Controller.ts +++ b/companion/lib/UI/Controller.ts @@ -1,6 +1,6 @@ import type { AppInfo } from '../Registry.js' import { UIExpress } from './Express.js' -import { ClientSocket, UIHandler } from './Handler.js' +import { UIHandler } from './Handler.js' import { UIServer } from './Server.js' import { UIUpdate } from './Update.js' import type express from 'express' @@ -15,14 +15,7 @@ export class UIController { this.express = new UIExpress(internalApiRouter) this.server = new UIServer(this.express.app) this.io = new UIHandler(appInfo, this.server) - this.update = new UIUpdate(appInfo, this.io) - } - - /** - * Setup a new socket client's events - */ - clientConnect(client: ClientSocket): void { - this.update.clientConnect(client) + this.update = new UIUpdate(appInfo) } close() { diff --git a/companion/lib/UI/TRPC.ts b/companion/lib/UI/TRPC.ts index 1fd8c86898..cc06a94fbb 100644 --- a/companion/lib/UI/TRPC.ts +++ b/companion/lib/UI/TRPC.ts @@ -1,6 +1,7 @@ import { initTRPC } from '@trpc/server' import type { Registry } from '../Registry.js' import type * as trpcExpress from '@trpc/server/adapters/express' +import { EventEmitter, on } from 'events' // created for each request export const createTrpcContext = ({} /* req, res */ : trpcExpress.CreateExpressContextOptions) => ({}) // no context @@ -40,3 +41,11 @@ export function createTrpcRouter(registry: Registry) { // Export type router type signature, // NOT the router itself. export type AppRouter = ReturnType + +export function toIterable, TKey extends string & keyof T>( + ee: EventEmitter, + key: TKey, + signal: AbortSignal | undefined +): NodeJS.AsyncIterator { + return on(ee as any, key, { signal }) as NodeJS.AsyncIterator +} diff --git a/companion/lib/UI/Update.ts b/companion/lib/UI/Update.ts index f0d4d10a25..59d22065b0 100644 --- a/companion/lib/UI/Update.ts +++ b/companion/lib/UI/Update.ts @@ -17,27 +17,30 @@ import LogController from '../Log/Controller.js' import type { AppInfo } from '../Registry.js' -import type { UIHandler } from './Handler.js' -import type { ClientSocket } from './Handler.js' import type { AppUpdateInfo } from '@companion-app/shared/Model/Common.js' import { compileUpdatePayload } from './UpdatePayload.js' -import { publicProcedure, router } from './TRPC.js' +import { publicProcedure, router, toIterable } from './TRPC.js' +import { EventEmitter } from 'events' + +interface UpdateEvents { + info: [info: AppUpdateInfo] +} export class UIUpdate { readonly #logger = LogController.createLogger('UI/Update') readonly #appInfo: AppInfo - readonly #ioController: UIHandler + + readonly #updateEvents = new EventEmitter() /** * Latest update information */ #latestUpdateData: AppUpdateInfo | null = null - constructor(appInfo: AppInfo, ioController: UIHandler) { + constructor(appInfo: AppInfo) { this.#logger.silly('loading update') this.#appInfo = appInfo - this.#ioController = ioController } startCycle() { @@ -52,17 +55,6 @@ export class UIUpdate { ) } - /** - * Setup a new socket client's events - */ - clientConnect(client: ClientSocket): void { - client.on('app-update-info', () => { - if (this.#latestUpdateData) { - client.emit('app-update-info', this.#latestUpdateData) - } - }) - } - /** * Perform the update request */ @@ -79,7 +71,7 @@ export class UIUpdate { this.#logger.debug(`fresh update data received ${JSON.stringify(body)}`) this.#latestUpdateData = body as AppUpdateInfo - this.#ioController.emitToAll('app-update-info', this.#latestUpdateData) + this.#updateEvents.emit('info', this.#latestUpdateData) }) .catch((e) => { this.#logger.verbose('update server said something unexpected!', e) @@ -87,15 +79,24 @@ export class UIUpdate { } createTrpcRouter() { + const self = this return router({ - // TODO - version: publicProcedure.query(() => { return { appVersion: this.#appInfo.appVersion, appBuild: this.#appInfo.appBuild, } }), + + updateInfo: publicProcedure.subscription(async function* (opts) { + const changes = toIterable(self.#updateEvents, 'info', opts.signal) + + if (self.#latestUpdateData) yield self.#latestUpdateData + + for await (const [data] of changes) { + yield data + } + }), }) } } diff --git a/shared-lib/lib/SocketIO.ts b/shared-lib/lib/SocketIO.ts index 934336c5ad..9579106e7d 100644 --- a/shared-lib/lib/SocketIO.ts +++ b/shared-lib/lib/SocketIO.ts @@ -48,8 +48,6 @@ import { ModuleStoreListCacheStore, ModuleStoreModuleInfoStore } from './Model/M export interface ClientToBackendEventsMap { disconnect: () => never // Hack because type is missing - 'app-update-info': () => never - set_userconfig_key(key: keyof UserConfigModel, value: any): never reset_userconfig_key(key: keyof UserConfigModel): never set_userconfig_keys(values: Partial): never @@ -348,8 +346,6 @@ export interface ClientToBackendEventsMap { } export interface BackendToClientEventsMap { - 'app-update-info': (info: AppUpdateInfo) => void - 'logs:lines': (rawItems: ClientLogLine[]) => void 'logs:clear': () => void diff --git a/webui/src/Layout/Header.tsx b/webui/src/Layout/Header.tsx index e47b2cd776..8b8c612551 100644 --- a/webui/src/Layout/Header.tsx +++ b/webui/src/Layout/Header.tsx @@ -1,4 +1,4 @@ -import React, { useContext, useEffect, useState } from 'react' +import React, { useContext } from 'react' import { CHeader, CHeaderBrand, @@ -11,7 +11,6 @@ import { } from '@coreui/react' import { faBars, faLock, faTriangleExclamation } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import type { AppUpdateInfo, AppVersionInfo } from '@companion-app/shared/Model/Common.js' import { RootAppStoreContext } from '../Stores/RootAppStore.js' import { observer } from 'mobx-react-lite' import { useSidebarState } from './Sidebar.js' @@ -23,22 +22,11 @@ interface MyHeaderProps { } export const MyHeader = observer(function MyHeader({ canLock, setLocked }: MyHeaderProps) { - const { socket, userConfig } = useContext(RootAppStoreContext) + const { userConfig } = useContext(RootAppStoreContext) const { showToggle, clickToggle } = useSidebarState() - const [updateData, setUpdateData] = useState(null) - - useEffect(() => { - if (!socket) return - - const unsubAppInfo = socket.on('app-update-info', setUpdateData) - socket.emit('app-update-info') - - return () => { - unsubAppInfo() - } - }, [socket]) + const updateData = trpc.appInfo.updateInfo.useSubscription() return ( @@ -59,11 +47,11 @@ export const MyHeader = observer(function MyHeader({ canLock, setLocked }: MyHea - {updateData?.message ? ( + {updateData.data ? ( - + - {updateData.message} + {updateData.data.message} ) : (