Files
alarm-client/src/state/useAlarmSocket.ts
T
Admin a29d9b775e [feat](client): address one instance origin instead of connection modes
Apps reach every service through Maestro on one port; direct service addressing is no longer a supported deployment, and Maestro has no per-service hostname or host-port route. Replace the maestro/direct modes and the serviceRef lookup with one configured origin and the fixed paths /api/alarm/{action,auth,ws}, defaulting dev builds to 127.0.0.1:8080 through adb reverse. The old mode-specific body wrapping and the direct-mode socket branch go with it.
2026-10-19 02:30:51 +01:00

170 lines
6.0 KiB
TypeScript

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppState, type AppStateStatus } from 'react-native';
import {
AlarmSocket,
DEFAULT_RECONNECT_POLICY,
type AlarmFrame,
type SocketState,
} from '../services/alarmSocket';
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
import { alarmStatus as fetchAlarmStatus, isAlertGone, type AlarmStatus } from '../services/alarmApi';
export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
export interface UseAlarmSocketOptions {
resolved: ResolvedConnection | null;
alertId: string | null;
subscribeToken: string | null;
/** Poll cadence while the app is foregrounded (spec: 5s). */
foregroundPollMs?: number;
/** Poll cadence while backgrounded (spec: 30s). */
backgroundPollMs?: number;
onTrigger: (frame: AlarmFrame) => void;
onDisarm: (frame: AlarmFrame) => void;
enabled?: boolean;
/** This device's ledger id (the service hashes it to address the debt). */
deviceId?: string | null;
/** The service answered 404: the alert is destroyed, there is nothing to ring. */
onGone?: () => void;
}
export interface UseAlarmSocketResult {
socketState: SocketState;
delivery: LiveDelivery;
attempts: number;
lastStatus: AlarmStatus | null;
/** Force an immediate status read (used by pull-to-refresh and Settings). */
refresh: () => Promise<void>;
}
/**
* Owns the live-alarm transport for one subscribed alert.
*
* The WebSocket is the delivery path: triggers arrive only over
* `ws(s)://{instance}/api/alarm/ws/alarm`. `delivery: 'unavailable'` means no
* instance is configured, and the UI must say this device is not listening
* rather than implying coverage. The status poll below is a display
* reconciliation only — it never becomes a delivery claim.
*/
export function useAlarmSocket({
resolved,
alertId,
subscribeToken,
foregroundPollMs = 5000,
backgroundPollMs = 30000,
onTrigger,
onDisarm,
enabled = true,
deviceId,
onGone,
}: UseAlarmSocketOptions): UseAlarmSocketResult {
const [socketState, setSocketState] = useState<SocketState>('idle');
const [attempts, setAttempts] = useState(0);
const [lastStatus, setLastStatus] = useState<AlarmStatus | null>(null);
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState);
const socketRef = useRef<AlarmSocket | null>(null);
const triggerRef = useRef(onTrigger);
const disarmRef = useRef(onDisarm);
triggerRef.current = onTrigger;
disarmRef.current = onDisarm;
const url = useMemo(
() => (resolved && alertId && subscribeToken ? alarmSocketUrl(resolved, alertId, subscribeToken) : null),
[resolved, alertId, subscribeToken],
);
/** Capability, not liveness: the URL exists. Liveness is `socketState`. */
const delivery: LiveDelivery = url ? 'socket' : 'unavailable';
/**
* A check-in can carry the durable "fired while you were offline" record
* (409 `ERR_ALARM_TRIGGERED_OFFLINE`, mapped to a success-shaped status by
* `alarmStatus`). Ack it over the socket when one is open, then let the
* caller's status effect raise the takeover; a closed socket is fine because
* the service clears the pending entry on the check-in response itself.
*/
const onGoneRef = useRef<UseAlarmSocketOptions['onGone']>(undefined);
onGoneRef.current = onGone;
const applyStatus = useCallback((status: AlarmStatus) => {
setLastStatus(status);
if (status.offlineTrigger && status.eventId) {
socketRef.current?.ack(status.eventId);
}
}, []);
const refresh = useCallback(async () => {
if (!alertId) return;
try {
applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined));
} catch (e) {
if (isAlertGone(e)) onGoneRef.current?.();
// A failed status read is not fatal — the socket is the primary path and
// the next poll retries.
}
}, [alertId, applyStatus, deviceId]);
// AppState awareness: while backgrounded the JS socket is unreliable, so the
// poll cadence relaxes to the background value and the socket is left to the
// native foreground service (which holds its own connection).
useEffect(() => {
const sub = AppState.addEventListener('change', next => setAppState(next));
return () => sub.remove();
}, []);
useEffect(() => {
if (!enabled || !url || !alertId || !subscribeToken) {
setSocketState('idle');
setAttempts(0);
return;
}
const socket = new AlarmSocket({
url,
policy: DEFAULT_RECONNECT_POLICY,
onStateChange: (state, nextAttempts) => {
setSocketState(state);
setAttempts(nextAttempts);
},
onAlarm: frame => {
if (frame.action === 'trigger') triggerRef.current(frame);
else disarmRef.current(frame);
},
});
socketRef.current = socket;
socket.connect();
return () => {
socket.close();
socketRef.current = null;
};
}, [enabled, url, alertId, subscribeToken]);
// Status reconciliation. It never *delivers* a live trigger (the socket is the
// alarm path), but it is also the check-in that carries the durable record of
// an alarm fired while this device was offline — so a missed trigger comes
// back as a late takeover instead of disappearing.
useEffect(() => {
if (!enabled || !alertId) return;
const interval = appState === 'active' ? foregroundPollMs : backgroundPollMs;
let cancelled = false;
const tick = async () => {
if (cancelled) return;
try {
const status = await fetchAlarmStatus(alertId, deviceId ?? undefined);
if (!cancelled) applyStatus(status);
} catch (e) {
if (isAlertGone(e)) onGoneRef.current?.();
// ignore otherwise — polling must never surface a spinner
}
};
tick();
const handle = setInterval(tick, interval);
return () => {
cancelled = true;
clearInterval(handle);
};
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs, applyStatus, deviceId]);
return { socketState, delivery, attempts, lastStatus, refresh };
}