/** * System — single, unified entry point for the camstack client API. * * Devices are returned as typed `DeviceProxy` objects (auto-injecting * `deviceId` + `nodeId`), system caps are exposed as typed namespaces * (`system.userManagement`, `system.storage`, etc.), and live events * flow through a single `subscribeEvent` helper. * * The escape hatches (`trpcClient`, `wsClient`) are still public so the * ui-library Provider can seed React Query with the same WS connection * — a future phase will narrow the surface further. */ import { createWSClient, type TRPCClient } from '@trpc/client'; import type { DeviceProxy, DeviceInfo, DeviceQueryFilters, DeviceLifecycleListener, SystemProxy, PasskeySummary } from '@camstack/types'; import type { BackendAppRouter } from './backend-router.js'; import type { BackendConnectionState } from './types.js'; /** * Second-factor kinds reported by `auth.login`. Kept in the SDK (not * imported from a cap) because it's a login-flow contract, not a cap * surface. `totp` → the `user-management` TOTP surface; `passkey` → any * `user-passkeys` cap provider. */ export type SecondFactorKind = 'totp' | 'passkey'; /** * Canonical user-info shape returned by `getMe()`. Mirrors the server's * `auth.me` output — kept narrow so SDK consumers don't need the full * tRPC inference chain to type a login flow. */ export interface UserInfo { readonly id: string; readonly username: string; readonly isAdmin: boolean; readonly permissions?: { readonly allowedAddons?: ReadonlyArray | '*'; readonly allowedDevices?: Readonly | '*'>>; }; } export interface SystemConfig { /** Backend server URL (e.g. "http://localhost:4443"). */ readonly serverUrl: string; /** JWT token for authentication. */ readonly token?: string; /** Use WebSocket transport (default: true in browser, false in Node). */ readonly useWebSocket?: boolean; /** Optional per-event connection-state callback. */ readonly onConnectionChange?: (state: BackendConnectionState) => void; /** Initial WS reconnect delay in ms (default 2000, capped via maxRetryDelayMs). */ readonly retryDelayMs?: number; /** Max WS reconnect delay in ms (default 30_000). */ readonly maxRetryDelayMs?: number; } type ConnectionListener = (state: BackendConnectionState, version: number) => void; export interface SystemLiveEvent { readonly id: string; readonly timestamp: Date | string; readonly source: { readonly type: string; readonly id: string | number; }; readonly category: string; readonly data: TData; } export type SystemLiveEventListener = (event: SystemLiveEvent) => void; type TrpcClient = TRPCClient; type TrpcWsClient = ReturnType; export declare class System { /** Active server base URL. Mutable via `switchServerUrl()` so the * endpoint-race helper can pivot the transport onto a LAN candidate * after the initial public-URL bootstrap. */ private _serverUrl; private readonly useWs; private readonly baseRetryMs; private readonly maxRetryMs; private readonly onConnectionChange; private token; private _trpcClient; private _wsClient; /** * One underlying `live.onEvent` subscription per DISTINCT category, fanned * out to every listener. * * Measured on the viewer's boot: ~40 subscriptions, of which 24 were twelve * cameras subscribing to the SAME two motion categories and filtering by * deviceId in their own callbacks. Each was its own WS handshake on a * transport that does not batch. Deduping here needs no protocol change and * no cooperation from callers — `subscribeEvent` keeps its exact shape. */ private readonly _eventSubs; private mirror; private mirrorInit; private connected; private connectedPromise; private _systemProxy; private _connectionVersion; /** * Bumped on every `reconnect()` BEFORE the previous socket is closed. * WS `onOpen`/`onClose` close over the epoch at `createWSClient` time * so a delayed close from a torn-down socket cannot mark the replacement * as disconnected (the LAN-switch shape: health 3/3, APIs then "offline"). */ private _wsEpoch; private readonly connectionListeners; constructor(config: SystemConfig); /** Active server base URL (no trailing slash). */ get serverUrl(): string; get connectionVersion(): number; /** * Subscribe to connection-state transitions. Called with `('connected' * | 'disconnected' | 'connecting', version)` whenever the SDK opens, * closes, or starts a manual reconnect. Listener errors are swallowed * so a misbehaving consumer cannot break the SDK's event loop. */ subscribeConnectionEvents(cb: ConnectionListener): () => void; private emitConnectionEvent; /** * Wait until the underlying tRPC transport is connected AND the * server has responded to a cheap auth round-trip (`auth.me`). This * is the canonical "ready to issue queries" gate. * * Why a probe, not just `ws.readyState === OPEN`? * The WS handshake completes asynchronously: tRPC's `wsLink` * queues outgoing messages and only flushes them after `open()` * resolves (post `connectionParams` send). On the server, the * tRPC context is created lazily once the connectionParams * message is received. A query fired between WS-open and * connection-params-processed is technically queued by tRPC, but * the auth context for that query is only resolved once the * handshake message is decoded server-side. A probe round-trip is * the safest way to confirm both sides have agreed on the auth * identity before the React tree starts firing parallel queries * (which can otherwise land before any addon-side service * discovery has settled, returning empty results that get cached). * * Idempotent — concurrent callers await the same in-flight Promise. * Bounded by `timeoutMs` (default 15s) — beyond which a * `Error('System.awaitConnected: probe timed out after Xms')` is * thrown so the host can render a clear error state instead of * hanging on a bricked socket. */ awaitConnected(timeoutMs?: number): Promise; /** * Warm-boot the device mirror. Awaits the transport probe first * (`awaitConnected`) so the three mirror round-trips * (`getAllBindings` + `getAllSnapshots` + `listAll`) cannot race * against the WS auth handshake. Subsequent `getDevice(id)` calls * are sync; live `device.*` event subscriptions keep the caches * fresh. * * Idempotent — concurrent callers await the same in-flight Promise. */ init(timeoutMs?: number): Promise; /** Promise that resolves once `init()` has completed. */ awaitReady(): Promise; /** True after `init()` resolves. */ isReady(): boolean; /** True after the transport probe has succeeded at least once. */ isConnected(): boolean; /** * Force a fresh WebSocket handshake. Tears down the wsClient + tRPC * client + mirror (the mirror captures the tRPC reference at * construction time and would otherwise dispatch through a closed * client) and rebuilds them. No-op for HTTP transport. */ reconnect(): void; /** * Pivot the underlying tRPC transport onto a different base URL. * Used by the endpoint-race flow: the SDK opens against the public * URL the operator provided, calls `localNetwork.getConnectionEndpoints` * to discover LAN candidates, races them, and (when a faster one wins) * calls `switchServerUrl(winner)` to migrate every subsequent query * onto the LAN path without losing auth state. * * Keeps the auth token. Tears down the WS + mirror and rebuilds them * against the new URL — same machinery as `reconnect()` but with a * different target. */ switchServerUrl(nextUrl: string): void; /** * Race the candidate base URLs reported by the hub's `local-network` * cap, pick the fastest one that responds, and (if it's different * from the current URL) pivot the transport onto it. * * Flow: * 1. Query `localNetwork.getConnectionEndpoints({ port })` over the * already-authenticated tRPC channel — the cap is auth-gated, * so the LAN IPs never leak to anonymous callers. * 2. For each candidate, fire a HEAD on `{baseUrl}/trpc/health` * with a short timeout (default 1500ms). The first 2xx wins. * 3. If the winner differs from `this.serverUrl`, call * `switchServerUrl(winner)` and return it. Otherwise return * the current URL unchanged. * * Bounded — if every candidate times out we keep the current URL. * Idempotent — safe to call on every connect / reconnect / network * change event. */ raceConnectionEndpoints(options?: { /** Per-candidate probe timeout. Default 1500ms. */ readonly perCandidateTimeoutMs?: number; /** Skip IPv6 candidates. Default `false`. */ readonly ipv4Only?: boolean; }): Promise<{ winner: string; switched: boolean; }>; /** Tear down WS connection + mirror. The instance is unusable afterwards. */ close(): void; private disposeMirror; login(username: string, password: string): Promise<{ token: string; requiresTotp?: boolean; secondFactors?: readonly SecondFactorKind[]; }>; /** * Second leg of the 2FA login. The caller passes the challenge * token returned by `login` (when `requiresTotp: true`) plus the * 6-digit code from the user's authenticator app. On success the * server mints the real session JWT, which we set as the active * token on this client. */ loginVerifyTotp(challengeToken: string, code: string): Promise<{ token: string; }>; /** * Passkey second leg — step 1. Exchanges the login challenge token for * the WebAuthn assertion options; the caller feeds `optionsJSON` to * `@simplewebauthn/browser` `startAuthentication`. PUBLIC (pre-session). */ loginBeginPasskey(challengeToken: string): Promise<{ optionsJSON: Record; }>; /** * Passkey second leg — step 2. Submits the browser assertion; on * success the server mints the real session JWT, which we set as the * active token on this client. */ loginVerifyPasskey(challengeToken: string, response: Record): Promise<{ token: string; }>; /** * Usernameless (passkey-FIRST) login — step 1. Fetches discoverable- * credential assertion options (EMPTY `allowCredentials`); the caller * feeds `optionsJSON` to `@simplewebauthn/browser` `startAuthentication` * and the browser's own passkey picker takes over. PUBLIC (pre-session, * no challenge token — the WebAuthn challenge is the whole state). */ passkeyLoginBegin(): Promise<{ optionsJSON: Record; }>; /** * Usernameless login — step 2. Submits the browser assertion; on * success the server resolves the credential's owner and mints the * real session JWT, which we set as the active token on this client. * * Second-factor aware (mirrors `login`): when the account still has a * second factor to satisfy (TOTP — 2FA applies uniformly after any * primary method), the server returns a short-lived CHALLENGE token + * `secondFactors`. That token is NOT persisted as the active session * — the caller completes the remaining factor via `loginVerifyTotp`. */ passkeyLoginFinish(response: Record): Promise<{ token: string; requiresTotp?: boolean; secondFactors?: readonly SecondFactorKind[]; }>; logout(): Promise; getMe(): Promise; changeOwnPassword(input: { currentPassword: string; newPassword: string; }): Promise<{ success: true; }>; setupOwnTotp(): Promise<{ secret: string; otpauthUrl: string; }>; confirmOwnTotp(input: { code: string; }): Promise<{ success: true; }>; disableOwnTotp(): Promise<{ success: true; }>; getOwnTotpStatus(): Promise<{ enabled: boolean; confirmedAt: number | null; }>; beginOwnPasskeyRegistration(): Promise<{ optionsJSON: Record; }>; finishOwnPasskeyRegistration(input: { response: Record; label: string; }): Promise<{ success: true; credentialId: string; }>; listOwnPasskeys(): Promise; removeOwnPasskey(input: { credentialId: string; }): Promise<{ success: true; }>; /** * Own passkey second-factor preference (opt-in, default OFF). * Enrolling a passkey only enables passkey-first sign-in; this flag * additionally demands the passkey after every password login. */ getOwnPasskeySecondFactorPreference(): Promise<{ enabled: boolean; }>; setOwnPasskeySecondFactorPreference(input: { enabled: boolean; }): Promise<{ success: true; }>; /** Update the auth token (e.g. after login or token refresh). */ setToken(token: string): void; /** * Synchronous snapshot of every device matching the optional filters. * Backed by the `SystemMirror` warm-boot cache — call `init()` first * (or `awaitReady()`) before invoking. Returns an empty array if the * mirror has not yet been booted. * * Each returned proxy has `binding` populated from the mirror's * binding cache (Phase 5 dedup), so consumers no longer need to * make a separate `deviceManager.getBindings` round-trip. */ listDevices(filters?: DeviceQueryFilters): readonly DeviceProxy[]; /** * Sync `DeviceInfo` snapshot (name, canonical type, online, …) for one device * from the warm-boot mirror — `null` if the mirror isn't booted or the device * is unknown. Unlike a `DeviceProxy` (cap accessors only), this carries the * display identity the UI needs to render a device on first paint. */ getDeviceInfo(deviceId: number): DeviceInfo | null; /** * The `DeviceInfo` snapshots for the current device set (the warm-boot cache), * honouring the same `filters` as {@link listDevices}. Lets a UI render a named * device list immediately without waiting for per-device lifecycle events. */ listDeviceInfos(filters?: DeviceQueryFilters): readonly DeviceInfo[]; /** * Sync lookup by numeric id. `null` if the mirror has not been booted * or the device is unknown. */ getDevice(deviceId: number): DeviceProxy | null; /** Sync lookup by display name (exact match). */ getDeviceByName(name: string): DeviceProxy | null; /** Sync lookup by stableId. */ getDeviceByStableId(stableId: string): DeviceProxy | null; /** * Resolve when a device with `deviceId` becomes available. Resolves * immediately if already known; rejects with a timeout error * otherwise (default 30s). */ waitForDevice(deviceId: number, timeoutMs?: number): Promise; /** Subscribe to `device.registered` events. */ onDeviceAdded(cb: DeviceLifecycleListener): () => void; /** Subscribe to `device.unregistered` events. */ onDeviceRemoved(cb: DeviceLifecycleListener): () => void; /** * Patch the proxy's `binding` field from the mirror's cache. The * generated `createDeviceProxy()` already sets `binding` to the * binding it was constructed with — this is a defensive overwrite * that uses the latest cached entry in case a `binding-changed` * event landed between proxy creation and access. */ private attachBinding; /** Fetch the latest cached binding from the mirror, or `null`. */ private lookupBinding; get addonPages(): SystemProxy['addonPages']; get addonSettings(): SystemProxy['addonSettings']; get alerts(): SystemProxy['alerts']; get audioAnalyzer(): SystemProxy['audioAnalyzer']; get audioCodec(): SystemProxy['audioCodec']; get backup(): SystemProxy['backup']; get decoder(): SystemProxy['decoder']; get deviceManager(): SystemProxy['deviceManager']; get deviceProvider(): SystemProxy['deviceProvider']; get deviceState(): SystemProxy['deviceState']; get metricsProvider(): SystemProxy['metricsProvider']; get notificationOutput(): SystemProxy['notificationOutput']; get pipelineExecutor(): SystemProxy['pipelineExecutor']; get pipelineOrchestrator(): SystemProxy['pipelineOrchestrator']; get pipelineRunner(): SystemProxy['pipelineRunner']; get settingsStore(): SystemProxy['settingsStore']; get storage(): SystemProxy['storage']; get streamBroker(): SystemProxy['streamBroker']; get turnProvider(): SystemProxy['turnProvider']; get userManagement(): SystemProxy['userManagement']; /** * Subscribe to a single event category. Returns an unsubscribe * handle. Errors thrown by the listener are swallowed so a single * misbehaving consumer cannot tear down the WS subscription. * * Categories should be values from the `EventCategory` enum * (`@camstack/types`) — passing a raw string works for forward-compat * but loses type safety. The SDK forwards the value verbatim to the * server's `live.onEvent` subscription. * * **N callers on one category share ONE subscription.** The caller's shape * is unchanged, and it is not asked to coordinate: twelve camera cards each * subscribing to the motion category and filtering by deviceId is the normal * pattern, and it used to open twelve WS subscriptions. */ subscribeEvent(category: string, cb: SystemLiveEventListener): () => void; /** * Drop every shared subscription. * * Called when the tRPC client is rebuilt: the handles above belong to the * OLD client and unsubscribing them is meaningless, while keeping them in * the map would make the next `subscribeEvent` attach a listener to a dead * socket and go silent — the worst outcome, because it looks like "no * events happened". */ private resetEventSubs; /** Direct tRPC client. Read once per call; rebuilt on `reconnect()`. */ get trpcClient(): TrpcClient; /** * Underlying WSClient (or `null` for HTTP transport). Used by * advanced consumers that need direct access to the WebSocket * (e.g. for keep-alive metrics). Rebuilt on `reconnect()`. */ get wsClient(): TrpcWsClient | null; private buildTrpcClient; } /** Create a `System` instance. Convenience factory. */ export declare function createSystem(config: SystemConfig): System; /** * Race a list of candidate base URLs and return the first one that * responds to `GET {baseUrl}/trpc/health` with a 2xx within * `timeoutMs`. Returns `null` if every candidate fails / times out. * * Implementation notes: * - Uses `fetch` with `AbortController` for per-candidate cutoff so a * stalled candidate doesn't pin the wallclock for the whole race. * - Cancels every still-pending probe as soon as the first one * succeeds — no wasted bandwidth on the loser candidates. * - `/trpc/health` is the only endpoint guaranteed to respond on every * CamStack deployment (registered alongside the tRPC plugin). It is * intentionally NOT auth-gated since it's used by load balancers / * uptime probes — same surface every reverse proxy already monitors. * - HEAD would be nicer but Cloudflare Tunnel + some browsers * mishandle HEAD on the ingress path; GET is safer. * * Exported so non-System callers (CLI helpers, tests) can race their * own candidate lists without instantiating a full System. */ export declare function raceFastestEndpoint(candidates: ReadonlyArray, timeoutMs: number): Promise; export {};