[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.
This commit is contained in:
2026-10-19 02:30:51 +01:00
parent 2df34ab938
commit a29d9b775e
9 changed files with 398 additions and 296 deletions
+18 -3
View File
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { KeyboardAvoidingView, Platform, View } from 'react-native';
import { useAuth } from '../state/AuthContext';
import { useConnection } from '../state/ConnectionContext';
import { navigationRef } from '../navigationRef';
import {
AppText,
Button,
@@ -17,7 +18,7 @@ type Mode = 'signin' | 'register';
export default function LoginScreen() {
const { login, register } = useAuth();
const { online, resolved } = useConnection();
const { online, resolved, settings } = useConnection();
const [mode, setMode] = useState<Mode>('signin');
const [username, setUsername] = useState('');
@@ -134,11 +135,25 @@ export default function LoginScreen() {
}}
/>
{/* A release build starts with no instance configured, and sign-in
is impossible until one is set — Settings also owns that field,
so the login screen must be able to reach it. Without this the
app is unconfigurable on a fresh install (Settings was only
reachable from Home, which needs a session). */}
<Button
label={settings.instanceUrl ? `Server · ${settings.instanceUrl}` : 'Choose your server'}
variant="ghost"
height={44}
onPress={() => {
if (navigationRef.isReady()) navigationRef.navigate('Settings');
}}
/>
{!online ? (
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
{resolved
? 'The alarm service did not answer. Check Settings → Connection.'
: 'Looking for the alarm service…'}
? 'Your instance did not answer. Check Settings → Connection.'
: 'Looking for your instance…'}
</AppText>
) : null}
</View>
+10 -6
View File
@@ -1,15 +1,22 @@
/**
* Alarm WebSocket client.
*
* URL (frozen contract, direct mode only — Maestro has no service WS
* passthrough): `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
* URL (single origin, frozen contract):
* `ws(s)://{instance}/api/alarm/ws/alarm?alertId=<id>&token=<subscribeToken>`
*
* Frames:
* server → client {"type":"alarm","alertId","eventId","action":"trigger"|"disarm",
* "message","serverTime","autoStopSeconds"}
* "message","serverTime"}
* client → server {"type":"ack","eventId"}
* client → server {"type":"ping"}
*
* Delivery is durable, not just live: on every successful upgrade the service
* replays that caller's unacked trigger frames down the socket (so the native
* foreground service rings with JS dead), and a replayed frame is handled
* exactly like a live one — the ack frame above is what retires it in the ledger.
* `alarmStatus`'s 409 check-in covers the cases a replay cannot reach (socket
* down, app reopened cold).
*
* The reconnect policy and the frame parser are pure and exported separately so
* they can be tested headlessly (no React Native, no real socket) — see
* `__tests__/alarmSocket.test.ts`.
@@ -112,7 +119,6 @@ export interface AlarmFrame {
action: 'trigger' | 'disarm';
message?: string;
serverTime?: string;
autoStopSeconds?: number;
}
export interface AckFrame {
@@ -151,8 +157,6 @@ export function parseFrame(raw: string): IncomingFrame {
action: obj.action,
message: typeof obj.message === 'string' ? obj.message : undefined,
serverTime: typeof obj.serverTime === 'string' ? obj.serverTime : undefined,
autoStopSeconds:
typeof obj.autoStopSeconds === 'number' ? obj.autoStopSeconds : undefined,
};
}
if (type === 'ack') {
+51 -43
View File
@@ -1,16 +1,22 @@
/**
* HTTP client for the Alarm service — house pattern from
* `calendar-client/src/services/apiClient.ts`, retargeted:
* HTTP client for the Alarm service — single origin.
*
* - maestro mode addresses `POST {maestro}/api/actions/{serviceRef}/{action}`
* with the `{payload: …}` envelope (Maestro's action surface is HTTP-only),
* - direct mode addresses the service's own listener and uses the `{payload: …}`
* envelope for `/action/*` while `/auth/*` take raw JSON bodies
* (`raw = true`),
* - every call carries `X-Access-Token` when a session exists,
* - a 401 (or an invalid/expired-token body) triggers ONE single-flight
* `/auth/refresh` and a single retry; a failed refresh clears the session.
* Every request goes to the instance the user configured, as a path under it:
*
* `/action/<name>` → `{instance}/api/alarm/action/<name>` (`{payload: …}`)
* `/auth/<path>` → `{instance}/api/alarm/auth/<path>` (raw JSON body)
*
* There is no `direct` mode and no `serviceRef` lookup: the service is reached
* only through the instance, which is the deployment the platform supports.
*
* Every `/action/*` call carries `X-Access-Token` when a session exists; `/auth/*`
* calls pass `raw = true` because the service's auth handlers decode the raw
* body (root AGENTS.md body contract). A 401 (or an invalid/expired-token body)
* triggers ONE single-flight `/auth/refresh` and a single retry; a failed
* refresh clears the session.
*/
import { servicePath } from './instanceUrl';
export class ApiError extends Error {
status: number | null;
/**
@@ -27,10 +33,25 @@ export class ApiError extends Error {
}
}
/**
* A 409 that is *not* a failure: the check-in reports an alarm that fired while
* this client was offline, with the frame in the same body. It is modelled as a
* distinct error type so `alarmStatus` can turn it into a success-shaped result
* before any generic error path sees it — callers must never render it as a
* toast, and the generic handler must not swallow it.
*/
export class OfflineAlarmError extends ApiError {
/** The whole parsed body, so the caller can find the frame without re-fetching. */
body: unknown;
constructor(status: number, message: string, code: string | null, body: unknown) {
super(status, message, code);
this.name = 'OfflineAlarmError';
this.body = body;
}
}
export interface ApiConfig {
baseUrl: string;
mode: 'maestro' | 'direct';
serviceRef: string | null;
token: string | null;
refreshToken: string | null;
onUnauthorized: () => void;
@@ -40,8 +61,6 @@ export interface ApiConfig {
const config: ApiConfig = {
baseUrl: '',
mode: 'direct',
serviceRef: null,
token: null,
refreshToken: null,
onUnauthorized: () => {},
@@ -56,25 +75,8 @@ export function apiConfig(): Readonly<ApiConfig> {
return config;
}
function splitQuery(path: string): [string, string] {
const i = path.indexOf('?');
return i === -1 ? [path, ''] : [path.slice(0, i), path.slice(i + 1)];
}
function buildUrl(path: string): string {
if (config.mode === 'maestro') {
if (!config.serviceRef) {
throw new ApiError(null, 'Service not found through Maestro');
}
const [pathOnly, query] = splitQuery(path);
const segments = pathOnly.split('/').filter(Boolean);
const actionName = segments[segments.length - 1];
if (!actionName) throw new ApiError(null, `Invalid path: ${path}`);
let url = `${config.baseUrl}/api/actions/${encodeURIComponent(config.serviceRef)}/${actionName}`;
if (query) url += `?${query}`;
return url;
}
return `${config.baseUrl}${path}`;
return `${config.baseUrl}${servicePath(path)}`;
}
// One in-flight refresh shared by every 401: a burst of requests that all hit
@@ -88,12 +90,9 @@ function refreshSession(): Promise<boolean> {
try {
if (!config.refreshToken) return false;
const url = buildUrl('/auth/refresh');
// Raw {refreshToken} in direct mode; {payload} action form in Maestro
// mode — the service proxies both (raw flag contract, root AGENTS.md).
const body =
config.mode === 'maestro'
? JSON.stringify({ payload: { refreshToken: config.refreshToken } })
: JSON.stringify({ refreshToken: config.refreshToken });
// `/auth/*` takes the raw body (root AGENTS.md body contract); the
// instance forwards it unchanged.
const body = JSON.stringify({ refreshToken: config.refreshToken });
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -130,13 +129,12 @@ async function perform<T>(
throw new ApiError(null, 'Server not configured yet');
}
// In Maestro mode every call goes through /api/actions/{service}/{action},
// which always expects the { payload: ... } wrapper. The raw flag only
// applies when talking to a service directly.
const maestro = config.mode === 'maestro';
// `/action/*` expects the `{ payload: … }` envelope; `/auth/*` expects the
// raw body. The instance forwards the body unchanged, so this is the service
// contract end to end.
let body: string | undefined;
if (payload !== undefined) {
body = maestro || !raw ? JSON.stringify({ payload }) : JSON.stringify(payload);
body = raw ? JSON.stringify(payload) : JSON.stringify({ payload });
}
const url = buildUrl(path);
@@ -169,6 +167,7 @@ async function perform<T>(
let message = `HTTP ${res.status}`;
let code: string | null = null;
let parsedBody: unknown = null;
try {
const t = await res.text();
if (t) {
@@ -179,6 +178,7 @@ async function perform<T>(
// that need to distinguish cases the message cannot.
try {
const parsed = JSON.parse(t) as { error?: { code?: unknown; message?: unknown } };
parsedBody = parsed;
const err = parsed?.error;
if (err && typeof err === 'object') {
if (typeof err.code === 'string') code = err.code;
@@ -189,6 +189,14 @@ async function perform<T>(
}
}
} catch {}
// Durable delivery: `alarmStatus` may answer 409 with this code to say an
// alarm fired while the client was offline. It is an alarm signal, not an
// error — surface it as its own type *before* the session/error handling
// below so no generic path can turn it into a failure.
if (res.status === 409 && code === 'ERR_ALARM_TRIGGERED_OFFLINE') {
throw new OfflineAlarmError(res.status, message, code, parsedBody);
}
// A 401, or any body reporting an invalid/expired token, means the session
// needs a refresh. This only applies when we actually hold a token — a 401
// on /auth/login just means wrong credentials and must surface the
+65 -217
View File
@@ -1,256 +1,104 @@
/**
* Connection resolution for the Alarm client.
* Connection resolution for the Alarm client — **single origin**.
*
* Mirrors `calendar-client/src/services/connection.ts` (the house pattern):
* two modes — `maestro` (the orchestrator's `/api/containers` list is probed and
* the service is addressed through `serviceRef`) and `direct` (the service's own
* `/_manifest` probe). Ports are unique per client/service so several can run at
* once. The Alarm service owns dev host port **8088**; this client's Metro runs
* on **8089** (lead ruling, 2026-10-18). They must differ: both are reached over
* `adb reverse`, which maps device `127.0.0.1:<port>` to the *dev machine's*
* `127.0.0.1:<port>`, so a shared number would make the packager and the service
* compete for one host socket. Per-client Metro ports: keepnote=8082,
* hub=8084, calendar=8086, alarm=8089.
* Product ruling: *"every packet goes through Maestro, and thus, only a single
* port needs to ever be open … Direct service access should never happen."*
* This app is not special: it is another service under one instance address.
*
* IMPORTANT (lead ruling, 2026-10-18): Maestro has NO service WebSocket
* passthrough — its only WS is `/api/ws` for its own container-status stream and
* its action surface is HTTP-only. So live delivery over the socket is a
* direct-mode capability; in maestro mode the client polls `alarmStatus`.
* So there is exactly one thing to resolve — the instance origin — and four
* paths under it:
*
* HTTP `{instance}/api/alarm/action/<action>` (actions, `{payload}` body)
* HTTP `{instance}/api/alarm/auth/<path>` (raw JSON body)
* WS `ws(s)://{instance}/api/alarm/ws/alarm?alertId=…&token=…`
*
* The old two-mode design (`maestro` via `/api/containers` + `serviceRef`, and
* `direct` via the service's own `/_manifest` and `:8088`) is deleted. Both
* existed to reach a service on its own port: the direct listener is no longer
* a supported deployment, and the proxied path is now the only path. The
* instance address is a *hosting* concept, not a per-service hostname — an
* instance exposes one port and every service is a path under it.
*
* The WebSocket is the delivery path. There is no polling mode that claims to
* deliver an alarm; a status poll may exist for display reconciliation only.
*/
import AsyncStorage from '@react-native-async-storage/async-storage';
import { normalizeInstanceUrl } from './instanceUrl';
export type ConnectionMode = 'maestro' | 'direct';
export { alarmSocketUrl, normalizeInstanceUrl, servicePath } from './instanceUrl';
export interface ConnectionSettings {
mode: ConnectionMode;
host: string;
port: string;
useAdbReverse: boolean;
}
export const SERVICE_NAME = 'alarm';
export { SERVICE_NAME } from './instanceUrl';
export const isRelease = !__DEV__;
/** Dev machine LAN address (fallback when adb reverse is off). */
const LAN_HOST = '192.168.1.232';
/**
* Dev default: Maestro on the dev machine. On a USB phone this is reached
* through `adb reverse tcp:8080 tcp:8080` (see `scripts/android.sh`); it is a
* development convenience, not a second connection mode.
*/
export const DEV_INSTANCE_URL = 'http://127.0.0.1:8080';
export const DEFAULTS: Record<ConnectionMode, { host: string; port: string }> = {
maestro: { host: LAN_HOST, port: '8080' },
direct: { host: LAN_HOST, port: '8088' },
};
export interface ConnectionSettings {
/** The instance origin, e.g. `https://dcntrlized.example` or `http://127.0.0.1:8080`. */
instanceUrl: string;
}
export const DEFAULT_SETTINGS: ConnectionSettings = {
mode: 'maestro',
host: '',
port: '',
useAdbReverse: true,
instanceUrl: isRelease ? '' : DEV_INSTANCE_URL,
};
const SETTINGS_KEY = 'settings.connection';
const LAST_HOST_KEY = 'settings.lastHost';
export function defaultPort(mode: ConnectionMode): string {
return DEFAULTS[mode].port;
}
function releaseBaseUrl(): string {
return `https://${SERVICE_NAME}.dcntrlized.org`;
}
const SETTINGS_KEY = 'settings.connection.v2';
export async function loadSettings(): Promise<ConnectionSettings> {
if (isRelease) {
return { ...DEFAULT_SETTINGS, mode: 'direct', useAdbReverse: false };
}
try {
const raw = await AsyncStorage.getItem(SETTINGS_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<ConnectionSettings>) };
const parsed = JSON.parse(raw) as Partial<ConnectionSettings>;
const normalized = typeof parsed.instanceUrl === 'string'
? normalizeInstanceUrl(parsed.instanceUrl)
: null;
return normalized ? { instanceUrl: normalized } : { ...DEFAULT_SETTINGS };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
export async function saveSettings(settings: ConnectionSettings): Promise<void> {
if (isRelease) return;
try {
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {}
}
function candidateHosts(settings: ConnectionSettings, lastHost: string | null): string[] {
const explicit = settings.host.trim();
if (explicit) return [explicit];
const hosts: string[] = [];
if (lastHost) hosts.push(lastHost);
hosts.push(settings.useAdbReverse ? '127.0.0.1' : DEFAULTS[settings.mode].host);
if (settings.useAdbReverse) hosts.push(LAN_HOST);
return [...new Set(hosts)];
}
async function reachable(baseUrl: string, timeoutMs: number): Promise<boolean> {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
const res = await fetch(`${baseUrl}/_manifest`, { signal: ctrl.signal });
clearTimeout(timer);
return res.ok;
} catch {
return false;
}
}
interface MaestroContainer {
id: string;
name: string;
service_name?: string;
docker_name?: string;
status?: string;
}
function findServiceRef(list: MaestroContainer[]): string | null {
const needle = SERVICE_NAME.toLowerCase();
const candidates = list.filter(c => {
const svc = (c.service_name || '').toLowerCase();
const docker = (c.docker_name || '').toLowerCase();
return svc === needle || docker === needle;
});
const match = candidates.find(c => c.status === 'running') || candidates[0];
if (!match) return null;
return match.service_name || match.docker_name || match.name || match.id;
}
// Maestro does not serve /_manifest; its reachability check and the service
// container lookup both come from the /api/containers endpoint.
async function probeMaestro(
baseUrl: string,
timeoutMs: number,
): Promise<{ reachable: boolean; serviceRef: string | null }> {
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
const res = await fetch(`${baseUrl}/api/containers`, { signal: ctrl.signal });
clearTimeout(timer);
if (!res.ok) return { reachable: false, serviceRef: null };
const list = (await res.json()) as MaestroContainer[];
return { reachable: true, serviceRef: findServiceRef(list) };
} catch (e) {
console.log('probeMaestro fetch failed', baseUrl, (e as Error).name, (e as Error).message);
return { reachable: false, serviceRef: null };
}
}
export interface ResolvedConnection {
/** Normalized origin, no trailing slash. Empty when not configured. */
baseUrl: string;
host: string;
port: string;
adbReverse: string | null;
mode: ConnectionMode;
serviceRef: string | null;
/** True when a live WebSocket is possible (direct mode only — see header). */
socketSupported: boolean;
}
export async function probe(
settings: ConnectionSettings,
timeoutMs = 2500,
): Promise<ResolvedConnection> {
if (isRelease) {
const host = `${SERVICE_NAME}.dcntrlized.org`;
return {
baseUrl: releaseBaseUrl(),
host,
port: '443',
adbReverse: null,
mode: 'direct',
serviceRef: null,
socketSupported: true,
};
}
const port = settings.port.trim() || defaultPort(settings.mode);
let lastHost: string | null = null;
try {
lastHost = await AsyncStorage.getItem(LAST_HOST_KEY);
} catch {}
const hosts = candidateHosts(settings, lastHost);
for (const host of hosts) {
const baseUrl = `http://${host}:${port}`;
if (settings.mode === 'maestro') {
const { reachable: okReach, serviceRef } = await probeMaestro(baseUrl, timeoutMs);
if (okReach) {
try {
await AsyncStorage.setItem(LAST_HOST_KEY, host);
} catch {}
return {
baseUrl,
host,
port,
adbReverse: adbReverseCommand(settings, port, host),
mode: settings.mode,
serviceRef,
socketSupported: false,
};
}
} else if (await reachable(baseUrl, timeoutMs)) {
try {
await AsyncStorage.setItem(LAST_HOST_KEY, host);
} catch {}
return {
baseUrl,
host,
port,
adbReverse: adbReverseCommand(settings, port, host),
mode: settings.mode,
serviceRef: null,
socketSupported: true,
};
}
}
const host = hosts[0];
return {
baseUrl: `http://${host}:${port}`,
host,
port,
adbReverse: adbReverseCommand(settings, port, host),
mode: settings.mode,
serviceRef: null,
socketSupported: settings.mode === 'direct',
};
}
export function adbReverseCommand(
settings: ConnectionSettings,
port?: string,
host?: string,
): string | null {
if (isRelease) return null;
const p = port || settings.port.trim() || defaultPort(settings.mode);
const h =
host ||
settings.host.trim() ||
(settings.useAdbReverse ? '127.0.0.1' : DEFAULTS[settings.mode].host);
if (h !== '127.0.0.1') return null;
return `adb reverse tcp:${p} tcp:${p}`;
/** Did the instance answer its public profile? */
online: boolean;
}
/**
* The exact WebSocket URL the client opens for an alert. Direct mode only:
* `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
* (release: `wss://alarm.dcntrlized.org/ws/alarm?...`).
* Resolves settings into a base URL and checks that the instance is reachable.
*
* Returned verbatim here so it is testable headlessly and so the service team
* can diff the client's expectation against the service.
* Reachability is `GET {instance}/api/instance` — the one deliberately public
* read surface on an instance. Whether the alarm service is installed is not
* probed separately: a stopped service is a service error, not a connection
* error, and the alarm screens surface it.
*/
export function alarmSocketUrl(
resolved: Pick<ResolvedConnection, 'baseUrl' | 'mode'>,
alertId: string,
subscribeToken: string,
): string | null {
if (resolved.mode !== 'direct') return null;
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}`;
return `${ws}/ws/alarm?${q}`;
export async function probe(
settings: ConnectionSettings,
timeoutMs = 4000,
): Promise<ResolvedConnection> {
const baseUrl = normalizeInstanceUrl(settings.instanceUrl) ?? '';
if (!baseUrl) return { baseUrl: '', online: false };
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), timeoutMs);
const res = await fetch(`${baseUrl}/api/instance`, { signal: ctrl.signal });
clearTimeout(timer);
return { baseUrl, online: res.ok };
} catch (e) {
console.log('instance probe failed', baseUrl, (e as Error).name, (e as Error).message);
return { baseUrl, online: false };
}
}
+55
View File
@@ -0,0 +1,55 @@
/**
* Pure single-origin URL helpers.
*
* Kept free of React Native / AsyncStorage imports so the transport contract can
* be asserted headlessly (`__tests__/connection.test.ts`) and so the HTTP client
* can build paths without pulling storage into a unit test.
*/
export const SERVICE_NAME = 'alarm';
/**
* Normalizes what the user typed into an origin.
*
* Accepts `localhost:8080`, `http://host:8080/`, `https://host`, and returns
* `http(s)://host[:port]` without a trailing slash. Returns `null` when the
* value cannot be an origin — the UI treats that as "not configured" instead of
* sending requests to a nonsense URL.
*/
export function normalizeInstanceUrl(raw: string): string | null {
const value = raw.trim();
if (!value) return null;
// Refuse other schemes rather than guessing (a typo must not become a fetch).
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) && !/^https?:\/\//i.test(value)) return null;
if (/\s/.test(value)) return null;
const withScheme = /^https?:\/\//i.test(value) ? value : `http://${value}`;
// Require a host: `http://` alone is not an instance.
const rest = withScheme.replace(/^https?:\/\//i, '').replace(/\/+$/, '');
if (!rest || rest.startsWith('/')) return null;
const scheme = withScheme.slice(0, withScheme.indexOf('://') + 3).toLowerCase();
return `${scheme}${rest}`;
}
/** `'/action/createAlert'` → `'/api/alarm/action/createAlert'`; same for `/auth/*`. */
export function servicePath(path: string): string {
const clean = path.startsWith('/') ? path : `/${path}`;
return `/api/${SERVICE_NAME}${clean}`;
}
/**
* The exact WebSocket URL the client opens for an alert:
* `ws(s)://{instance}/api/alarm/ws/alarm?alertId=<id>&token=<subscribeToken>`.
*
* That the upgrade actually succeeds through a live Maestro is proven by the
* E2E harness, not here. Returns `null` when no instance is configured.
*/
export function alarmSocketUrl(
resolved: { baseUrl: string },
alertId: string,
subscribeToken: string,
): string | null {
if (!resolved.baseUrl) return null;
const ws = resolved.baseUrl.replace(/^http/i, 'ws');
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}`;
return `${ws}/api/${SERVICE_NAME}/ws/alarm?${q}`;
}
+4 -6
View File
@@ -8,6 +8,7 @@ import React, {
} from 'react';
import { configureApi } from '../services/apiClient';
import {
DEFAULT_SETTINGS,
isRelease,
loadSettings,
probe,
@@ -31,10 +32,7 @@ const ConnectionContext = createContext<ConnectionContextValue | undefined>(unde
export function ConnectionProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState<ConnectionSettings>({
mode: 'maestro',
host: '',
port: '',
useAdbReverse: true,
instanceUrl: DEFAULT_SETTINGS.instanceUrl,
});
const [resolved, setResolved] = useState<ResolvedConnection | null>(null);
const [probing, setProbing] = useState(true);
@@ -44,7 +42,7 @@ export function ConnectionProvider({ children }: { children: React.ReactNode })
setProbing(true);
const r = await probe(s);
setResolved(r);
configureApi({ baseUrl: r.baseUrl, mode: r.mode, serviceRef: r.serviceRef });
configureApi({ baseUrl: r.baseUrl });
setProbing(false);
}, []);
@@ -85,7 +83,7 @@ export function ConnectionProvider({ children }: { children: React.ReactNode })
probing,
reprobe,
isRelease,
online: !!resolved && !!resolved.baseUrl,
online: !!resolved?.online,
};
return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>;
+44 -14
View File
@@ -7,7 +7,7 @@ import {
type SocketState,
} from '../services/alarmSocket';
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection';
import { alarmStatus as fetchAlarmStatus, type AlarmStatus } from '../services/alarmApi';
import { alarmStatus as fetchAlarmStatus, isAlertGone, type AlarmStatus } from '../services/alarmApi';
export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
@@ -22,6 +22,10 @@ export interface UseAlarmSocketOptions {
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 {
@@ -36,9 +40,11 @@ export interface UseAlarmSocketResult {
/**
* Owns the live-alarm transport for one subscribed alert.
*
* Direct mode gets the WebSocket (the only mode Maestro can pass through);
* maestro mode is polling-only and reports `delivery: 'unavailable'` so the
* Settings/health screen can say so out loud instead of pretending.
* 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,
@@ -49,6 +55,8 @@ export function useAlarmSocket({
onTrigger,
onDisarm,
enabled = true,
deviceId,
onGone,
}: UseAlarmSocketOptions): UseAlarmSocketResult {
const [socketState, setSocketState] = useState<SocketState>('idle');
const [attempts, setAttempts] = useState(0);
@@ -66,17 +74,36 @@ export function useAlarmSocket({
[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 {
setLastStatus(await fetchAlarmStatus(alertId));
} catch {
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]);
}, [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
@@ -112,8 +139,10 @@ export function useAlarmSocket({
};
}, [enabled, url, alertId, subscribeToken]);
// Polling is always on while armed: it is the fallback when the socket is not
// available (maestro mode) and the safety net when it is momentarily down.
// 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;
@@ -121,10 +150,11 @@ export function useAlarmSocket({
const tick = async () => {
if (cancelled) return;
try {
const status = await fetchAlarmStatus(alertId);
if (!cancelled) setLastStatus(status);
} catch {
// ignore — polling must never surface a spinner
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();
@@ -133,7 +163,7 @@ export function useAlarmSocket({
cancelled = true;
clearInterval(handle);
};
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs]);
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs, applyStatus, deviceId]);
return { socketState, delivery, attempts, lastStatus, refresh };
}