Skip to content

Commit d23ba26

Browse files
committed
feat: sequential port discovery for standalone PWA mesh discovery
Web server walks up from coordinatorPort+1 (19877 by default) instead of using a random port. The standalone PWA probes the same range on localhost to find the mesh WebSocket endpoint. When served from GitHub Pages or any non-localhost host, the PWA: - Skips the direct server WebSocket (CommsWs) - Gracefully handles REST API failures (MeshClient provides state) - Probes localhost:19877..19886 for the mesh WS endpoint
1 parent 6aec130 commit d23ba26

4 files changed

Lines changed: 107 additions & 10 deletions

File tree

src/bridges/pi/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,7 @@ export default function (pi: ExtensionAPI) {
146146
cwd: process.cwd(),
147147
pid: process.pid,
148148
});
149-
webHandle = await tryStartWebServer(webCtrl);
149+
webHandle = await tryStartWebServer(webCtrl, store.coordinatorPort);
150150
}
151151

152152
refreshStatus();

src/bridges/user/web/frontend/main.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -143,9 +143,16 @@ function sendAction(action: Action): void {
143143
}
144144

145145
async function refreshState(): Promise<void> {
146-
const [agents, rooms] = await Promise.all([fetchAgents(), fetchRooms()]);
147-
state.setAgents(agents);
148-
state.setRooms(rooms);
146+
try {
147+
const [agents, rooms] = await Promise.all([
148+
fetchAgents(),
149+
fetchRooms(),
150+
]);
151+
state.setAgents(agents);
152+
state.setRooms(rooms);
153+
} catch {
154+
// REST API unavailable (standalone PWA) — MeshClient provides state
155+
}
149156
}
150157

151158
async function onJoinRoom(roomId: string): Promise<void> {
@@ -275,7 +282,12 @@ state.subscribe(rerender);
275282

276283
const deepLink = parseDeepLink(location.search);
277284

278-
ws.connect();
285+
// Only connect the direct server WebSocket when served from localhost.
286+
// On GitHub Pages / standalone deployments, only MeshClient is used.
287+
const localPattern = /^(localhost|127\.\d+\.\d+\.\d+)(:\d+)?$/;
288+
if (localPattern.test(location.host)) {
289+
ws.connect();
290+
}
279291

280292
// Initial state fetch, then resolve any deep link from the URL
281293
void refreshState().then(() => {

src/bridges/user/web/frontend/mesh-client.ts

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,10 +119,21 @@ export class MeshClient {
119119

120120
worker.port.start();
121121

122-
// Tell the worker to connect to the mesh WS endpoint
123-
const proto = location.protocol === "https:" ? "wss:" : "ws:";
124-
const url = `${proto}//${location.host}/ws/mesh`;
125-
this.postToWorker({ type: "init", url });
122+
// Discover the web server — walk up from 19877 matching the server's
123+
// port discovery. If served by the local server (location.host is
124+
// localhost/127.0.0.1), use that directly. Otherwise probe localhost.
125+
const localPattern = /^(localhost|127\.\d+\.\d+\.\d+)(:\d+)?$/;
126+
const isLocal = localPattern.test(location.host);
127+
if (isLocal) {
128+
const proto = location.protocol === "https:" ? "wss:" : "ws:";
129+
const url = `${proto}//${location.host}/ws/mesh`;
130+
this.postToWorker({ type: "init", url });
131+
} else {
132+
// Standalone PWA (e.g. GitHub Pages) — probe localhost ports.
133+
// The web server binds at coordinatorPort + 1, walking up if taken.
134+
// Coordinator defaults to 19876, so web server starts at 19877.
135+
this.probeLocalMesh(19877, 10);
136+
}
126137
}
127138

128139
/** Get the current mesh state (snapshot). */
@@ -153,6 +164,35 @@ export class MeshClient {
153164
this.pendingActions.clear();
154165
}
155166

167+
/**
168+
* Probe localhost ports sequentially for the web server's /ws/mesh endpoint.
169+
* Matches the server's port discovery: starts at 19877, walks up.
170+
* Sends an init message to the worker on first successful WS upgrade.
171+
*/
172+
private probeLocalMesh(basePort: number, maxAttempts: number): void {
173+
let attempts = 0;
174+
175+
const tryPort = (port: number): void => {
176+
if (attempts >= maxAttempts) return;
177+
attempts++;
178+
179+
const url = `ws://127.0.0.1:${String(port)}/ws/mesh`;
180+
// Quick HTTP fetch to check if anything is listening and speaks our protocol.
181+
// A WebSocket upgrade would be cleaner but fetch is simpler and avoids
182+
// a visible WS error in the console.
183+
fetch(`http://127.0.0.1:${String(port)}/`, { mode: "no-cors" })
184+
.then(() => {
185+
// Something responded — try connecting via the worker
186+
this.postToWorker({ type: "init", url });
187+
})
188+
.catch(() => {
189+
tryPort(port + 1);
190+
});
191+
};
192+
193+
tryPort(basePort);
194+
}
195+
156196
private postToWorker(msg: unknown): void {
157197
if (this.worker) {
158198
this.worker.port.postMessage(JSON.stringify(msg));

src/bridges/user/web/server.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,47 @@ import type {
3232

3333
const WEB_HOST = "127.0.0.1";
3434

35+
/** Maximum number of ports to try when searching for a free one. */
36+
const WEB_PORT_MAX_ATTEMPTS = 10;
37+
38+
// ---------------------------------------------------------------------------
39+
// Port discovery — walk up from 19877
40+
// ---------------------------------------------------------------------------
41+
42+
/**
43+
* Try binding sequentially from WEB_PORT_BASE.
44+
* Returns the first port that succeeds, or undefined if all are taken.
45+
*/
46+
function findFreePort(
47+
base: number,
48+
maxAttempts: number = WEB_PORT_MAX_ATTEMPTS,
49+
): Promise<number | undefined> {
50+
return new Promise((resolve) => {
51+
let attempts = 0;
52+
53+
function tryPort(port: number): void {
54+
if (attempts >= maxAttempts) {
55+
resolve(undefined);
56+
return;
57+
}
58+
attempts++;
59+
60+
const probe = http.createServer();
61+
probe.on("error", () => {
62+
probe.close();
63+
tryPort(port + 1);
64+
});
65+
probe.listen(port, WEB_HOST, () => {
66+
const addr = probe.address();
67+
const actualPort = typeof addr === "object" && addr ? addr.port : port;
68+
probe.close(() => resolve(actualPort));
69+
});
70+
}
71+
72+
tryPort(base);
73+
});
74+
}
75+
3576
// ---------------------------------------------------------------------------
3677
// Static assets — loaded into memory at module load
3778
// ---------------------------------------------------------------------------
@@ -105,8 +146,12 @@ export interface WebServerHandle {
105146
*/
106147
export async function tryStartWebServer(
107148
controller?: ChatController,
149+
coordinatorPort?: number,
108150
): Promise<WebServerHandle | undefined> {
109-
return createWebServer(0, controller);
151+
const base = (coordinatorPort ?? 19876) + 1;
152+
const port = await findFreePort(base);
153+
if (port === undefined) return undefined;
154+
return createWebServer(port, controller);
110155
}
111156

112157
/**

0 commit comments

Comments
 (0)