( req: IncomingMessage, res: ServerResponse, routes: ReturnType<typeof buildRoutes>, deps: ServerDeps, options: ServerOptions, log: ReturnType<typeof rootLogger.child>, )
| 118 | } |
| 119 | |
| 120 | async function dispatch( |
| 121 | req: IncomingMessage, |
| 122 | res: ServerResponse, |
| 123 | routes: ReturnType<typeof buildRoutes>, |
| 124 | deps: ServerDeps, |
| 125 | options: ServerOptions, |
| 126 | log: ReturnType<typeof rootLogger.child>, |
| 127 | ): Promise<void> { |
| 128 | const url = new URL(req.url ?? "/", "http://localhost"); |
| 129 | const method = (req.method ?? "GET").toUpperCase(); |
| 130 | let pathname = url.pathname; |
| 131 | |
| 132 | const selfAgent = (options.agent ?? null) as AgentName | null; |
| 133 | |
| 134 | // Backwards-compat for the old single-port "hub/peer" layout, where |
| 135 | // every URL was prefixed (`/openclaw/api/v1/...` or |
| 136 | // `/hermes/...`). New installs serve the SPA at root, but old |
| 137 | // bookmarks (and the old `AGENT_PREFIX` baked into older viewer |
| 138 | // bundles) still hit the prefixed paths. |
| 139 | // |
| 140 | // Two cases: |
| 141 | // - Prefix matches THIS agent → drop the prefix and continue |
| 142 | // dispatching internally. We must NOT 302 here, because POST / |
| 143 | // PATCH / DELETE get downgraded to GET on a 302 in most browsers, |
| 144 | // which would silently corrupt mutations. |
| 145 | // - Prefix matches the OTHER agent → that agent lives on its own |
| 146 | // port now, so 302 the user there. (We accept the request-method |
| 147 | // downgrade because cross-port redirects are inherently a "follow |
| 148 | // this link" gesture; the SPA bundle on the other port re-issues |
| 149 | // mutations from form state, not from the original POST body.) |
| 150 | for (const name of AGENT_PREFIXES) { |
| 151 | const prefix = `/${name}`; |
| 152 | if (pathname === prefix || pathname.startsWith(`${prefix}/`)) { |
| 153 | const tail = pathname.slice(prefix.length) || "/"; |
| 154 | if (name === "memos" || name === selfAgent) { |
| 155 | pathname = tail; |
| 156 | break; |
| 157 | } |
| 158 | const peerPort = AGENT_DEFAULT_PORTS[name]; |
| 159 | const targetHost = req.headers["host"]?.split(":")[0] ?? "127.0.0.1"; |
| 160 | res.writeHead(302, { |
| 161 | Location: `http://${targetHost}:${peerPort}${tail}${url.search}`, |
| 162 | }); |
| 163 | res.end(); |
| 164 | return; |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | // Static assets first — cheapest path. Serve on GET/HEAD only. |
| 169 | // The root path falls through to the static handler which serves |
| 170 | // `index.html` (the SPA). There is no picker page: each agent owns |
| 171 | // its own port and is reachable directly. The SPA's header probes |
| 172 | // the peer's well-known port and surfaces a small link if it's up. |
| 173 | if ((method === "GET" || method === "HEAD") && !pathname.startsWith("/api/")) { |
| 174 | const served = await serveStatic(res, pathname, options); |
| 175 | if (served) return; |
| 176 | } |
| 177 |
no test coverage detected