feat(backend,frontend): migrate data APIs from REST to native tRPC over WebSocket

Replace REST module routers with a single typed tRPC appRouter served over
/trpc (HTTP + WebSocket), and rewire the frontend to call it via
@trpc/client wsLink (browser) and httpLink (RSC data layer). Existing
/api/health + /api/metrics stay as plain Express for infra scraping.

Notable fixes surfaced by the live smoke test:
- Express 5 / path-to-regexp v8 rejects the /trpc/* wildcard route; use a
  prefix middleware that computes opts.path from the URL instead.
- nodeHTTPRequestHandler treats opts.path as the literal procedure path, so
  it is derived per-request from req.url.
- Two ws servers on one http.Server (the /ws voice socket + /trpc) collided
  and returned 400 on upgrade; both now use noServer + a manually routed
  server.on('upgrade') keyed by path.

Verified: BE tsc+biome+40 vitest green; FE tsc+biome green; live
HTTP and WebSocket calls returned real prod data.

Co-Authored-By: Claude Opus 4.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
asepharyana
2026-08-16 13:25:10 +07:00
co-authored by Claude Opus 4.5
parent d8552a9fb8
commit 2fa1827f17
47 changed files with 3318 additions and 1049 deletions
+47
View File
@@ -0,0 +1,47 @@
import type { IncomingMessage, Server } from "node:http";
import type { Duplex } from "node:stream";
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { WebSocketServer } from "ws";
import { createChildLogger } from "@/shared/logger/index";
import { appRouter } from "./routers";
const logger = createChildLogger("trpc.ws");
/**
* Attach the tRPC WebSocket handler to the shared HTTP server, on a path
* SEPARATE from the voice/binary WebSocket (`/ws`). All structured data RPCs
* (dashboard, messages, moderation, media, voice control, recordings,
* analysis, chatbot, config, ui-state) flow over this `/trpc` socket; the
* `/ws` socket is left untouched for Discord PCM audio + gateway events.
*
* We use `noServer` + a manual `upgrade` router (instead of
* `new WebSocketServer({ server, path: "/trpc" })`) because two `ws` servers
* mounted with the `server` option on the SAME http.Server both register
* `upgrade` listeners, and `ws`'s path-guarded listener can reject (400) the
* other server's path. Routing the upgrade ourselves by URL keeps `/trpc`
* and `/ws` fully isolated.
*/
export function createTRPCWebSocketServer(server: Server): WebSocketServer {
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
applyWSSHandler({
wss,
prefix: "/trpc",
router: appRouter,
createContext: (opts) => ({ conn: opts.res }),
keepAlive: { enabled: true, pingMs: 30_000, pongWaitMs: 10_000 },
onError: (err) => {
logger.error({ err }, "tRPC WS error");
},
});
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
if (!req.url?.startsWith("/trpc")) return; // let the /ws server handle it
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req);
});
});
logger.info({ path: "/trpc" }, "tRPC WebSocket server attached");
return wss;
}