@@ -47,27 +47,35 @@ export interface DaemonHandle {
4747
4848// Port budget.
4949//
50- // MQTT ports are PINNED to 1883 / 9001 because the web UI (mqtt-store.svelte.js)
51- // hardcodes `ws://${hostname}:9001/mqtt` and we cannot patch the client per the
52- // Phase 1 brief (read-only on web/src/**). This means:
53- // - tests cannot run in parallel across spec files (one broker on 9001)
54- // - Phil's dev daemon MUST be stopped before running E2E (see README)
50+ // EVERY port is slot-scoped so N daemons run side by side (workers > 1).
5551//
56- // MCP and web ports are slot-scoped so a future client refactor with config-
57- // derived ws URL can flip workers>1.
58- const FIXED_BROKER_PORT = 1883 ;
59- const FIXED_BROKER_WS_PORT = 9001 ;
52+ // The MQTT ports used to be PINNED to 1883 / 9001 because the web UI was
53+ // believed to hardcode `ws://${hostname}:9001/mqtt`. That pin is now OBSOLETE:
54+ // the single-origin refactor (PRs #23-26) moved the browser's broker connection
55+ // to a same-origin `/mqtt` WebSocket bridged onto the daemon's WEB port. The
56+ // daemon advertises `broker_ws_same_origin: true` via /api/capabilities, so
57+ // mqtt-store's `resolveBrokerUrl()` connects to `ws://<page-host>:<web-port>/mqtt`
58+ // — NOT a hardcoded 9001. The daemon still BINDS a broker TCP listener
59+ // (broker.port) and a broker WS listener (broker.ws_port) for its own internal
60+ // MQTT client + non-web (TUI/MCP/doctor) clients, so both must still get a
61+ // unique, slot-scoped port to avoid cross-worker collisions; the browser path
62+ // never touches them.
63+ //
64+ // Each slot reserves a contiguous 10-port window (mcp .. mqttWs use offsets
65+ // 0..3), so slots never overlap.
6066const PORT_BASE = {
6167 mcp : 9930 ,
6268 web : 9931 ,
69+ mqttTcp : 9932 ,
70+ mqttWs : 9933 ,
6371} ;
6472
6573export function portsForSlot ( slot : number ) : DaemonPorts {
6674 return {
6775 mcp : PORT_BASE . mcp + slot * 10 ,
6876 web : PORT_BASE . web + slot * 10 ,
69- mqttTcp : FIXED_BROKER_PORT ,
70- mqttWs : FIXED_BROKER_WS_PORT ,
77+ mqttTcp : PORT_BASE . mqttTcp + slot * 10 ,
78+ mqttWs : PORT_BASE . mqttWs + slot * 10 ,
7179 } ;
7280}
7381
@@ -140,15 +148,16 @@ function buildConfigYaml(opts: ConfigOptions): string {
140148 ` api_base: "http://127.0.0.1:${ ports . mcp } "` ,
141149 ' strict_cors: true' ,
142150 ' markdown_render_enabled: true' ,
143- // The web client hardcodes ws://${hostname}:9001/mqtt. In reverse-proxy
144- // mode the CSP derives its WS allow-list from api_base (port mcp), so we
145- // must whitelist the hardcoded 9001 URL explicitly. Same for localhost
146- // since the browser may resolve loopback as either.
151+ // Single-origin: the browser connects to ws://<web-host>:<web-port>/mqtt
152+ // (same origin as the page), which CSP connect-src 'self' already permits,
153+ // so no broker-WS-port whitelist is needed. We still whitelist the slot's
154+ // own broker WS port defensively for any legacy/cached bundle that derives
155+ // ws://host:<ws_port>/mqtt from the advertised broker_ws_port.
147156 ' csp_extra_connect_src:' ,
148- ` - "ws://127.0.0.1:${ FIXED_BROKER_WS_PORT } "` ,
149- ` - "ws://127.0.0.1:${ FIXED_BROKER_WS_PORT } /mqtt"` ,
150- ` - "ws://localhost:${ FIXED_BROKER_WS_PORT } "` ,
151- ` - "ws://localhost:${ FIXED_BROKER_WS_PORT } /mqtt"` ,
157+ ` - "ws://127.0.0.1:${ ports . mqttWs } "` ,
158+ ` - "ws://127.0.0.1:${ ports . mqttWs } /mqtt"` ,
159+ ` - "ws://localhost:${ ports . mqttWs } "` ,
160+ ` - "ws://localhost:${ ports . mqttWs } /mqtt"` ,
152161 'notifications:' ,
153162 ' hook_enabled: false' ,
154163 ' sound_enabled: false' ,
@@ -180,17 +189,14 @@ export async function spawnDaemon(opts: SpawnDaemonOptions): Promise<DaemonHandl
180189 const ports = portsForSlot ( opts . slot ) ;
181190 const startupTimeoutMs = opts . startupTimeoutMs ?? 20000 ;
182191
183- // Verify ports are free before we try to bind.
192+ // Verify ports are free before we try to bind. All ports are slot-scoped,
193+ // so a collision here means a stale e2e daemon (or another process) is
194+ // squatting on this slot's window — not a pinned-port conflict.
184195 for ( const p of [ ports . mcp , ports . web , ports . mqttTcp , ports . mqttWs ] ) {
185196 if ( ! ( await isPortFree ( p ) ) ) {
186- const isMqtt = p === FIXED_BROKER_PORT || p === FIXED_BROKER_WS_PORT ;
187197 throw new Error (
188198 `E2E port ${ p } (slot ${ opts . slot } ) is already in use. ` +
189- ( isMqtt
190- ? `MQTT ports (1883/9001) are pinned because the web UI hardcodes them. ` +
191- `Stop your dev claude-comms daemon (\`claude-comms stop\`) before running E2E. ` +
192- `See web/e2e/README.md for details.`
193- : `Check for stale e2e daemons (pkill -f 'claude-comms start').` )
199+ `Check for stale e2e daemons (pkill -f 'claude-comms start').`
194200 ) ;
195201 }
196202 }
0 commit comments