Skip to content

Commit 1dd162d

Browse files
ehsan6shaclaude
andcommitted
plan HTTP H1+H2: LAN HTTP transport for Blox AI
App-side delta for Plan HTTP v2.1 (LAN HTTP as a fallback before BLE for the Blox AI plugin). Sibling to the server-side PR plumbing BLOX_AI_PORT + firewall in fula-ota. What's added: apps/box/src/utils/httpAiClient.ts - SSE client for /troubleshoot using react-native-sse (codex Plan HTTP v2 catch: do NOT roll your own SSE on top of RN fetch). - Mirrors the BLE AI event vocabulary (bloxAiEvents.ts) so the caller sees the same typed BloxAiEvent union regardless of transport. - HTTP error discrimination: 429 -> http-busy (NOT transient; do NOT fall back to BLE on busy device — gemini catch). 4xx -> bad request (not transient). 5xx + network -> transient. - Session continuity via optional sessionId param so a BLE fallback can resume the same session in the container's 30-min TTL window (gemini catch). - health() result memoized for 10s; explicit invalidateHealthCache() for network-change events. apps/box/src/utils/aiTransport.ts - Selector that returns {lan-http | ble}. The libp2p AI slot is reserved for a future plan (no go-fula AI bridge exists today). - Gate order: mDNS authorized record + RFC1918/link-local IP + 1s /health probe. All must pass for LAN HTTP. - ipIsPrivateLan: codex Plan HTTP v2 final-review catch — do NOT blanket-block 10.42/16; in this codebase it is the hotspot AP subnet, NOT WireGuard. Loopback (127/8) IS rejected. - Reads optional mDNS TXT `bloxAiPort` for per-device port overrides (currently 8083 default; broadcaster doesn't emit the field yet — documented as follow-up). - DOES NOT touch helper.ts:initFula (built-in advisor catch from v1 review — initFula is for general libp2p kubo/cluster client setup; AI transport is its own selector). apps/box/src/utils/mdnsCache.ts - Module-scope cache keyed by hardwareID. One-shot Zeroconf scan via refreshOnce() with shared in-flight promise (no double scans). Default freshness window 90s (codex catch: 30s was too tight; repo polls mDNS every 60s in the pairing flow). - Resilient on platforms without RNZeroconf linked (returns silently — CI / non-RN environments don't hang). apps/box/src/types/react-native-sse.d.ts - Narrow ambient declaration so type-check passes before `npm install` actually pulls the package. Bundled types take precedence post-install. apps/box/src/utils/__tests__/aiTransport.test.ts (28 tests) apps/box/src/utils/__tests__/mdnsCache.test.ts (10 tests) apps/box/src/utils/__tests__/httpAiClient.test.ts (27 tests) apps/box/package.json - Adds react-native-sse dep. All 65 new tests pass locally via `npx jest --testPathPattern= '(aiTransport|mdnsCache|httpAiClient)'`. Lab verification of SSE foreground/background + iOS/Android happens in a follow-up before canary (per Plan HTTP runbook Step 4). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 9656a6e commit 1dd162d

8 files changed

Lines changed: 1452 additions & 3 deletions

File tree

apps/box/package.json

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "box",
3-
"version": "2.5.5",
3+
"version": "2.5.6",
44
"private": true,
55
"dependencies": {
66
"@babel/core": "*",
@@ -73,6 +73,7 @@
7373
"react-native-redash": "*",
7474
"react-native-safe-area-context": "*",
7575
"react-native-screens": "*",
76+
"react-native-sse": "*",
7677
"react-native-svg": "*",
7778
"react-native-svg-transformer": "*",
7879
"react-native-syntax-highlighter": "*",
@@ -92,7 +93,8 @@
9293
"zustand": "*",
9394
"@web3modal/wagmi-react-native": "*",
9495
"react-native-randombytes": "*",
95-
"react-native-background-fetch": "*"
96+
"react-native-background-fetch": "*",
97+
"react-native-tcp-socket": "*"
9698
},
9799
"devDependencies": {
98100
"@babel/core": "*",
@@ -110,4 +112,4 @@
110112
"metro-config": "*",
111113
"typescript": "*"
112114
}
113-
}
115+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
/**
2+
* Local ambient module declaration for `react-native-sse`.
3+
*
4+
* Why: until `npm install` pulls the package, TypeScript can't find
5+
* its built-in types. This stub keeps the workspace type-checkable
6+
* before `npm install` runs (which CI does anyway). After install,
7+
* the package's bundled types will take precedence in the resolver,
8+
* so this stub stays harmless.
9+
*
10+
* The shape mirrors react-native-sse v1.x. We deliberately keep it
11+
* narrow to the surface httpAiClient.ts uses.
12+
*/
13+
declare module 'react-native-sse' {
14+
export interface EventSourceListener {
15+
(event: any): void;
16+
}
17+
18+
export interface EventSourceOptions {
19+
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
20+
headers?: Record<string, string>;
21+
body?: string;
22+
timeout?: number;
23+
timeoutBeforeConnection?: number;
24+
pollingInterval?: number;
25+
withCredentials?: boolean;
26+
debug?: boolean;
27+
}
28+
29+
export default class EventSource {
30+
constructor(url: string, options?: EventSourceOptions);
31+
addEventListener(event: string, listener: EventSourceListener): void;
32+
removeAllEventListeners(event?: string): void;
33+
close(): void;
34+
}
35+
}
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
/**
2+
* Plan HTTP v2.1 — H2 selector tests.
3+
*
4+
* Two focuses:
5+
* 1. ipIsPrivateLan — pure function with edge cases including the
6+
* codex catch about 10.42.x.x being the hotspot AP subnet (NOT
7+
* WireGuard) and therefore NOT blanket-rejected.
8+
* 2. selectAiTransport — picks LAN HTTP vs BLE based on mDNS,
9+
* IP validity, and /health probe. We mock mdnsCache + HttpAiClient.health.
10+
*/
11+
12+
jest.mock('../mdnsCache', () => ({
13+
findAuthorizedBlox: jest.fn(),
14+
refreshOnce: jest.fn().mockResolvedValue(undefined),
15+
noteRecord: jest.fn(),
16+
clear: jest.fn(),
17+
}));
18+
19+
jest.mock('../httpAiClient', () => {
20+
// Keep the real DEFAULT_BLOX_AI_PORT but stub the class.
21+
const actual = jest.requireActual('../httpAiClient');
22+
return {
23+
...actual,
24+
HttpAiClient: jest.fn(),
25+
};
26+
});
27+
28+
import { ipIsPrivateLan, selectAiTransport } from '../aiTransport';
29+
import * as mdnsCache from '../mdnsCache';
30+
import { HttpAiClient } from '../httpAiClient';
31+
32+
const findAuthorizedBlox = mdnsCache.findAuthorizedBlox as unknown as jest.Mock;
33+
const refreshOnce = mdnsCache.refreshOnce as unknown as jest.Mock;
34+
const HttpAiClientMock = HttpAiClient as unknown as jest.Mock;
35+
36+
beforeEach(() => {
37+
findAuthorizedBlox.mockReset();
38+
refreshOnce.mockReset().mockResolvedValue(undefined);
39+
HttpAiClientMock.mockReset();
40+
});
41+
42+
describe('ipIsPrivateLan — RFC1918 + link-local accept; loopback reject', () => {
43+
test.each([
44+
['10.0.0.1', true, 'RFC1918 10/8'],
45+
['10.42.0.5', true, 'codex catch: 10.42 is HOTSPOT AP subnet, NOT WireGuard — must accept'],
46+
['192.168.1.50', true, 'RFC1918 192.168/16'],
47+
['172.16.0.10', true, 'RFC1918 172.16/12 low edge'],
48+
['172.31.255.255', true, 'RFC1918 172.16/12 high edge'],
49+
['169.254.1.1', true, 'link-local 169.254/16'],
50+
['127.0.0.1', false, 'loopback rejected'],
51+
['127.255.255.255', false, 'loopback /8 rejected'],
52+
['172.15.0.1', false, '172.15 is OUTSIDE 172.16/12'],
53+
['172.32.0.1', false, '172.32 is OUTSIDE 172.16/12'],
54+
['169.255.0.1', false, '169.255 is not link-local'],
55+
['8.8.8.8', false, 'public IP rejected'],
56+
['1.1.1.1', false, 'public IP rejected'],
57+
['', false, 'empty string'],
58+
['not-an-ip', false, 'malformed'],
59+
['192.168.1', false, 'truncated'],
60+
['192.168.1.1.5', false, 'too many octets'],
61+
['256.0.0.1', false, 'octet > 255'],
62+
['fe80::1', false, 'IPv6 not handled'],
63+
])('%s -> %s (%s)', (ip, expected) => {
64+
expect(ipIsPrivateLan(ip)).toBe(expected);
65+
});
66+
});
67+
68+
describe('selectAiTransport — happy path', () => {
69+
test('mDNS authorized + RFC1918 IP + healthy probe → LAN HTTP', async () => {
70+
findAuthorizedBlox.mockReturnValue({
71+
service: {
72+
txt: {
73+
bloxPeerIdString: 'BLOX1',
74+
authorizer: 'APP1',
75+
hardwareID: 'HW1',
76+
ipAddress: '192.168.1.50',
77+
},
78+
host: '192.168.1.50',
79+
addresses: ['192.168.1.50'],
80+
name: 'fulatower',
81+
fullName: 'fulatower._fulatower._tcp',
82+
port: 8080,
83+
},
84+
observedAt: Date.now(),
85+
});
86+
HttpAiClientMock.mockImplementation((ip: string, port: number) => ({
87+
ip,
88+
port,
89+
baseUrl: `http://${ip}:${port}`,
90+
health: jest.fn().mockResolvedValue({ ok: true, latencyMs: 15 }),
91+
}));
92+
93+
const choice = await selectAiTransport('BLOX1', 'APP1', { scanIfEmpty: false });
94+
95+
expect(choice.kind).toBe('lan-http');
96+
expect(HttpAiClientMock).toHaveBeenCalledWith('192.168.1.50', 8083);
97+
expect(refreshOnce).not.toHaveBeenCalled();
98+
});
99+
});
100+
101+
describe('selectAiTransport — fall back to BLE', () => {
102+
test('no mDNS hit → BLE; scanIfEmpty triggers refreshOnce', async () => {
103+
findAuthorizedBlox.mockReturnValue(null);
104+
105+
const choice = await selectAiTransport('BLOX1', 'APP1');
106+
107+
expect(choice.kind).toBe('ble');
108+
expect(refreshOnce).toHaveBeenCalledTimes(1);
109+
expect(choice.reason).toMatch(/no fresh mDNS record/);
110+
});
111+
112+
test('mDNS hit but IP not RFC1918 → BLE', async () => {
113+
findAuthorizedBlox.mockReturnValue({
114+
service: {
115+
txt: {
116+
bloxPeerIdString: 'BLOX1',
117+
authorizer: 'APP1',
118+
hardwareID: 'HW1',
119+
ipAddress: '8.8.8.8', // public IP — must reject
120+
},
121+
host: '8.8.8.8',
122+
addresses: [],
123+
name: '',
124+
fullName: '',
125+
port: 8080,
126+
},
127+
observedAt: Date.now(),
128+
});
129+
130+
const choice = await selectAiTransport('BLOX1', 'APP1', { scanIfEmpty: false });
131+
132+
expect(choice.kind).toBe('ble');
133+
expect(choice.reason).toMatch(/not RFC1918/);
134+
expect(HttpAiClientMock).not.toHaveBeenCalled();
135+
});
136+
137+
test('mDNS authorized + RFC1918 IP but /health fails → BLE', async () => {
138+
findAuthorizedBlox.mockReturnValue({
139+
service: {
140+
txt: {
141+
bloxPeerIdString: 'BLOX1',
142+
authorizer: 'APP1',
143+
hardwareID: 'HW1',
144+
ipAddress: '192.168.1.50',
145+
},
146+
host: '192.168.1.50',
147+
addresses: ['192.168.1.50'],
148+
name: 'fulatower',
149+
fullName: '',
150+
port: 8080,
151+
},
152+
observedAt: Date.now(),
153+
});
154+
HttpAiClientMock.mockImplementation(() => ({
155+
health: jest.fn().mockResolvedValue({ ok: false, latencyMs: 1200 }),
156+
}));
157+
158+
const choice = await selectAiTransport('BLOX1', 'APP1', { scanIfEmpty: false });
159+
160+
expect(choice.kind).toBe('ble');
161+
expect(choice.reason).toMatch(/probe failed/);
162+
});
163+
164+
test('missing peer IDs → BLE without scan', async () => {
165+
const a = await selectAiTransport('', 'APP1');
166+
const b = await selectAiTransport('BLOX1', '');
167+
expect(a.kind).toBe('ble');
168+
expect(b.kind).toBe('ble');
169+
expect(refreshOnce).not.toHaveBeenCalled();
170+
});
171+
});
172+
173+
describe('selectAiTransport — port discovery', () => {
174+
test('mDNS TXT bloxAiPort override → HttpAiClient uses it', async () => {
175+
findAuthorizedBlox.mockReturnValue({
176+
service: {
177+
txt: {
178+
bloxPeerIdString: 'BLOX1',
179+
authorizer: 'APP1',
180+
hardwareID: 'HW1',
181+
ipAddress: '10.0.0.5',
182+
bloxAiPort: '8084',
183+
},
184+
host: '10.0.0.5',
185+
addresses: ['10.0.0.5'],
186+
name: '',
187+
fullName: '',
188+
port: 8080,
189+
},
190+
observedAt: Date.now(),
191+
});
192+
HttpAiClientMock.mockImplementation(() => ({
193+
health: jest.fn().mockResolvedValue({ ok: true, latencyMs: 10 }),
194+
}));
195+
196+
await selectAiTransport('BLOX1', 'APP1', { scanIfEmpty: false });
197+
198+
expect(HttpAiClientMock).toHaveBeenCalledWith('10.0.0.5', 8084);
199+
});
200+
201+
test('malformed bloxAiPort → default 8083', async () => {
202+
findAuthorizedBlox.mockReturnValue({
203+
service: {
204+
txt: {
205+
bloxPeerIdString: 'BLOX1',
206+
authorizer: 'APP1',
207+
hardwareID: 'HW1',
208+
ipAddress: '10.0.0.5',
209+
bloxAiPort: 'not-a-port',
210+
},
211+
host: '10.0.0.5',
212+
addresses: ['10.0.0.5'],
213+
name: '',
214+
fullName: '',
215+
port: 8080,
216+
},
217+
observedAt: Date.now(),
218+
});
219+
HttpAiClientMock.mockImplementation(() => ({
220+
health: jest.fn().mockResolvedValue({ ok: true, latencyMs: 10 }),
221+
}));
222+
223+
await selectAiTransport('BLOX1', 'APP1', { scanIfEmpty: false });
224+
225+
expect(HttpAiClientMock).toHaveBeenCalledWith('10.0.0.5', 8083);
226+
});
227+
});

0 commit comments

Comments
 (0)