/** * Cloud-app Socket.IO connection. * * The cloud-app process opens ONE Socket.IO connection per Gridapp (NOT per * Device, NOT per Tenant) — phyhub keys the room model on `appRegistrationId`, * which it derives from the JWT. From the client's perspective this means a * single transport, an auto-refreshing token, and an inbox of Cloud twins * delivered via `cloudAppAuthenticated`. * * Lifecycle: * 1. caller invokes `connect()` * 2. we exchange `{ appRegistrationId, appSecret }` for a JWT (Phase 13 endpoint) * 3. we open the socket with `auth: { cloudAppJwt: token }` * 4. on `connect`, server emits `cloudAppAuthenticated` with the initial * twin set; we store it and resolve the connect promise * 5. ~60s before expiry we re-exchange and patch `socket.auth.cloudAppJwt` * so the next reconnect uses the fresh JWT * * Reconnects: socket.io-client retries automatically. We also re-exchange a * fresh JWT on `reconnect_attempt` if the cached one has expired so a long * outage can't get stuck with a stale token. */ import { io, type Socket, type ManagerOptions, type SocketOptions } from 'socket.io-client'; import type { CloudTwinResponse } from '../types/twin.types'; import { exchangeCloudAppToken, CloudAppTokenExchangeError, type CloudAppToken, type CloudAppFetch, } from './cloud-app-token.service'; /** * `connect()` resolves with the live socket plus the twin snapshot the server * delivers on the auth ack. `initialTwins` is `undefined` (not `[]`) when no * fresh snapshot is available — currently only on the "already connected" * fast path. Callers should treat `undefined` as "rely on whatever you have * cached" and an empty array as "server confirmed zero twins". */ export interface CloudAppConnectResult { socket: Socket; initialTwins: CloudTwinResponse[] | undefined; } export interface CloudAppConnectionParams { appRegistrationId: string; appSecret: string; coreApiUrl: string; phyhubUrl: string; /** How many ms before expiry should we refresh? Default 60_000 (60s). */ refreshLeadMs?: number; /** Override fetch implementation (tests). */ fetch?: CloudAppFetch; /** Override the socket.io-client factory (tests). */ ioFactory?: (url: string, opts: Partial) => Socket; /** Optional logger. Defaults to console. */ logger?: Pick; } const DEFAULT_REFRESH_LEAD_MS = 60_000; const NOOP_LOGGER: Pick = { info: () => {}, warn: () => {}, error: () => {}, }; export class CloudAppConnection { private readonly params: Required> & { fetch?: CloudAppFetch; ioFactory: (url: string, opts: Partial) => Socket; logger: Pick; }; private socket: Socket | null = null; private cachedToken: CloudAppToken | null = null; private refreshTimer: ReturnType | undefined; private connected = false; constructor(params: CloudAppConnectionParams) { if (!params.appRegistrationId) throw new Error('CloudAppConnection: appRegistrationId required'); if (!params.appSecret) throw new Error('CloudAppConnection: appSecret required'); if (!params.coreApiUrl) throw new Error('CloudAppConnection: coreApiUrl required'); if (!params.phyhubUrl) throw new Error('CloudAppConnection: phyhubUrl required'); this.params = { appRegistrationId: params.appRegistrationId, appSecret: params.appSecret, coreApiUrl: params.coreApiUrl.replace(/\/$/, ''), phyhubUrl: params.phyhubUrl.replace(/\/$/, ''), refreshLeadMs: params.refreshLeadMs ?? DEFAULT_REFRESH_LEAD_MS, fetch: params.fetch, ioFactory: params.ioFactory ?? ((url, opts) => io(url, opts)), logger: params.logger ?? NOOP_LOGGER, }; } /** * Exchange a fresh JWT and open the Socket.IO connection. Resolves once the * server has emitted `cloudAppAuthenticated`, returning the live socket and * the twin snapshot the server delivered on that event. Throws on auth * failure. * * The auth ack is the *only* moment phyhub bulk-delivers the cloud twin * set; subsequent changes ride `twinCreated/Updated/Deleted` on the * `cloud-app-${appRegistrationId}` room. Returning the snapshot here lets * callers hydrate their in-memory state synchronously before any caller * code runs after the `await`, eliminating the race where a listener * attached post-`connect()` would miss the initial event. */ public async connect(): Promise { if (this.connected && this.socket) { // Reconnect-from-warm-cache path: we have no fresh snapshot to return. // `undefined` (not `[]`) signals that — callers can keep whatever Map // they already hold rather than wiping it. return { socket: this.socket, initialTwins: undefined }; } this.cachedToken = await this.fetchToken(); const socket = this.params.ioFactory(this.params.phyhubUrl, { auth: { cloudAppJwt: this.cachedToken.token }, reconnection: true, // socket.io-client default is Infinity — keep it, since cloud-app processes // are long-running and a transient outage shouldn't kill the connection. transports: ['websocket', 'polling'], }); this.socket = socket; // If a long outage burns through the cached token's TTL, we need to swap // in a fresh JWT before the auto-reconnect handshake happens. socket.io.on('reconnect_attempt', () => { void this.refreshIfStale(); }); const initialTwins = await new Promise((resolve, reject) => { const onAuthenticated = (payload: { status?: string; twins?: CloudTwinResponse[] }) => { cleanup(); this.connected = true; this.scheduleRefresh(); // Server always includes `twins` in the success ack (socket.service.ts // `subscribeSocketCloudAppEvents`). Falling back to `undefined` // defensively in case a future schema regression drops the field — // empty-array would mislead callers into clearing their cache. resolve(Array.isArray(payload?.twins) ? payload.twins : undefined); }; const onConnectError = (error: Error) => { cleanup(); // Phase 13 phyhub middleware rejects bad JWT with a `SocketError`, // which surfaces here as `connect_error`. We don't retry — let the // caller decide what to do (likely abort startup). this.params.logger.error('[cloud-app-connection] connect_error', error.message ?? error); reject(error); }; const cleanup = () => { socket.off('cloudAppAuthenticated', onAuthenticated); socket.off('connect_error', onConnectError); }; socket.on('cloudAppAuthenticated', onAuthenticated); socket.on('connect_error', onConnectError); }); return { socket, initialTwins }; } /** Stop the refresh timer and disconnect. Safe to call multiple times. */ public disconnect(): void { this.connected = false; if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = undefined; } if (this.socket) { this.socket.disconnect(); this.socket = null; } this.cachedToken = null; } /** Currently cached token (for tests / introspection). */ public getToken(): CloudAppToken | null { return this.cachedToken; } public getSocket(): Socket | null { return this.socket; } // --- internals --- private async fetchToken(): Promise { return exchangeCloudAppToken({ appRegistrationId: this.params.appRegistrationId, appSecret: this.params.appSecret, coreApiUrl: this.params.coreApiUrl, fetch: this.params.fetch, }); } /** * Schedule a refresh `refreshLeadMs` before the cached token expires. * Caps the delay at 0 if expiry is already past — we'd rather refresh * immediately than schedule into the past. */ private scheduleRefresh(): void { if (this.refreshTimer) { clearTimeout(this.refreshTimer); this.refreshTimer = undefined; } if (!this.cachedToken) return; const now = Date.now(); const delay = Math.max(0, this.cachedToken.expiresAtMs - now - this.params.refreshLeadMs); this.refreshTimer = setTimeout(() => { void this.runRefresh(); }, delay); // Don't keep the event loop alive solely to refresh the JWT — the cloud-app // process's own work decides lifetime. if (typeof this.refreshTimer === 'object' && this.refreshTimer && 'unref' in this.refreshTimer) { (this.refreshTimer as { unref?: () => void }).unref?.(); } } private async runRefresh(): Promise { if (!this.connected) return; try { const fresh = await this.fetchToken(); this.cachedToken = fresh; // Patch the auth on the live socket so the next reconnect handshake // (whenever it happens) uses the fresh JWT. socket.io-client reads // auth from this property at handshake time. if (this.socket) { const socketAuth = (this.socket as unknown as { auth: Record }).auth; if (socketAuth) socketAuth.cloudAppJwt = fresh.token; } this.params.logger.info(`[cloud-app-connection] refreshed JWT, new expiry ${fresh.expiresAt}`); } catch (error) { // Non-401 errors should be retried — schedule a short retry. 401 means // the secret was rotated under us and there's no point retrying. if (error instanceof CloudAppTokenExchangeError && error.status === 401) { this.params.logger.error('[cloud-app-connection] refresh rejected with 401; secret rotated?', error.message); return; } this.params.logger.warn('[cloud-app-connection] refresh failed, retrying in 30s', error); this.refreshTimer = setTimeout(() => void this.runRefresh(), 30_000); return; } this.scheduleRefresh(); } private async refreshIfStale(): Promise { if (!this.cachedToken) return; const now = Date.now(); if (this.cachedToken.expiresAtMs - now > this.params.refreshLeadMs) return; await this.runRefresh(); } }