@camstack/sdk 1.2.127 → 1.2.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/system.d.ts CHANGED
@@ -1,456 +1,515 @@
1
1
  /**
2
- * System — single, unified entry point for the camstack client API.
3
- *
4
- * Devices are returned as typed `DeviceProxy` objects (auto-injecting
5
- * `deviceId` + `nodeId`), system caps are exposed as typed namespaces
6
- * (`system.userManagement`, `system.storage`, etc.), and live events
7
- * flow through a single `subscribeEvent` helper.
8
- *
9
- * The escape hatches (`trpcClient`, `wsClient`) are still public so the
10
- * ui-library Provider can seed React Query with the same WS connection
11
- * — a future phase will narrow the surface further.
12
- */
13
- import { createWSClient, type TRPCClient } from '@trpc/client';
14
- import type { DeviceProxy, DeviceInfo, DeviceQueryFilters, DeviceLifecycleListener, SystemProxy, PasskeySummary } from '@camstack/types';
15
- import type { BackendAppRouter } from './backend-router.js';
16
- import type { BackendConnectionState } from './types.js';
2
+ * System — single, unified entry point for the camstack client API.
3
+ *
4
+ * Devices are returned as typed `DeviceProxy` objects (auto-injecting
5
+ * `deviceId` + `nodeId`), system caps are exposed as typed namespaces
6
+ * (`system.userManagement`, `system.storage`, etc.), and live events
7
+ * flow through a single `subscribeEvent` helper.
8
+ *
9
+ * The escape hatches (`trpcClient`, `wsClient`) are still public so the
10
+ * ui-library Provider can seed React Query with the same WS connection
11
+ * — a future phase will narrow the surface further.
12
+ */
13
+ import { createWSClient, type TRPCClient } from "@trpc/client";
14
+ import type { DeviceProxy, DeviceInfo, DeviceQueryFilters, DeviceLifecycleListener, SystemProxy, PasskeySummary } from "@camstack/types";
15
+ import type { BackendAppRouter } from "./backend-router.js";
16
+ import type { BackendConnectionState } from "./types.js";
17
17
  /**
18
- * Second-factor kinds reported by `auth.login`. Kept in the SDK (not
19
- * imported from a cap) because it's a login-flow contract, not a cap
20
- * surface. `totp` → the `user-management` TOTP surface; `passkey` → any
21
- * `user-passkeys` cap provider.
22
- */
23
- export type SecondFactorKind = 'totp' | 'passkey';
18
+ * Second-factor kinds reported by `auth.login`. Kept in the SDK (not
19
+ * imported from a cap) because it's a login-flow contract, not a cap
20
+ * surface. `totp` → the `user-management` TOTP surface; `passkey` → any
21
+ * `user-passkeys` cap provider.
22
+ */
23
+ export type SecondFactorKind = "totp" | "passkey";
24
24
  /**
25
- * Canonical user-info shape returned by `getMe()`. Mirrors the server's
26
- * `auth.me` output — kept narrow so SDK consumers don't need the full
27
- * tRPC inference chain to type a login flow.
28
- */
25
+ * Canonical user-info shape returned by `getMe()`. Mirrors the server's
26
+ * `auth.me` output — kept narrow so SDK consumers don't need the full
27
+ * tRPC inference chain to type a login flow.
28
+ */
29
29
  export interface UserInfo {
30
- readonly id: string;
31
- readonly username: string;
32
- readonly isAdmin: boolean;
33
- readonly permissions?: {
34
- readonly allowedAddons?: ReadonlyArray<string> | '*';
35
- readonly allowedDevices?: Readonly<Record<string, ReadonlyArray<string> | '*'>>;
36
- };
30
+ readonly id: string;
31
+ readonly username: string;
32
+ readonly isAdmin: boolean;
33
+ readonly permissions?: {
34
+ readonly allowedAddons?: ReadonlyArray<string> | "*";
35
+ readonly allowedDevices?: Readonly<Record<string, ReadonlyArray<string> | "*">>;
36
+ };
37
37
  }
38
38
  export interface SystemConfig {
39
- /** Backend server URL (e.g. "http://localhost:4443"). */
40
- readonly serverUrl: string;
41
- /** JWT token for authentication. */
42
- readonly token?: string;
43
- /** Use WebSocket transport (default: true in browser, false in Node). */
44
- readonly useWebSocket?: boolean;
45
- /** Optional per-event connection-state callback. */
46
- readonly onConnectionChange?: (state: BackendConnectionState) => void;
47
- /** Initial WS reconnect delay in ms (default 2000, capped via maxRetryDelayMs). */
48
- readonly retryDelayMs?: number;
49
- /** Max WS reconnect delay in ms (default 30_000). */
50
- readonly maxRetryDelayMs?: number;
39
+ /** Backend server URL (e.g. "http://localhost:4443"). */
40
+ readonly serverUrl: string;
41
+ /** JWT token for authentication. */
42
+ readonly token?: string;
43
+ /** Use WebSocket transport (default: true in browser, false in Node). */
44
+ readonly useWebSocket?: boolean;
45
+ /** Optional per-event connection-state callback. */
46
+ readonly onConnectionChange?: (state: BackendConnectionState) => void;
47
+ /** Initial WS reconnect delay in ms (default 2000, capped via maxRetryDelayMs). */
48
+ readonly retryDelayMs?: number;
49
+ /** Max WS reconnect delay in ms (default 30_000). */
50
+ readonly maxRetryDelayMs?: number;
51
51
  }
52
52
  type ConnectionListener = (state: BackendConnectionState, version: number) => void;
53
+ /** Options of every login leg. */
54
+ export interface LoginOptions {
55
+ /** Ask for a short access token + a rotating refresh token (recommended). */
56
+ readonly refreshable?: boolean;
57
+ }
58
+ /** What a login leg returns: a session, or a second-factor challenge. */
59
+ export interface LoginOutcome {
60
+ readonly token: string;
61
+ readonly requiresTotp?: boolean;
62
+ readonly secondFactors?: readonly SecondFactorKind[];
63
+ /** When the access token expires (epoch ms). */
64
+ readonly expiresAt?: number;
65
+ /** The server-side login session. */
66
+ readonly sessionId?: string;
67
+ /** When the session expires unless refreshed (epoch ms). */
68
+ readonly sessionExpiresAt?: number;
69
+ /** Only for `refreshable: true` — swap it at `refreshSession` before `expiresAt`. */
70
+ readonly refreshToken?: string;
71
+ }
72
+ /** What `refreshSession` returns. */
73
+ export interface RefreshedSession {
74
+ readonly token: string;
75
+ readonly refreshToken: string;
76
+ readonly expiresAt: number;
77
+ readonly sessionId: string;
78
+ readonly sessionExpiresAt: number;
79
+ readonly user: {
80
+ readonly id: string;
81
+ readonly username: string;
82
+ readonly isAdmin: boolean;
83
+ };
84
+ }
53
85
  export interface SystemLiveEvent<TData = unknown> {
54
- readonly id: string;
55
- readonly timestamp: Date | string;
56
- readonly source: {
57
- readonly type: string;
58
- readonly id: string | number;
59
- };
60
- readonly category: string;
61
- readonly data: TData;
86
+ readonly id: string;
87
+ readonly timestamp: Date | string;
88
+ readonly source: {
89
+ readonly type: string;
90
+ readonly id: string | number;
91
+ };
92
+ readonly category: string;
93
+ readonly data: TData;
62
94
  }
63
95
  export type SystemLiveEventListener<TData = unknown> = (event: SystemLiveEvent<TData>) => void;
64
96
  type TrpcClient = TRPCClient<BackendAppRouter>;
65
97
  type TrpcWsClient = ReturnType<typeof createWSClient>;
66
98
  export declare class System {
67
- /** Active server base URL. Mutable via `switchServerUrl()` so the
68
- * endpoint-race helper can pivot the transport onto a LAN candidate
69
- * after the initial public-URL bootstrap. */
70
- private _serverUrl;
71
- private readonly useWs;
72
- private readonly baseRetryMs;
73
- private readonly maxRetryMs;
74
- private readonly onConnectionChange;
75
- private token;
76
- private _trpcClient;
77
- private _wsClient;
78
- /**
79
- * One underlying `live.onEvent` subscription per DISTINCT category, fanned
80
- * out to every listener.
81
- *
82
- * Measured on the viewer's boot: ~40 subscriptions, of which 24 were twelve
83
- * cameras subscribing to the SAME two motion categories and filtering by
84
- * deviceId in their own callbacks. Each was its own WS handshake on a
85
- * transport that does not batch. Deduping here needs no protocol change and
86
- * no cooperation from callers — `subscribeEvent` keeps its exact shape.
87
- */
88
- private readonly _eventSubs;
89
- private mirror;
90
- private mirrorInit;
91
- private connected;
92
- private connectedPromise;
93
- private _systemProxy;
94
- private _connectionVersion;
95
- /**
96
- * Bumped on every `reconnect()` BEFORE the previous socket is closed.
97
- * WS `onOpen`/`onClose` close over the epoch at `createWSClient` time
98
- * so a delayed close from a torn-down socket cannot mark the replacement
99
- * as disconnected (the LAN-switch shape: health 3/3, APIs then "offline").
100
- */
101
- private _wsEpoch;
102
- private readonly connectionListeners;
103
- constructor(config: SystemConfig);
104
- /** Active server base URL (no trailing slash). */
105
- get serverUrl(): string;
106
- get connectionVersion(): number;
107
- /**
108
- * Subscribe to connection-state transitions. Called with `('connected'
109
- * | 'disconnected' | 'connecting', version)` whenever the SDK opens,
110
- * closes, or starts a manual reconnect. Listener errors are swallowed
111
- * so a misbehaving consumer cannot break the SDK's event loop.
112
- */
113
- subscribeConnectionEvents(cb: ConnectionListener): () => void;
114
- private emitConnectionEvent;
115
- /**
116
- * Wait until the underlying tRPC transport is connected AND the
117
- * server has responded to a cheap auth round-trip (`auth.me`). This
118
- * is the canonical "ready to issue queries" gate.
119
- *
120
- * Why a probe, not just `ws.readyState === OPEN`?
121
- * The WS handshake completes asynchronously: tRPC's `wsLink`
122
- * queues outgoing messages and only flushes them after `open()`
123
- * resolves (post `connectionParams` send). On the server, the
124
- * tRPC context is created lazily once the connectionParams
125
- * message is received. A query fired between WS-open and
126
- * connection-params-processed is technically queued by tRPC, but
127
- * the auth context for that query is only resolved once the
128
- * handshake message is decoded server-side. A probe round-trip is
129
- * the safest way to confirm both sides have agreed on the auth
130
- * identity before the React tree starts firing parallel queries
131
- * (which can otherwise land before any addon-side service
132
- * discovery has settled, returning empty results that get cached).
133
- *
134
- * Idempotent — concurrent callers await the same in-flight Promise.
135
- * Bounded by `timeoutMs` (default 15s) — beyond which a
136
- * `Error('System.awaitConnected: probe timed out after Xms')` is
137
- * thrown so the host can render a clear error state instead of
138
- * hanging on a bricked socket.
139
- */
140
- awaitConnected(timeoutMs?: number): Promise<void>;
141
- /**
142
- * Warm-boot the device mirror. Awaits the transport probe first
143
- * (`awaitConnected`) so the three mirror round-trips
144
- * (`getAllBindings` + `getAllSnapshots` + `listAll`) cannot race
145
- * against the WS auth handshake. Subsequent `getDevice(id)` calls
146
- * are sync; live `device.*` event subscriptions keep the caches
147
- * fresh.
148
- *
149
- * Idempotent — concurrent callers await the same in-flight Promise.
150
- */
151
- init(timeoutMs?: number): Promise<void>;
152
- /** Promise that resolves once `init()` has completed. */
153
- awaitReady(): Promise<void>;
154
- /** True after `init()` resolves. */
155
- isReady(): boolean;
156
- /** True after the transport probe has succeeded at least once. */
157
- isConnected(): boolean;
158
- /**
159
- * Force a fresh WebSocket handshake. Tears down the wsClient + tRPC
160
- * client + mirror (the mirror captures the tRPC reference at
161
- * construction time and would otherwise dispatch through a closed
162
- * client) and rebuilds them. No-op for HTTP transport.
163
- */
164
- reconnect(): void;
165
- /**
166
- * Pivot the underlying tRPC transport onto a different base URL.
167
- * Used by the endpoint-race flow: the SDK opens against the public
168
- * URL the operator provided, calls `localNetwork.getConnectionEndpoints`
169
- * to discover LAN candidates, races them, and (when a faster one wins)
170
- * calls `switchServerUrl(winner)` to migrate every subsequent query
171
- * onto the LAN path without losing auth state.
172
- *
173
- * Keeps the auth token. Tears down the WS + mirror and rebuilds them
174
- * against the new URL — same machinery as `reconnect()` but with a
175
- * different target.
176
- */
177
- switchServerUrl(nextUrl: string): void;
178
- /**
179
- * Race the candidate base URLs reported by the hub's `local-network`
180
- * cap, pick the fastest one that responds, and (if it's different
181
- * from the current URL) pivot the transport onto it.
182
- *
183
- * Flow:
184
- * 1. Query `localNetwork.getConnectionEndpoints({ port })` over the
185
- * already-authenticated tRPC channel — the cap is auth-gated,
186
- * so the LAN IPs never leak to anonymous callers.
187
- * 2. For each candidate, fire a GET on `{baseUrl}/health` (the
188
- * auth-free liveness route) with a short timeout (default 1500ms).
189
- * The first 2xx wins.
190
- * 3. If the winner differs from `this.serverUrl`, call
191
- * `switchServerUrl(winner)` and return it. Otherwise return
192
- * the current URL unchanged.
193
- *
194
- * Bounded — if every candidate times out we keep the current URL.
195
- * Idempotent — safe to call on every connect / reconnect / network
196
- * change event.
197
- */
198
- raceConnectionEndpoints(options?: {
199
- /** Per-candidate probe timeout. Default 1500ms. */
200
- readonly perCandidateTimeoutMs?: number;
201
- /** Skip IPv6 candidates. Default `false`. */
202
- readonly ipv4Only?: boolean;
203
- }): Promise<{
204
- winner: string;
205
- switched: boolean;
206
- }>;
207
- /** Tear down WS connection + mirror. The instance is unusable afterwards. */
208
- close(): void;
209
- private disposeMirror;
210
- login(username: string, password: string): Promise<{
211
- token: string;
212
- requiresTotp?: boolean;
213
- secondFactors?: readonly SecondFactorKind[];
214
- }>;
215
- /**
216
- * Second leg of the 2FA login. The caller passes the challenge
217
- * token returned by `login` (when `requiresTotp: true`) plus the
218
- * 6-digit code from the user's authenticator app. On success the
219
- * server mints the real session JWT, which we set as the active
220
- * token on this client.
221
- */
222
- loginVerifyTotp(challengeToken: string, code: string): Promise<{
223
- token: string;
224
- }>;
225
- /**
226
- * Passkey second leg — step 1. Exchanges the login challenge token for
227
- * the WebAuthn assertion options; the caller feeds `optionsJSON` to
228
- * `@simplewebauthn/browser` `startAuthentication`. PUBLIC (pre-session).
229
- */
230
- loginBeginPasskey(challengeToken: string): Promise<{
231
- optionsJSON: Record<string, unknown>;
232
- }>;
233
- /**
234
- * Passkey second leg — step 2. Submits the browser assertion; on
235
- * success the server mints the real session JWT, which we set as the
236
- * active token on this client.
237
- */
238
- loginVerifyPasskey(challengeToken: string, response: Record<string, unknown>): Promise<{
239
- token: string;
240
- }>;
241
- /**
242
- * Usernameless (passkey-FIRST) login — step 1. Fetches discoverable-
243
- * credential assertion options (EMPTY `allowCredentials`); the caller
244
- * feeds `optionsJSON` to `@simplewebauthn/browser` `startAuthentication`
245
- * and the browser's own passkey picker takes over. PUBLIC (pre-session,
246
- * no challenge token — the WebAuthn challenge is the whole state).
247
- */
248
- passkeyLoginBegin(): Promise<{
249
- optionsJSON: Record<string, unknown>;
250
- }>;
251
- /**
252
- * Usernameless login — step 2. Submits the browser assertion; on
253
- * success the server resolves the credential's owner and mints the
254
- * real session JWT, which we set as the active token on this client.
255
- *
256
- * Second-factor aware (mirrors `login`): when the account still has a
257
- * second factor to satisfy (TOTP — 2FA applies uniformly after any
258
- * primary method), the server returns a short-lived CHALLENGE token +
259
- * `secondFactors`. That token is NOT persisted as the active session
260
- * — the caller completes the remaining factor via `loginVerifyTotp`.
261
- */
262
- passkeyLoginFinish(response: Record<string, unknown>): Promise<{
263
- token: string;
264
- requiresTotp?: boolean;
265
- secondFactors?: readonly SecondFactorKind[];
266
- }>;
267
- logout(): Promise<void>;
268
- getMe(): Promise<UserInfo>;
269
- changeOwnPassword(input: {
270
- currentPassword: string;
271
- newPassword: string;
272
- }): Promise<{
273
- success: true;
274
- }>;
275
- setupOwnTotp(): Promise<{
276
- secret: string;
277
- otpauthUrl: string;
278
- }>;
279
- confirmOwnTotp(input: {
280
- code: string;
281
- }): Promise<{
282
- success: true;
283
- }>;
284
- disableOwnTotp(): Promise<{
285
- success: true;
286
- }>;
287
- getOwnTotpStatus(): Promise<{
288
- enabled: boolean;
289
- confirmedAt: number | null;
290
- }>;
291
- beginOwnPasskeyRegistration(): Promise<{
292
- optionsJSON: Record<string, unknown>;
293
- }>;
294
- finishOwnPasskeyRegistration(input: {
295
- response: Record<string, unknown>;
296
- label: string;
297
- }): Promise<{
298
- success: true;
299
- credentialId: string;
300
- }>;
301
- listOwnPasskeys(): Promise<readonly PasskeySummary[]>;
302
- removeOwnPasskey(input: {
303
- credentialId: string;
304
- }): Promise<{
305
- success: true;
306
- }>;
307
- /**
308
- * Own passkey second-factor preference (opt-in, default OFF).
309
- * Enrolling a passkey only enables passkey-first sign-in; this flag
310
- * additionally demands the passkey after every password login.
311
- */
312
- getOwnPasskeySecondFactorPreference(): Promise<{
313
- enabled: boolean;
314
- }>;
315
- setOwnPasskeySecondFactorPreference(input: {
316
- enabled: boolean;
317
- }): Promise<{
318
- success: true;
319
- }>;
320
- /** Update the auth token (e.g. after login or token refresh). */
321
- setToken(token: string): void;
322
- /**
323
- * Synchronous snapshot of every device matching the optional filters.
324
- * Backed by the `SystemMirror` warm-boot cache — call `init()` first
325
- * (or `awaitReady()`) before invoking. Returns an empty array if the
326
- * mirror has not yet been booted.
327
- *
328
- * Each returned proxy has `binding` populated from the mirror's
329
- * binding cache (Phase 5 dedup), so consumers no longer need to
330
- * make a separate `deviceManager.getBindings` round-trip.
331
- */
332
- listDevices(filters?: DeviceQueryFilters): readonly DeviceProxy[];
333
- /**
334
- * Sync `DeviceInfo` snapshot (name, canonical type, online, …) for one device
335
- * from the warm-boot mirror — `null` if the mirror isn't booted or the device
336
- * is unknown. Unlike a `DeviceProxy` (cap accessors only), this carries the
337
- * display identity the UI needs to render a device on first paint.
338
- */
339
- getDeviceInfo(deviceId: number): DeviceInfo | null;
340
- /**
341
- * The `DeviceInfo` snapshots for the current device set (the warm-boot cache),
342
- * honouring the same `filters` as {@link listDevices}. Lets a UI render a named
343
- * device list immediately without waiting for per-device lifecycle events.
344
- */
345
- listDeviceInfos(filters?: DeviceQueryFilters): readonly DeviceInfo[];
346
- /**
347
- * Sync lookup by numeric id. `null` if the mirror has not been booted
348
- * or the device is unknown.
349
- */
350
- getDevice(deviceId: number): DeviceProxy | null;
351
- /** Sync lookup by display name (exact match). */
352
- getDeviceByName(name: string): DeviceProxy | null;
353
- /** Sync lookup by stableId. */
354
- getDeviceByStableId(stableId: string): DeviceProxy | null;
355
- /**
356
- * Resolve when a device with `deviceId` becomes available. Resolves
357
- * immediately if already known; rejects with a timeout error
358
- * otherwise (default 30s).
359
- */
360
- waitForDevice(deviceId: number, timeoutMs?: number): Promise<DeviceProxy>;
361
- /** Subscribe to `device.registered` events. */
362
- onDeviceAdded(cb: DeviceLifecycleListener): () => void;
363
- /** Subscribe to `device.unregistered` events. */
364
- onDeviceRemoved(cb: DeviceLifecycleListener): () => void;
365
- /**
366
- * Patch the proxy's `binding` field from the mirror's cache. The
367
- * generated `createDeviceProxy()` already sets `binding` to the
368
- * binding it was constructed with — this is a defensive overwrite
369
- * that uses the latest cached entry in case a `binding-changed`
370
- * event landed between proxy creation and access.
371
- */
372
- private attachBinding;
373
- /** Fetch the latest cached binding from the mirror, or `null`. */
374
- private lookupBinding;
375
- get addonPages(): SystemProxy['addonPages'];
376
- get addonSettings(): SystemProxy['addonSettings'];
377
- get alerts(): SystemProxy['alerts'];
378
- get audioAnalyzer(): SystemProxy['audioAnalyzer'];
379
- get audioCodec(): SystemProxy['audioCodec'];
380
- get backup(): SystemProxy['backup'];
381
- get decoder(): SystemProxy['decoder'];
382
- get deviceManager(): SystemProxy['deviceManager'];
383
- get deviceProvider(): SystemProxy['deviceProvider'];
384
- get deviceState(): SystemProxy['deviceState'];
385
- get metricsProvider(): SystemProxy['metricsProvider'];
386
- get notificationOutput(): SystemProxy['notificationOutput'];
387
- get pipelineExecutor(): SystemProxy['pipelineExecutor'];
388
- get pipelineOrchestrator(): SystemProxy['pipelineOrchestrator'];
389
- get pipelineRunner(): SystemProxy['pipelineRunner'];
390
- get settingsStore(): SystemProxy['settingsStore'];
391
- get storage(): SystemProxy['storage'];
392
- get streamBroker(): SystemProxy['streamBroker'];
393
- get turnProvider(): SystemProxy['turnProvider'];
394
- get userManagement(): SystemProxy['userManagement'];
395
- /**
396
- * Subscribe to a single event category. Returns an unsubscribe
397
- * handle. Errors thrown by the listener are swallowed so a single
398
- * misbehaving consumer cannot tear down the WS subscription.
399
- *
400
- * Categories should be values from the `EventCategory` enum
401
- * (`@camstack/types`) — passing a raw string works for forward-compat
402
- * but loses type safety. The SDK forwards the value verbatim to the
403
- * server's `live.onEvent` subscription.
404
- *
405
- * **N callers on one category share ONE subscription.** The caller's shape
406
- * is unchanged, and it is not asked to coordinate: twelve camera cards each
407
- * subscribing to the motion category and filtering by deviceId is the normal
408
- * pattern, and it used to open twelve WS subscriptions.
409
- */
410
- subscribeEvent<TData = unknown>(category: string, cb: SystemLiveEventListener<TData>): () => void;
411
- /**
412
- * Drop every shared subscription.
413
- *
414
- * Called when the tRPC client is rebuilt: the handles above belong to the
415
- * OLD client and unsubscribing them is meaningless, while keeping them in
416
- * the map would make the next `subscribeEvent` attach a listener to a dead
417
- * socket and go silent — the worst outcome, because it looks like "no
418
- * events happened".
419
- */
420
- private resetEventSubs;
421
- /** Direct tRPC client. Read once per call; rebuilt on `reconnect()`. */
422
- get trpcClient(): TrpcClient;
423
- /**
424
- * Underlying WSClient (or `null` for HTTP transport). Used by
425
- * advanced consumers that need direct access to the WebSocket
426
- * (e.g. for keep-alive metrics). Rebuilt on `reconnect()`.
427
- */
428
- get wsClient(): TrpcWsClient | null;
429
- private buildTrpcClient;
99
+ /** Active server base URL. Mutable via `switchServerUrl()` so the
100
+ * endpoint-race helper can pivot the transport onto a LAN candidate
101
+ * after the initial public-URL bootstrap. */
102
+ private _serverUrl;
103
+ private readonly useWs;
104
+ private readonly baseRetryMs;
105
+ private readonly maxRetryMs;
106
+ private readonly onConnectionChange;
107
+ private token;
108
+ private _trpcClient;
109
+ private _wsClient;
110
+ /**
111
+ * One underlying `live.onEvent` subscription per DISTINCT category, fanned
112
+ * out to every listener.
113
+ *
114
+ * Measured on the viewer's boot: ~40 subscriptions, of which 24 were twelve
115
+ * cameras subscribing to the SAME two motion categories and filtering by
116
+ * deviceId in their own callbacks. Each was its own WS handshake on a
117
+ * transport that does not batch. Deduping here needs no protocol change and
118
+ * no cooperation from callers — `subscribeEvent` keeps its exact shape.
119
+ */
120
+ private readonly _eventSubs;
121
+ private mirror;
122
+ private mirrorInit;
123
+ private connected;
124
+ private connectedPromise;
125
+ private _systemProxy;
126
+ private _connectionVersion;
127
+ /**
128
+ * Bumped on every `reconnect()` BEFORE the previous socket is closed.
129
+ * WS `onOpen`/`onClose` close over the epoch at `createWSClient` time
130
+ * so a delayed close from a torn-down socket cannot mark the replacement
131
+ * as disconnected (the LAN-switch shape: health 3/3, APIs then "offline").
132
+ */
133
+ private _wsEpoch;
134
+ private readonly connectionListeners;
135
+ constructor(config: SystemConfig);
136
+ /** Active server base URL (no trailing slash). */
137
+ get serverUrl(): string;
138
+ get connectionVersion(): number;
139
+ /**
140
+ * Subscribe to connection-state transitions. Called with `('connected'
141
+ * | 'disconnected' | 'connecting', version)` whenever the SDK opens,
142
+ * closes, or starts a manual reconnect. Listener errors are swallowed
143
+ * so a misbehaving consumer cannot break the SDK's event loop.
144
+ */
145
+ subscribeConnectionEvents(cb: ConnectionListener): () => void;
146
+ private emitConnectionEvent;
147
+ /**
148
+ * Wait until the underlying tRPC transport is connected AND the
149
+ * server has responded to a cheap auth round-trip (`auth.me`). This
150
+ * is the canonical "ready to issue queries" gate.
151
+ *
152
+ * Why a probe, not just `ws.readyState === OPEN`?
153
+ * The WS handshake completes asynchronously: tRPC's `wsLink`
154
+ * queues outgoing messages and only flushes them after `open()`
155
+ * resolves (post `connectionParams` send). On the server, the
156
+ * tRPC context is created lazily once the connectionParams
157
+ * message is received. A query fired between WS-open and
158
+ * connection-params-processed is technically queued by tRPC, but
159
+ * the auth context for that query is only resolved once the
160
+ * handshake message is decoded server-side. A probe round-trip is
161
+ * the safest way to confirm both sides have agreed on the auth
162
+ * identity before the React tree starts firing parallel queries
163
+ * (which can otherwise land before any addon-side service
164
+ * discovery has settled, returning empty results that get cached).
165
+ *
166
+ * Idempotent — concurrent callers await the same in-flight Promise.
167
+ * Bounded by `timeoutMs` (default 15s) — beyond which a
168
+ * `Error('System.awaitConnected: probe timed out after Xms')` is
169
+ * thrown so the host can render a clear error state instead of
170
+ * hanging on a bricked socket.
171
+ */
172
+ awaitConnected(timeoutMs?: number): Promise<void>;
173
+ /**
174
+ * Warm-boot the device mirror. Awaits the transport probe first
175
+ * (`awaitConnected`) so the three mirror round-trips
176
+ * (`getAllBindings` + `getAllSnapshots` + `listAll`) cannot race
177
+ * against the WS auth handshake. Subsequent `getDevice(id)` calls
178
+ * are sync; live `device.*` event subscriptions keep the caches
179
+ * fresh.
180
+ *
181
+ * Idempotent — concurrent callers await the same in-flight Promise.
182
+ */
183
+ init(timeoutMs?: number): Promise<void>;
184
+ /** Promise that resolves once `init()` has completed. */
185
+ awaitReady(): Promise<void>;
186
+ /** True after `init()` resolves. */
187
+ isReady(): boolean;
188
+ /** True after the transport probe has succeeded at least once. */
189
+ isConnected(): boolean;
190
+ /**
191
+ * Force a fresh WebSocket handshake. Tears down the wsClient + tRPC
192
+ * client + mirror (the mirror captures the tRPC reference at
193
+ * construction time and would otherwise dispatch through a closed
194
+ * client) and rebuilds them. No-op for HTTP transport.
195
+ */
196
+ reconnect(): void;
197
+ /**
198
+ * Pivot the underlying tRPC transport onto a different base URL.
199
+ * Used by the endpoint-race flow: the SDK opens against the public
200
+ * URL the operator provided, calls `localNetwork.getConnectionEndpoints`
201
+ * to discover LAN candidates, races them, and (when a faster one wins)
202
+ * calls `switchServerUrl(winner)` to migrate every subsequent query
203
+ * onto the LAN path without losing auth state.
204
+ *
205
+ * Keeps the auth token. Tears down the WS + mirror and rebuilds them
206
+ * against the new URL — same machinery as `reconnect()` but with a
207
+ * different target.
208
+ */
209
+ switchServerUrl(nextUrl: string): void;
210
+ /**
211
+ * Race the candidate base URLs reported by the hub's `local-network`
212
+ * cap, pick the fastest one that responds, and (if it's different
213
+ * from the current URL) pivot the transport onto it.
214
+ *
215
+ * Flow:
216
+ * 1. Query `localNetwork.getConnectionEndpoints({ port })` over the
217
+ * already-authenticated tRPC channel — the cap is auth-gated,
218
+ * so the LAN IPs never leak to anonymous callers.
219
+ * 2. For each candidate, fire a GET on `{baseUrl}/health` (the
220
+ * auth-free liveness route) with a short timeout (default 1500ms).
221
+ * The first 2xx wins.
222
+ * 3. If the winner differs from `this.serverUrl`, call
223
+ * `switchServerUrl(winner)` and return it. Otherwise return
224
+ * the current URL unchanged.
225
+ *
226
+ * Bounded — if every candidate times out we keep the current URL.
227
+ * Idempotent — safe to call on every connect / reconnect / network
228
+ * change event.
229
+ */
230
+ raceConnectionEndpoints(options?: {
231
+ /** Per-candidate probe timeout. Default 1500ms. */
232
+ readonly perCandidateTimeoutMs?: number;
233
+ /** Skip IPv6 candidates. Default `false`. */
234
+ readonly ipv4Only?: boolean;
235
+ }): Promise<{
236
+ winner: string;
237
+ switched: boolean;
238
+ }>;
239
+ /** Tear down WS connection + mirror. The instance is unusable afterwards. */
240
+ close(): void;
241
+ private disposeMirror;
242
+ login(username: string, password: string, options?: LoginOptions): Promise<LoginOutcome>;
243
+ /**
244
+ * Second leg of the 2FA login. The caller passes the challenge
245
+ * token returned by `login` (when `requiresTotp: true`) plus the
246
+ * 6-digit code from the user's authenticator app. On success the
247
+ * server mints the real session JWT, which we set as the active
248
+ * token on this client.
249
+ */
250
+ loginVerifyTotp(challengeToken: string, code: string, options?: LoginOptions): Promise<LoginOutcome>;
251
+ /**
252
+ * Passkey second leg — step 1. Exchanges the login challenge token for
253
+ * the WebAuthn assertion options; the caller feeds `optionsJSON` to
254
+ * `@simplewebauthn/browser` `startAuthentication`. PUBLIC (pre-session).
255
+ */
256
+ loginBeginPasskey(challengeToken: string): Promise<{
257
+ optionsJSON: Record<string, unknown>;
258
+ }>;
259
+ /**
260
+ * Passkey second leg — step 2. Submits the browser assertion; on
261
+ * success the server mints the real session JWT, which we set as the
262
+ * active token on this client.
263
+ */
264
+ loginVerifyPasskey(challengeToken: string, response: Record<string, unknown>, options?: LoginOptions): Promise<LoginOutcome>;
265
+ /**
266
+ * Usernameless (passkey-FIRST) login — step 1. Fetches discoverable-
267
+ * credential assertion options (EMPTY `allowCredentials`); the caller
268
+ * feeds `optionsJSON` to `@simplewebauthn/browser` `startAuthentication`
269
+ * and the browser's own passkey picker takes over. PUBLIC (pre-session,
270
+ * no challenge token — the WebAuthn challenge is the whole state).
271
+ */
272
+ passkeyLoginBegin(): Promise<{
273
+ optionsJSON: Record<string, unknown>;
274
+ }>;
275
+ /**
276
+ * Usernameless login — step 2. Submits the browser assertion; on
277
+ * success the server resolves the credential's owner and mints the
278
+ * real session JWT, which we set as the active token on this client.
279
+ *
280
+ * Second-factor aware (mirrors `login`): when the account still has a
281
+ * second factor to satisfy (TOTP — 2FA applies uniformly after any
282
+ * primary method), the server returns a short-lived CHALLENGE token +
283
+ * `secondFactors`. That token is NOT persisted as the active session
284
+ * — the caller completes the remaining factor via `loginVerifyTotp`.
285
+ */
286
+ passkeyLoginFinish(response: Record<string, unknown>, options?: LoginOptions): Promise<LoginOutcome>;
287
+ /**
288
+ * Swap a refresh token for a new access token and a ROTATED refresh token
289
+ * (the old one stops working). Sets the new access token on this client.
290
+ * Throws a tRPC UNAUTHORIZED when the session is over — sign in again.
291
+ */
292
+ refreshSession(refreshToken: string): Promise<RefreshedSession>;
293
+ /**
294
+ * End this session on the hub (its tokens stop working at once);
295
+ * `everywhere: true` ends every session of the user.
296
+ */
297
+ logout(options?: {
298
+ readonly everywhere?: boolean;
299
+ }): Promise<void>;
300
+ getMe(): Promise<UserInfo>;
301
+ changeOwnPassword(input: {
302
+ currentPassword: string;
303
+ newPassword: string;
304
+ }): Promise<{
305
+ success: true;
306
+ }>;
307
+ setupOwnTotp(): Promise<{
308
+ secret: string;
309
+ otpauthUrl: string;
310
+ }>;
311
+ confirmOwnTotp(input: {
312
+ code: string;
313
+ }): Promise<{
314
+ success: true;
315
+ }>;
316
+ /** Needs a fresh `reauthToken` from {@link reauthenticate}. */
317
+ disableOwnTotp(input: {
318
+ reauthToken: string;
319
+ }): Promise<{
320
+ success: true;
321
+ }>;
322
+ /**
323
+ * Re-authentication, passkey path step 1: assertion options for the
324
+ * caller's own passkeys. Feed `optionsJSON` to `startAuthentication`, then
325
+ * pass the result to {@link reauthenticate} as `passkeyResponse`.
326
+ */
327
+ reauthBeginPasskey(): Promise<{
328
+ optionsJSON: Record<string, unknown>;
329
+ }>;
330
+ /**
331
+ * Prove it is you — the current password or a passkey assertion — for a
332
+ * 5-minute `reauthToken`, required by `disableOwnTotp`, `removeOwnPasskey`
333
+ * and `auth.beginExternalLink` (valid only in this session).
334
+ */
335
+ reauthenticate(input: {
336
+ readonly password: string;
337
+ } | {
338
+ readonly passkeyResponse: Record<string, unknown>;
339
+ }): Promise<{
340
+ reauthToken: string;
341
+ expiresAt: number;
342
+ method: "password" | "passkey";
343
+ }>;
344
+ getOwnTotpStatus(): Promise<{
345
+ enabled: boolean;
346
+ confirmedAt: number | null;
347
+ }>;
348
+ beginOwnPasskeyRegistration(): Promise<{
349
+ optionsJSON: Record<string, unknown>;
350
+ }>;
351
+ finishOwnPasskeyRegistration(input: {
352
+ response: Record<string, unknown>;
353
+ label: string;
354
+ }): Promise<{
355
+ success: true;
356
+ credentialId: string;
357
+ }>;
358
+ listOwnPasskeys(): Promise<readonly PasskeySummary[]>;
359
+ /** Needs a fresh `reauthToken` from {@link reauthenticate}. */
360
+ removeOwnPasskey(input: {
361
+ credentialId: string;
362
+ reauthToken: string;
363
+ }): Promise<{
364
+ success: true;
365
+ }>;
366
+ /**
367
+ * Own passkey second-factor preference (opt-in, default OFF).
368
+ * Enrolling a passkey only enables passkey-first sign-in; this flag
369
+ * additionally demands the passkey after every password login.
370
+ */
371
+ getOwnPasskeySecondFactorPreference(): Promise<{
372
+ enabled: boolean;
373
+ }>;
374
+ setOwnPasskeySecondFactorPreference(input: {
375
+ enabled: boolean;
376
+ }): Promise<{
377
+ success: true;
378
+ }>;
379
+ /** Update the auth token (e.g. after login or token refresh). */
380
+ setToken(token: string): void;
381
+ /**
382
+ * Synchronous snapshot of every device matching the optional filters.
383
+ * Backed by the `SystemMirror` warm-boot cache — call `init()` first
384
+ * (or `awaitReady()`) before invoking. Returns an empty array if the
385
+ * mirror has not yet been booted.
386
+ *
387
+ * Each returned proxy has `binding` populated from the mirror's
388
+ * binding cache (Phase 5 dedup), so consumers no longer need to
389
+ * make a separate `deviceManager.getBindings` round-trip.
390
+ */
391
+ listDevices(filters?: DeviceQueryFilters): readonly DeviceProxy[];
392
+ /**
393
+ * Sync `DeviceInfo` snapshot (name, canonical type, online, …) for one device
394
+ * from the warm-boot mirror — `null` if the mirror isn't booted or the device
395
+ * is unknown. Unlike a `DeviceProxy` (cap accessors only), this carries the
396
+ * display identity the UI needs to render a device on first paint.
397
+ */
398
+ getDeviceInfo(deviceId: number): DeviceInfo | null;
399
+ /**
400
+ * The `DeviceInfo` snapshots for the current device set (the warm-boot cache),
401
+ * honouring the same `filters` as {@link listDevices}. Lets a UI render a named
402
+ * device list immediately without waiting for per-device lifecycle events.
403
+ */
404
+ listDeviceInfos(filters?: DeviceQueryFilters): readonly DeviceInfo[];
405
+ /**
406
+ * Sync lookup by numeric id. `null` if the mirror has not been booted
407
+ * or the device is unknown.
408
+ */
409
+ getDevice(deviceId: number): DeviceProxy | null;
410
+ /** Sync lookup by display name (exact match). */
411
+ getDeviceByName(name: string): DeviceProxy | null;
412
+ /** Sync lookup by stableId. */
413
+ getDeviceByStableId(stableId: string): DeviceProxy | null;
414
+ /**
415
+ * Resolve when a device with `deviceId` becomes available. Resolves
416
+ * immediately if already known; rejects with a timeout error
417
+ * otherwise (default 30s).
418
+ */
419
+ waitForDevice(deviceId: number, timeoutMs?: number): Promise<DeviceProxy>;
420
+ /** Subscribe to `device.registered` events. */
421
+ onDeviceAdded(cb: DeviceLifecycleListener): () => void;
422
+ /** Subscribe to `device.unregistered` events. */
423
+ onDeviceRemoved(cb: DeviceLifecycleListener): () => void;
424
+ /**
425
+ * Patch the proxy's `binding` field from the mirror's cache. The
426
+ * generated `createDeviceProxy()` already sets `binding` to the
427
+ * binding it was constructed with — this is a defensive overwrite
428
+ * that uses the latest cached entry in case a `binding-changed`
429
+ * event landed between proxy creation and access.
430
+ */
431
+ private attachBinding;
432
+ /** Fetch the latest cached binding from the mirror, or `null`. */
433
+ private lookupBinding;
434
+ get addonPages(): SystemProxy["addonPages"];
435
+ get addonSettings(): SystemProxy["addonSettings"];
436
+ get alerts(): SystemProxy["alerts"];
437
+ get audioAnalyzer(): SystemProxy["audioAnalyzer"];
438
+ get audioCodec(): SystemProxy["audioCodec"];
439
+ get backup(): SystemProxy["backup"];
440
+ get decoder(): SystemProxy["decoder"];
441
+ get deviceManager(): SystemProxy["deviceManager"];
442
+ get deviceProvider(): SystemProxy["deviceProvider"];
443
+ get deviceState(): SystemProxy["deviceState"];
444
+ get metricsProvider(): SystemProxy["metricsProvider"];
445
+ get notificationOutput(): SystemProxy["notificationOutput"];
446
+ get pipelineExecutor(): SystemProxy["pipelineExecutor"];
447
+ get pipelineOrchestrator(): SystemProxy["pipelineOrchestrator"];
448
+ get pipelineRunner(): SystemProxy["pipelineRunner"];
449
+ get settingsStore(): SystemProxy["settingsStore"];
450
+ get storage(): SystemProxy["storage"];
451
+ get streamBroker(): SystemProxy["streamBroker"];
452
+ get turnProvider(): SystemProxy["turnProvider"];
453
+ get userManagement(): SystemProxy["userManagement"];
454
+ /**
455
+ * Subscribe to a single event category. Returns an unsubscribe
456
+ * handle. Errors thrown by the listener are swallowed so a single
457
+ * misbehaving consumer cannot tear down the WS subscription.
458
+ *
459
+ * Categories should be values from the `EventCategory` enum
460
+ * (`@camstack/types`) — passing a raw string works for forward-compat
461
+ * but loses type safety. The SDK forwards the value verbatim to the
462
+ * server's `live.onEvent` subscription.
463
+ *
464
+ * **N callers on one category share ONE subscription.** The caller's shape
465
+ * is unchanged, and it is not asked to coordinate: twelve camera cards each
466
+ * subscribing to the motion category and filtering by deviceId is the normal
467
+ * pattern, and it used to open twelve WS subscriptions.
468
+ */
469
+ subscribeEvent<TData = unknown>(category: string, cb: SystemLiveEventListener<TData>): () => void;
470
+ /**
471
+ * Drop every shared subscription.
472
+ *
473
+ * Called when the tRPC client is rebuilt: the handles above belong to the
474
+ * OLD client and unsubscribing them is meaningless, while keeping them in
475
+ * the map would make the next `subscribeEvent` attach a listener to a dead
476
+ * socket and go silent — the worst outcome, because it looks like "no
477
+ * events happened".
478
+ */
479
+ private resetEventSubs;
480
+ /** Direct tRPC client. Read once per call; rebuilt on `reconnect()`. */
481
+ get trpcClient(): TrpcClient;
482
+ /**
483
+ * Underlying WSClient (or `null` for HTTP transport). Used by
484
+ * advanced consumers that need direct access to the WebSocket
485
+ * (e.g. for keep-alive metrics). Rebuilt on `reconnect()`.
486
+ */
487
+ get wsClient(): TrpcWsClient | null;
488
+ private buildTrpcClient;
430
489
  }
431
490
  /** Create a `System` instance. Convenience factory. */
432
491
  export declare function createSystem(config: SystemConfig): System;
433
492
  /**
434
- * Race a list of candidate base URLs and return the first one that
435
- * responds to `GET {baseUrl}/health` with a 2xx within
436
- * `timeoutMs`. Returns `null` if every candidate fails / times out.
437
- *
438
- * Implementation notes:
439
- * - Uses `fetch` with `AbortController` for per-candidate cutoff so a
440
- * stalled candidate doesn't pin the wallclock for the whole race.
441
- * - Cancels every still-pending probe as soon as the first one
442
- * succeeds — no wasted bandwidth on the loser candidates.
443
- * - `/health` is the hub's auth-free liveness route (`{"ok":true}`),
444
- * the one load balancers and uptime probes already watch. NOT
445
- * `/trpc/health`: under `/trpc/*` the tRPC plugin resolves `health` as
446
- * a procedure name and answers 404 on a HEALTHY hub (the fastify route
447
- * of that name exists only on the degraded branch taken when tRPC fails
448
- * to register), so every race over it rejected and fell back.
449
- * - HEAD would be nicer but Cloudflare Tunnel + some browsers
450
- * mishandle HEAD on the ingress path; GET is safer.
451
- *
452
- * Exported so non-System callers (CLI helpers, tests) can race their
453
- * own candidate lists without instantiating a full System.
454
- */
493
+ * Race a list of candidate base URLs and return the first one that
494
+ * responds to `GET {baseUrl}/health` with a 2xx within
495
+ * `timeoutMs`. Returns `null` if every candidate fails / times out.
496
+ *
497
+ * Implementation notes:
498
+ * - Uses `fetch` with `AbortController` for per-candidate cutoff so a
499
+ * stalled candidate doesn't pin the wallclock for the whole race.
500
+ * - Cancels every still-pending probe as soon as the first one
501
+ * succeeds — no wasted bandwidth on the loser candidates.
502
+ * - `/health` is the hub's auth-free liveness route (`{"ok":true}`),
503
+ * the one load balancers and uptime probes already watch. NOT
504
+ * `/trpc/health`: under `/trpc/*` the tRPC plugin resolves `health` as
505
+ * a procedure name and answers 404 on a HEALTHY hub (the fastify route
506
+ * of that name exists only on the degraded branch taken when tRPC fails
507
+ * to register), so every race over it rejected and fell back.
508
+ * - HEAD would be nicer but Cloudflare Tunnel + some browsers
509
+ * mishandle HEAD on the ingress path; GET is safer.
510
+ *
511
+ * Exported so non-System callers (CLI helpers, tests) can race their
512
+ * own candidate lists without instantiating a full System.
513
+ */
455
514
  export declare function raceFastestEndpoint(candidates: ReadonlyArray<string>, timeoutMs: number): Promise<string | null>;
456
515
  export {};