[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
+144
View File
@@ -0,0 +1,144 @@
/**
* Single-origin transport contract.
*
* The client talks to one instance origin and four paths under it. These are
* pure-string assertions: they pin what the client will send, not that a live
* Maestro accepts the upgrade — that is the E2E harness's job.
*/
import { configureApi, get } from '../src/services/apiClient';
import { alarmSocketUrl, normalizeInstanceUrl, servicePath } from '../src/services/instanceUrl';
import { listSubscriptions, removeSubscription } from '../src/services/alarmApi';
import { probe } from '../src/services/connection';
// The probe module reads settings from storage; the URL helpers above do not,
// which is why they live in a storage-free module.
jest.mock('@react-native-async-storage/async-storage', () => ({
getItem: jest.fn(async () => null),
setItem: jest.fn(async () => undefined),
}));
describe('normalizeInstanceUrl', () => {
it('accepts a bare host and adds http', () => {
expect(normalizeInstanceUrl('localhost:8080')).toBe('http://localhost:8080');
});
it('keeps https and strips trailing slashes', () => {
expect(normalizeInstanceUrl(' https://dcntrlized.example/ ')).toBe('https://dcntrlized.example');
expect(normalizeInstanceUrl('http://10.0.2.2:8080///')).toBe('http://10.0.2.2:8080');
});
it('refuses non-http schemes, whitespace and empty values', () => {
expect(normalizeInstanceUrl('')).toBeNull();
expect(normalizeInstanceUrl(' ')).toBeNull();
expect(normalizeInstanceUrl('ftp://host')).toBeNull();
expect(normalizeInstanceUrl('http://')).toBeNull();
expect(normalizeInstanceUrl('http://host with space')).toBeNull();
});
});
describe('servicePath', () => {
it('prefixes actions and auth with the service name', () => {
expect(servicePath('/action/createAlert')).toBe('/api/alarm/action/createAlert');
expect(servicePath('/auth/login')).toBe('/api/alarm/auth/login');
expect(servicePath('action/getAlert')).toBe('/api/alarm/action/getAlert');
});
});
describe('alarmSocketUrl', () => {
it('carries the instance prefix and the frozen query', () => {
expect(
alarmSocketUrl({ baseUrl: 'https://instance.example' }, 'a1 b', 'tok/en'),
).toBe('wss://instance.example/api/alarm/ws/alarm?alertId=a1%20b&token=tok%2Fen');
});
it('keeps ws for a plain-http dev instance', () => {
expect(alarmSocketUrl({ baseUrl: 'http://127.0.0.1:8080' }, 'a', 't')).toBe(
'ws://127.0.0.1:8080/api/alarm/ws/alarm?alertId=a&token=t',
);
});
it('is null when no instance is configured', () => {
expect(alarmSocketUrl({ baseUrl: '' }, 'a', 't')).toBeNull();
});
});
describe('probe', () => {
it('reports offline with no base URL when nothing is configured', async () => {
await expect(probe({ instanceUrl: '' })).resolves.toEqual({ baseUrl: '', online: false });
});
});
describe('HTTP paths and bodies', () => {
const g = globalThis as unknown as { fetch: typeof fetch };
const realFetch = g.fetch;
const seen: { url: string; body: string | undefined }[] = [];
beforeEach(() => {
seen.length = 0;
configureApi({
baseUrl: 'https://instance.example',
token: 'tok',
refreshToken: null,
onUnauthorized: () => {},
onSessionUpdate: () => {},
});
g.fetch = (async (url: string, init?: { body?: string }) => {
seen.push({ url, body: init?.body });
return {
ok: true,
status: 200,
json: async () => ({ items: [] }),
text: async () => '',
};
}) as unknown as typeof fetch;
});
afterEach(() => {
g.fetch = realFetch;
});
it('sends actions through /api/alarm/action with the payload envelope', async () => {
await get('/action/getAlert?alertId=a1');
expect(seen[0].url).toBe('https://instance.example/api/alarm/action/getAlert?alertId=a1');
});
it('sends revocation with the wire subscription id inside the payload envelope', async () => {
// The service parses `subscriptionId` as a uuid; before the type matched the
// wire, the client sent `undefined` here and the revoke failed with a 400.
await removeSubscription({ alertId: 'a1', subscriptionId: 's1' });
expect(seen[0].url).toBe(
'https://instance.example/api/alarm/action/removeSubscription',
);
expect(JSON.parse(String(seen[0].body))).toEqual({
payload: { alertId: 'a1', subscriptionId: 's1' },
});
});
it('unwraps the listSubscriptions `{items}` envelope', async () => {
g.fetch = (async () => ({
ok: true,
status: 200,
json: async () => ({
items: [
{
id: 's1',
deviceLabel: 'Android phone',
platform: 'android',
enabled: true,
created: '2026-10-18T21:00:00Z',
},
],
}),
text: async () => '',
})) as unknown as typeof fetch;
await expect(listSubscriptions('a1')).resolves.toEqual([
{
id: 's1',
deviceLabel: 'Android phone',
platform: 'android',
enabled: true,
created: '2026-10-18T21:00:00Z',
},
]);
});
});
+7 -7
View File
@@ -4,17 +4,17 @@
# Expects metro already running on 127.0.0.1:8089 (start it with `bun start` # Expects metro already running on 127.0.0.1:8089 (start it with `bun start`
# in another terminal). Reverses the adb tunnels so the device reaches the # in another terminal). Reverses the adb tunnels so the device reaches the
# dev machine via 127.0.0.1, then builds, installs and launches the app: # dev machine via 127.0.0.1, then builds, installs and launches the app:
# - 8080 Maestro (dev default) # - 8080 the instance/Maestro origin (dev default; every service path lives
# - 8088 Alarm direct mode (bypasses Maestro) — the service's host port # under it — the client never opens the service's own 8088)
# - 8089 the Metro packager (this client's port) # - 8089 the Metro packager (this client's port)
# Each reverse maps device 127.0.0.1:<port> -> host 127.0.0.1:<port>, so Metro # Each reverse maps device 127.0.0.1:<port> -> host 127.0.0.1:<port>.
# and the service must keep distinct numbers; 8088 belongs to the service.
# #
# If your phone is on the same LAN instead, turn "Use adb reverse" off in # The instance address is the one thing the app configures (Settings). Leave it
# Settings and it will fall back to the LAN host baked into src/services/connection.ts. # at the default http://127.0.0.1:8080 for a USB phone with these reverses, or
# point it at a LAN/WAN origin; there is no direct-service mode to toggle.
set -euo pipefail set -euo pipefail
for port in 8080 8088 8089; do for port in 8080 8089; do
adb reverse tcp:"$port" tcp:"$port" >/dev/null 2>&1 || true adb reverse tcp:"$port" tcp:"$port" >/dev/null 2>&1 || true
done done
+18 -3
View File
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { KeyboardAvoidingView, Platform, View } from 'react-native'; import { KeyboardAvoidingView, Platform, View } from 'react-native';
import { useAuth } from '../state/AuthContext'; import { useAuth } from '../state/AuthContext';
import { useConnection } from '../state/ConnectionContext'; import { useConnection } from '../state/ConnectionContext';
import { navigationRef } from '../navigationRef';
import { import {
AppText, AppText,
Button, Button,
@@ -17,7 +18,7 @@ type Mode = 'signin' | 'register';
export default function LoginScreen() { export default function LoginScreen() {
const { login, register } = useAuth(); const { login, register } = useAuth();
const { online, resolved } = useConnection(); const { online, resolved, settings } = useConnection();
const [mode, setMode] = useState<Mode>('signin'); const [mode, setMode] = useState<Mode>('signin');
const [username, setUsername] = useState(''); 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 ? ( {!online ? (
<AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}> <AppText style={{ ...typeCaption, color: textSecondary, textAlign: 'center' }}>
{resolved {resolved
? 'The alarm service did not answer. Check Settings → Connection.' ? 'Your instance did not answer. Check Settings → Connection.'
: 'Looking for the alarm service…'} : 'Looking for your instance…'}
</AppText> </AppText>
) : null} ) : null}
</View> </View>
+10 -6
View File
@@ -1,15 +1,22 @@
/** /**
* Alarm WebSocket client. * Alarm WebSocket client.
* *
* URL (frozen contract, direct mode only — Maestro has no service WS * URL (single origin, frozen contract):
* passthrough): `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>` * `ws(s)://{instance}/api/alarm/ws/alarm?alertId=<id>&token=<subscribeToken>`
* *
* Frames: * Frames:
* server → client {"type":"alarm","alertId","eventId","action":"trigger"|"disarm", * server → client {"type":"alarm","alertId","eventId","action":"trigger"|"disarm",
* "message","serverTime","autoStopSeconds"} * "message","serverTime"}
* client → server {"type":"ack","eventId"} * client → server {"type":"ack","eventId"}
* client → server {"type":"ping"} * 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 * 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 * they can be tested headlessly (no React Native, no real socket) — see
* `__tests__/alarmSocket.test.ts`. * `__tests__/alarmSocket.test.ts`.
@@ -112,7 +119,6 @@ export interface AlarmFrame {
action: 'trigger' | 'disarm'; action: 'trigger' | 'disarm';
message?: string; message?: string;
serverTime?: string; serverTime?: string;
autoStopSeconds?: number;
} }
export interface AckFrame { export interface AckFrame {
@@ -151,8 +157,6 @@ export function parseFrame(raw: string): IncomingFrame {
action: obj.action, action: obj.action,
message: typeof obj.message === 'string' ? obj.message : undefined, message: typeof obj.message === 'string' ? obj.message : undefined,
serverTime: typeof obj.serverTime === 'string' ? obj.serverTime : undefined, serverTime: typeof obj.serverTime === 'string' ? obj.serverTime : undefined,
autoStopSeconds:
typeof obj.autoStopSeconds === 'number' ? obj.autoStopSeconds : undefined,
}; };
} }
if (type === 'ack') { if (type === 'ack') {
+51 -43
View File
@@ -1,16 +1,22 @@
/** /**
* HTTP client for the Alarm service — house pattern from * HTTP client for the Alarm service — single origin.
* `calendar-client/src/services/apiClient.ts`, retargeted:
* *
* - maestro mode addresses `POST {maestro}/api/actions/{serviceRef}/{action}` * Every request goes to the instance the user configured, as a path under it:
* with the `{payload: …}` envelope (Maestro's action surface is HTTP-only), *
* - direct mode addresses the service's own listener and uses the `{payload: …}` * `/action/<name>` → `{instance}/api/alarm/action/<name>` (`{payload: …}`)
* envelope for `/action/*` while `/auth/*` take raw JSON bodies * `/auth/<path>` → `{instance}/api/alarm/auth/<path>` (raw JSON body)
* (`raw = true`), *
* - every call carries `X-Access-Token` when a session exists, * There is no `direct` mode and no `serviceRef` lookup: the service is reached
* - a 401 (or an invalid/expired-token body) triggers ONE single-flight * only through the instance, which is the deployment the platform supports.
* `/auth/refresh` and a single retry; a failed refresh clears the session. *
* 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 { export class ApiError extends Error {
status: number | null; 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 { export interface ApiConfig {
baseUrl: string; baseUrl: string;
mode: 'maestro' | 'direct';
serviceRef: string | null;
token: string | null; token: string | null;
refreshToken: string | null; refreshToken: string | null;
onUnauthorized: () => void; onUnauthorized: () => void;
@@ -40,8 +61,6 @@ export interface ApiConfig {
const config: ApiConfig = { const config: ApiConfig = {
baseUrl: '', baseUrl: '',
mode: 'direct',
serviceRef: null,
token: null, token: null,
refreshToken: null, refreshToken: null,
onUnauthorized: () => {}, onUnauthorized: () => {},
@@ -56,25 +75,8 @@ export function apiConfig(): Readonly<ApiConfig> {
return config; 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 { function buildUrl(path: string): string {
if (config.mode === 'maestro') { return `${config.baseUrl}${servicePath(path)}`;
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}`;
} }
// One in-flight refresh shared by every 401: a burst of requests that all hit // One in-flight refresh shared by every 401: a burst of requests that all hit
@@ -88,12 +90,9 @@ function refreshSession(): Promise<boolean> {
try { try {
if (!config.refreshToken) return false; if (!config.refreshToken) return false;
const url = buildUrl('/auth/refresh'); const url = buildUrl('/auth/refresh');
// Raw {refreshToken} in direct mode; {payload} action form in Maestro // `/auth/*` takes the raw body (root AGENTS.md body contract); the
// mode — the service proxies both (raw flag contract, root AGENTS.md). // instance forwards it unchanged.
const body = const body = JSON.stringify({ refreshToken: config.refreshToken });
config.mode === 'maestro'
? JSON.stringify({ payload: { refreshToken: config.refreshToken } })
: JSON.stringify({ refreshToken: config.refreshToken });
const res = await fetch(url, { const res = await fetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -130,13 +129,12 @@ async function perform<T>(
throw new ApiError(null, 'Server not configured yet'); throw new ApiError(null, 'Server not configured yet');
} }
// In Maestro mode every call goes through /api/actions/{service}/{action}, // `/action/*` expects the `{ payload: … }` envelope; `/auth/*` expects the
// which always expects the { payload: ... } wrapper. The raw flag only // raw body. The instance forwards the body unchanged, so this is the service
// applies when talking to a service directly. // contract end to end.
const maestro = config.mode === 'maestro';
let body: string | undefined; let body: string | undefined;
if (payload !== 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); const url = buildUrl(path);
@@ -169,6 +167,7 @@ async function perform<T>(
let message = `HTTP ${res.status}`; let message = `HTTP ${res.status}`;
let code: string | null = null; let code: string | null = null;
let parsedBody: unknown = null;
try { try {
const t = await res.text(); const t = await res.text();
if (t) { if (t) {
@@ -179,6 +178,7 @@ async function perform<T>(
// that need to distinguish cases the message cannot. // that need to distinguish cases the message cannot.
try { try {
const parsed = JSON.parse(t) as { error?: { code?: unknown; message?: unknown } }; const parsed = JSON.parse(t) as { error?: { code?: unknown; message?: unknown } };
parsedBody = parsed;
const err = parsed?.error; const err = parsed?.error;
if (err && typeof err === 'object') { if (err && typeof err === 'object') {
if (typeof err.code === 'string') code = err.code; if (typeof err.code === 'string') code = err.code;
@@ -189,6 +189,14 @@ async function perform<T>(
} }
} }
} catch {} } 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 // 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 // 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 // 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): * Product ruling: *"every packet goes through Maestro, and thus, only a single
* two modes — `maestro` (the orchestrator's `/api/containers` list is probed and * port needs to ever be open … Direct service access should never happen."*
* the service is addressed through `serviceRef`) and `direct` (the service's own * This app is not special: it is another service under one instance address.
* `/_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.
* *
* IMPORTANT (lead ruling, 2026-10-18): Maestro has NO service WebSocket * So there is exactly one thing to resolve — the instance origin — and four
* passthrough — its only WS is `/api/ws` for its own container-status stream and * paths under it:
* its action surface is HTTP-only. So live delivery over the socket is a *
* direct-mode capability; in maestro mode the client polls `alarmStatus`. * 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 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 { export { SERVICE_NAME } from './instanceUrl';
mode: ConnectionMode;
host: string;
port: string;
useAdbReverse: boolean;
}
export const SERVICE_NAME = 'alarm';
export const isRelease = !__DEV__; 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 }> = { export interface ConnectionSettings {
maestro: { host: LAN_HOST, port: '8080' }, /** The instance origin, e.g. `https://dcntrlized.example` or `http://127.0.0.1:8080`. */
direct: { host: LAN_HOST, port: '8088' }, instanceUrl: string;
}; }
export const DEFAULT_SETTINGS: ConnectionSettings = { export const DEFAULT_SETTINGS: ConnectionSettings = {
mode: 'maestro', instanceUrl: isRelease ? '' : DEV_INSTANCE_URL,
host: '',
port: '',
useAdbReverse: true,
}; };
const SETTINGS_KEY = 'settings.connection'; const SETTINGS_KEY = 'settings.connection.v2';
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`;
}
export async function loadSettings(): Promise<ConnectionSettings> { export async function loadSettings(): Promise<ConnectionSettings> {
if (isRelease) {
return { ...DEFAULT_SETTINGS, mode: 'direct', useAdbReverse: false };
}
try { try {
const raw = await AsyncStorage.getItem(SETTINGS_KEY); const raw = await AsyncStorage.getItem(SETTINGS_KEY);
if (!raw) return { ...DEFAULT_SETTINGS }; 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 { } catch {
return { ...DEFAULT_SETTINGS }; return { ...DEFAULT_SETTINGS };
} }
} }
export async function saveSettings(settings: ConnectionSettings): Promise<void> { export async function saveSettings(settings: ConnectionSettings): Promise<void> {
if (isRelease) return;
try { try {
await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); await AsyncStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
} catch {} } 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 { export interface ResolvedConnection {
/** Normalized origin, no trailing slash. Empty when not configured. */
baseUrl: string; baseUrl: string;
host: string; /** Did the instance answer its public profile? */
port: string; online: boolean;
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}`;
} }
/** /**
* The exact WebSocket URL the client opens for an alert. Direct mode only: * Resolves settings into a base URL and checks that the instance is reachable.
* `ws://<host>:<port>/ws/alarm?alertId=<id>&token=<subscribeToken>`
* (release: `wss://alarm.dcntrlized.org/ws/alarm?...`).
* *
* Returned verbatim here so it is testable headlessly and so the service team * Reachability is `GET {instance}/api/instance` — the one deliberately public
* can diff the client's expectation against the service. * 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( export async function probe(
resolved: Pick<ResolvedConnection, 'baseUrl' | 'mode'>, settings: ConnectionSettings,
alertId: string, timeoutMs = 4000,
subscribeToken: string, ): Promise<ResolvedConnection> {
): string | null { const baseUrl = normalizeInstanceUrl(settings.instanceUrl) ?? '';
if (resolved.mode !== 'direct') return null; if (!baseUrl) return { baseUrl: '', online: false };
const ws = resolved.baseUrl.replace(/^http/i, 'ws'); try {
const q = `alertId=${encodeURIComponent(alertId)}&token=${encodeURIComponent(subscribeToken)}`; const ctrl = new AbortController();
return `${ws}/ws/alarm?${q}`; 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'; } from 'react';
import { configureApi } from '../services/apiClient'; import { configureApi } from '../services/apiClient';
import { import {
DEFAULT_SETTINGS,
isRelease, isRelease,
loadSettings, loadSettings,
probe, probe,
@@ -31,10 +32,7 @@ const ConnectionContext = createContext<ConnectionContextValue | undefined>(unde
export function ConnectionProvider({ children }: { children: React.ReactNode }) { export function ConnectionProvider({ children }: { children: React.ReactNode }) {
const [settings, setSettings] = useState<ConnectionSettings>({ const [settings, setSettings] = useState<ConnectionSettings>({
mode: 'maestro', instanceUrl: DEFAULT_SETTINGS.instanceUrl,
host: '',
port: '',
useAdbReverse: true,
}); });
const [resolved, setResolved] = useState<ResolvedConnection | null>(null); const [resolved, setResolved] = useState<ResolvedConnection | null>(null);
const [probing, setProbing] = useState(true); const [probing, setProbing] = useState(true);
@@ -44,7 +42,7 @@ export function ConnectionProvider({ children }: { children: React.ReactNode })
setProbing(true); setProbing(true);
const r = await probe(s); const r = await probe(s);
setResolved(r); setResolved(r);
configureApi({ baseUrl: r.baseUrl, mode: r.mode, serviceRef: r.serviceRef }); configureApi({ baseUrl: r.baseUrl });
setProbing(false); setProbing(false);
}, []); }, []);
@@ -85,7 +83,7 @@ export function ConnectionProvider({ children }: { children: React.ReactNode })
probing, probing,
reprobe, reprobe,
isRelease, isRelease,
online: !!resolved && !!resolved.baseUrl, online: !!resolved?.online,
}; };
return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>; return <ConnectionContext.Provider value={value}>{children}</ConnectionContext.Provider>;
+44 -14
View File
@@ -7,7 +7,7 @@ import {
type SocketState, type SocketState,
} from '../services/alarmSocket'; } from '../services/alarmSocket';
import { alarmSocketUrl, type ResolvedConnection } from '../services/connection'; 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'; export type LiveDelivery = 'socket' | 'polling' | 'unavailable';
@@ -22,6 +22,10 @@ export interface UseAlarmSocketOptions {
onTrigger: (frame: AlarmFrame) => void; onTrigger: (frame: AlarmFrame) => void;
onDisarm: (frame: AlarmFrame) => void; onDisarm: (frame: AlarmFrame) => void;
enabled?: boolean; 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 { export interface UseAlarmSocketResult {
@@ -36,9 +40,11 @@ export interface UseAlarmSocketResult {
/** /**
* Owns the live-alarm transport for one subscribed alert. * Owns the live-alarm transport for one subscribed alert.
* *
* Direct mode gets the WebSocket (the only mode Maestro can pass through); * The WebSocket is the delivery path: triggers arrive only over
* maestro mode is polling-only and reports `delivery: 'unavailable'` so the * `ws(s)://{instance}/api/alarm/ws/alarm`. `delivery: 'unavailable'` means no
* Settings/health screen can say so out loud instead of pretending. * 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({ export function useAlarmSocket({
resolved, resolved,
@@ -49,6 +55,8 @@ export function useAlarmSocket({
onTrigger, onTrigger,
onDisarm, onDisarm,
enabled = true, enabled = true,
deviceId,
onGone,
}: UseAlarmSocketOptions): UseAlarmSocketResult { }: UseAlarmSocketOptions): UseAlarmSocketResult {
const [socketState, setSocketState] = useState<SocketState>('idle'); const [socketState, setSocketState] = useState<SocketState>('idle');
const [attempts, setAttempts] = useState(0); const [attempts, setAttempts] = useState(0);
@@ -66,17 +74,36 @@ export function useAlarmSocket({
[resolved, alertId, subscribeToken], [resolved, alertId, subscribeToken],
); );
/** Capability, not liveness: the URL exists. Liveness is `socketState`. */
const delivery: LiveDelivery = url ? 'socket' : 'unavailable'; 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 () => { const refresh = useCallback(async () => {
if (!alertId) return; if (!alertId) return;
try { try {
setLastStatus(await fetchAlarmStatus(alertId)); applyStatus(await fetchAlarmStatus(alertId, deviceId ?? undefined));
} catch { } catch (e) {
if (isAlertGone(e)) onGoneRef.current?.();
// A failed status read is not fatal — the socket is the primary path and // A failed status read is not fatal — the socket is the primary path and
// the next poll retries. // the next poll retries.
} }
}, [alertId]); }, [alertId, applyStatus, deviceId]);
// AppState awareness: while backgrounded the JS socket is unreliable, so the // 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 // 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]); }, [enabled, url, alertId, subscribeToken]);
// Polling is always on while armed: it is the fallback when the socket is not // Status reconciliation. It never *delivers* a live trigger (the socket is the
// available (maestro mode) and the safety net when it is momentarily down. // 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(() => { useEffect(() => {
if (!enabled || !alertId) return; if (!enabled || !alertId) return;
const interval = appState === 'active' ? foregroundPollMs : backgroundPollMs; const interval = appState === 'active' ? foregroundPollMs : backgroundPollMs;
@@ -121,10 +150,11 @@ export function useAlarmSocket({
const tick = async () => { const tick = async () => {
if (cancelled) return; if (cancelled) return;
try { try {
const status = await fetchAlarmStatus(alertId); const status = await fetchAlarmStatus(alertId, deviceId ?? undefined);
if (!cancelled) setLastStatus(status); if (!cancelled) applyStatus(status);
} catch { } catch (e) {
// ignore — polling must never surface a spinner if (isAlertGone(e)) onGoneRef.current?.();
// ignore otherwise — polling must never surface a spinner
} }
}; };
tick(); tick();
@@ -133,7 +163,7 @@ export function useAlarmSocket({
cancelled = true; cancelled = true;
clearInterval(handle); clearInterval(handle);
}; };
}, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs]); }, [enabled, alertId, appState, foregroundPollMs, backgroundPollMs, applyStatus, deviceId]);
return { socketState, delivery, attempts, lastStatus, refresh }; return { socketState, delivery, attempts, lastStatus, refresh };
} }