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
+1
View File
@@ -10,6 +10,7 @@
"format": "biome format --write"
},
"dependencies": {
"@trpc/client": "^11.18.0",
"clsx": "^2.1.1",
"lucide-react": "^1.27.0",
"motion": "^12.0.0",
+25
View File
@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@trpc/client':
specifier: ^11.18.0
version: 11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)
clsx:
specifier: ^2.1.1
version: 2.1.1
@@ -538,6 +541,19 @@ packages:
'@tailwindcss/postcss@4.3.3':
resolution: {integrity: sha512-JTSZZGQi1AyKirbLN3azmjVzef92tcX7h+iSqPdaeStyFpGpDlKvvpxeOE8njhbUanbRwr3z8DyzhICWnMtQeg==}
'@trpc/client@11.18.0':
resolution: {integrity: sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow==}
hasBin: true
peerDependencies:
'@trpc/server': 11.18.0
typescript: '>=5.7.2'
'@trpc/server@11.18.0':
resolution: {integrity: sha512-JAvXOuNTxgXjIDfQaOvDq1j66LMNfDJUH1IU7Slfn8EvRv2EkH6ehu3A7zpYhjO0syHHiYg77v2lG2JFJgvw7Q==}
hasBin: true
peerDependencies:
typescript: '>=5.7.2'
'@tweenjs/tween.js@23.1.3':
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
@@ -1376,6 +1392,15 @@ snapshots:
postcss: 8.5.25
tailwindcss: 4.3.3
'@trpc/client@11.18.0(@trpc/server@11.18.0(typescript@5.9.3))(typescript@5.9.3)':
dependencies:
'@trpc/server': 11.18.0(typescript@5.9.3)
typescript: 5.9.3
'@trpc/server@11.18.0(typescript@5.9.3)':
dependencies:
typescript: 5.9.3
'@tweenjs/tween.js@23.1.3': {}
'@types/node@20.19.43':
+13 -18
View File
@@ -1,27 +1,22 @@
import { trpc } from "@/lib/trpc/client";
import type { ChatbotHistoryRow, ChatbotResponse } from "@/lib/types";
import { api } from "./client";
function userHeader(userId?: string): Record<string, string> {
return userId && userId !== "anonymous" ? { "X-User-Id": userId } : {};
}
export const chatbotApi = {
send: (message: string, guildId?: string, userId?: string) =>
api.post<ChatbotResponse>(
"/api/chat",
{
message,
context: guildId ? { guildId } : undefined,
},
userHeader(userId),
),
trpc.chatbot.chat.mutate({
message,
context: guildId ? { guildId } : undefined,
userId,
}) as unknown as Promise<ChatbotResponse>,
getHistory: (userId?: string) =>
api.get<{ history: ChatbotHistoryRow[]; total: number }>(
"/api/chat/history",
userHeader(userId),
),
trpc.chatbot.history.query({
limit: 50,
userId,
}) as unknown as Promise<{ history: ChatbotHistoryRow[]; total: number }>,
clearHistory: (userId?: string) =>
api.delete<{ ok: boolean }>("/api/chat/history", userHeader(userId)),
trpc.chatbot.clearHistory.mutate({
userId,
}) as unknown as Promise<{ ok: boolean }>,
};
-69
View File
@@ -1,69 +0,0 @@
export class ApiError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "ApiError";
this.statusCode = statusCode;
}
}
/**
* API base URL resolution.
*
* Default: same-origin the production nginx (gmw-proxy) proxies /api/* to
* the backend, so no cross-origin config is needed. For local dev against a
* remote deployment, set NEXT_PUBLIC_API_URL (e.g. https://imphnen.asepharyana.my.id).
*/
function getBaseUrl(): string {
const override =
typeof process !== "undefined" ? process.env.NEXT_PUBLIC_API_URL : "";
if (override) return override.replace(/\/+$/, "");
if (typeof window === "undefined") return "";
const protocol = window.location.protocol.replace(":", "");
const port = window.location.port;
return `${protocol}://${window.location.hostname}${port ? `:${port}` : ""}`;
}
export async function apiRequest<T>(
method: string,
path: string,
body?: unknown,
headers?: Record<string, string>,
): Promise<T> {
const url = `${getBaseUrl()}${path}`;
const finalHeaders: Record<string, string> = { ...(headers ?? {}) };
if (body !== undefined) {
finalHeaders["Content-Type"] ??= "application/json";
}
const response = await fetch(url, {
method,
headers: finalHeaders,
body: body !== undefined ? JSON.stringify(body) : undefined,
});
if (response.status >= 400) {
const text = await response.text().catch(() => "");
throw new ApiError(text || `HTTP ${response.status}`, response.status);
}
// Handle 204 No Content (e.g., DELETE)
if (response.status === 204) {
return undefined as T;
}
return response.json() as Promise<T>;
}
export const api = {
get: <T>(path: string, headers?: Record<string, string>) =>
apiRequest<T>("GET", path, undefined, headers),
post: <T>(path: string, body?: unknown, headers?: Record<string, string>) =>
apiRequest<T>("POST", path, body, headers),
delete: <T>(path: string, headers?: Record<string, string>) =>
apiRequest<T>("DELETE", path, undefined, headers),
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { trpc } from "@/lib/trpc/client";
import type { AppConfig } from "@/lib/types";
import { api } from "./client";
export const configApi = {
get: () => api.get<AppConfig>("/api/config"),
get: () => trpc.config.get.query() as unknown as Promise<AppConfig>,
};
+30 -26
View File
@@ -1,3 +1,4 @@
import { trpc } from "@/lib/trpc/client";
import type {
DashboardActivity,
DashboardChannelDetail,
@@ -8,44 +9,47 @@ import type {
TopReactedMessage,
TopReactor,
} from "@/lib/types";
import { api } from "./client";
export const dashboardApi = {
getStats: () => api.get<DashboardStats>("/api/dashboard/stats"),
getStats: () =>
trpc.dashboard.stats.query() as unknown as Promise<DashboardStats>,
getActivity: (days = 14) =>
api.get<DashboardActivity>(`/api/dashboard/activity?days=${days}`),
trpc.dashboard.activity.query({
days,
}) as unknown as Promise<DashboardActivity>,
listUsers: (limit?: number, cursor?: string, search?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (cursor) params.set("cursor", cursor);
if (search) params.set("search", search);
const qs = params.toString();
return api.get<PaginatedUsers>(`/api/dashboard/users${qs ? `?${qs}` : ""}`);
},
listUsers: (limit?: number, cursor?: string, search?: string) =>
trpc.dashboard.users.query({
limit,
cursor,
search,
}) as unknown as Promise<PaginatedUsers>,
getUserDetail: (userId: string) =>
api.get<DashboardUserDetail>(`/api/dashboard/users/${userId}`),
trpc.dashboard.userDetail.query({
userId,
}) as unknown as Promise<DashboardUserDetail>,
listChannels: (limit?: number, search?: string, guildId?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (search) params.set("search", search);
// Backend reads req.query.guild_id (snake_case) — see createDashboardRouter in dashboard.routes.ts
if (guildId) params.set("guild_id", guildId);
const qs = params.toString();
return api.get<PaginatedChannels>(
`/api/dashboard/channels${qs ? `?${qs}` : ""}`,
);
},
listChannels: (limit?: number, search?: string, guildId?: string) =>
trpc.dashboard.channels.query({
limit,
search,
guildId,
}) as unknown as Promise<PaginatedChannels>,
getChannelDetail: (channelId: string) =>
api.get<DashboardChannelDetail>(`/api/dashboard/channels/${channelId}`),
trpc.dashboard.channelDetail.query({
channelId,
}) as unknown as Promise<DashboardChannelDetail>,
getTopReactions: (limit = 20) =>
api.get<TopReactedMessage[]>(`/api/dashboard/reactions?limit=${limit}`),
trpc.dashboard.reactions.query({ limit }) as unknown as Promise<
TopReactedMessage[]
>,
getTopReactors: (limit = 20) =>
api.get<TopReactor[]>(`/api/dashboard/reactors?limit=${limit}`),
trpc.dashboard.reactors.query({ limit }) as unknown as Promise<
TopReactor[]
>,
};
+4 -1
View File
@@ -1,5 +1,8 @@
// tRPC client is browser-only (wsLink). Re-export it for convenience / for any
// code that wants to call tRPC directly instead of going through the api/*
// wrappers. Server-side RSC data lives in ./server (httpLink).
export { trpc } from "../trpc/client";
export { chatbotApi } from "./chatbot";
export { ApiError, api, apiRequest } from "./client";
export { configApi } from "./config";
export { dashboardApi } from "./dashboard";
export { mediaApi } from "./media";
+7 -6
View File
@@ -1,11 +1,12 @@
import { trpc } from "@/lib/trpc/client";
import type { MediaState } from "@/lib/types";
import { api } from "./client";
export const mediaApi = {
getStatus: () => api.get<MediaState>("/api/media/status"),
getStatus: () => trpc.media.status.query() as unknown as Promise<MediaState>,
queue: (source: string, mode: string) =>
api.post<MediaState>("/api/media/queue", { source, mode }),
skip: () => api.post<MediaState>("/api/media/skip", {}),
stop: () => api.post<MediaState>("/api/media/stop", {}),
loop: (loop: boolean) => api.post<MediaState>("/api/media/loop", { loop }),
trpc.media.queue.mutate({ source, mode }) as unknown as Promise<MediaState>,
skip: () => trpc.media.skip.mutate() as unknown as Promise<MediaState>,
stop: () => trpc.media.stop.mutate() as unknown as Promise<MediaState>,
loop: (loop: boolean) =>
trpc.media.loop.mutate({ loop }) as unknown as Promise<MediaState>,
};
+44 -54
View File
@@ -1,5 +1,5 @@
import { trpc } from "@/lib/trpc/client";
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
import { api } from "./client";
export const messagesApi = {
list: (
@@ -7,69 +7,59 @@ export const messagesApi = {
limit?: number,
channelId?: string,
cursor?: string,
) => {
// Backend messageQuerySchema expects camelCase guildId (see messages.schema.ts)
const params = new URLSearchParams({ guildId });
if (limit) params.set("limit", String(limit));
if (channelId) params.set("channelId", channelId);
if (cursor) params.set("cursor", cursor);
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
`/api/messages?${params}`,
);
},
) =>
trpc.messages.list.query({
guildId,
limit,
channelId,
cursor,
}) as unknown as Promise<{
data: MessageRecord[];
nextCursor: string | null;
}>,
getByChannel: (channelId: string, limit?: number, cursor?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (cursor) params.set("cursor", cursor);
const qs = params.toString();
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
`/api/messages/${channelId}${qs ? `?${qs}` : ""}`,
);
},
getByChannel: (channelId: string, limit?: number, cursor?: string) =>
trpc.messages.byChannel.query({
channelId,
query: { channelId, limit, cursor },
}) as unknown as Promise<{
data: MessageRecord[];
nextCursor: string | null;
}>,
getDetail: (id: string) =>
api.get<MessageRecord>(`/api/messages/detail/${id}`),
trpc.messages.detail.query({ id }) as unknown as Promise<MessageRecord>,
getImages: (guildId: string, limit?: number) => {
// Backend reads req.query.guildId (camelCase) — see handleGetImageMessages in messages.controller.ts
const params = new URLSearchParams({ guildId });
if (limit) params.set("limit", String(limit));
return api.get<{ data: MessageRecord[]; nextCursor: string | null }>(
`/api/messages/images?${params}`,
);
},
getImages: (guildId: string, limit?: number) =>
trpc.messages.images.query({ guildId, limit }) as unknown as Promise<{
data: MessageRecord[];
nextCursor: string | null;
}>,
getAttachments: (
channelId: string,
limit?: number,
cursor?: string,
messageId?: string,
) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (cursor) params.set("cursor", cursor);
if (messageId) params.set("messageId", messageId);
const qs = params.toString();
return api.get<{ data: AttachmentRecord[]; nextCursor: string | null }>(
`/api/messages/${channelId}/attachments${qs ? `?${qs}` : ""}`,
);
},
) =>
trpc.messages.attachmentsByChannel.query({
channelId,
query: { channelId, limit, cursor, messageId },
}) as unknown as Promise<{
data: AttachmentRecord[];
nextCursor: string | null;
}>,
getReview: (limit?: number, channelId?: string) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (channelId) params.set("channelId", channelId);
return api.get<{ results: MessageRecord[]; limit: number; cursor: null }>(
`/api/review?${params}`,
);
},
getReview: (limit?: number, channelId?: string) =>
trpc.messages.review.query({ limit, channelId }) as unknown as Promise<{
results: MessageRecord[];
limit: number;
cursor: null;
}>,
search: (query: string, limit?: number) => {
const params = new URLSearchParams({ q: query });
if (limit) params.set("limit", String(limit));
return api.get<{ results: MessageRecord[] }>(
`/api/analysis/search?${params}`,
);
},
// Analysis search (formerly /api/analysis/search → tRPC analysis.search)
search: (q: string, limit?: number) =>
trpc.analysis.search.query({ q, limit }) as unknown as Promise<{
results: MessageRecord[];
}>,
};
+10 -13
View File
@@ -1,23 +1,20 @@
import { trpc } from "@/lib/trpc/client";
import type { ModerationStats, PaginatedModerationActions } from "@/lib/types";
import { api } from "./client";
export const moderationApi = {
getStats: () => api.get<ModerationStats>("/api/moderation/stats"),
getStats: () =>
trpc.moderation.stats.query() as unknown as Promise<ModerationStats>,
listActions: (
limit?: number,
status?: string,
actionType?: string,
cursor?: string,
) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (status) params.set("status", status);
if (actionType) params.set("actionType", actionType);
if (cursor) params.set("cursor", cursor);
const qs = params.toString();
return api.get<PaginatedModerationActions>(
`/api/moderation/actions${qs ? `?${qs}` : ""}`,
);
},
) =>
trpc.moderation.actions.query({
limit,
status,
actionType,
cursor,
}) as unknown as Promise<PaginatedModerationActions>,
};
+12 -11
View File
@@ -1,5 +1,5 @@
import { trpc } from "@/lib/trpc/client";
import type { PaginatedRecordings } from "@/lib/types";
import { api } from "./client";
export const recordingsApi = {
list: (
@@ -7,15 +7,16 @@ export const recordingsApi = {
channelId?: string,
userId?: string,
cursor?: string,
) => {
const params = new URLSearchParams();
if (limit) params.set("limit", String(limit));
if (channelId) params.set("channelId", channelId);
if (userId) params.set("userId", userId);
if (cursor) params.set("cursor", cursor);
const qs = params.toString();
return api.get<PaginatedRecordings>(`/api/recordings${qs ? `?${qs}` : ""}`);
},
) =>
trpc.recordings.list.query({
limit,
channelId,
userId,
cursor,
}) as unknown as Promise<PaginatedRecordings>,
delete: (id: string) => api.delete<{ ok: boolean }>(`/api/recordings/${id}`),
delete: (id: string) =>
trpc.recordings.delete.mutate({ id }) as unknown as Promise<{
ok: boolean;
}>,
};
+45 -78
View File
@@ -1,30 +1,51 @@
/**
* Server-only data layer.
* Server-only data layer (React Server Components / route handlers).
*
* These fetchers run exclusively on the Next.js server (React Server
* Components / route handlers). They call the backend over HTTP directly
* (`GMW_BACKEND_URL`), so the browser never needs a client round-trip for the
* initial page data the first paint is server-rendered.
* The dashboard is fully tRPC-native: this module talks to the backend's
* tRPC HTTP endpoint (/trpc) via an httpLink client the same appRouter the
* browser reaches over WebSocket. No legacy REST `/api/*` is used.
*
* Never import this module from a client component. Browser code should keep
* using `@/lib/api/client` (same-origin via the reverse proxy) for live ops.
* The client is loosely typed (see ./types TRPCClient); results are asserted
* to the frontend's local types at each call site.
*
* Never import this module from a client component. Browser code uses
* `@/lib/trpc/client` (wsLink) via the `@/lib/api/*` wrappers.
*/
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import type {
AppConfig,
DashboardActivity,
DashboardStats,
Guild,
MediaState,
ModerationAction,
ModerationStats,
PaginatedModerationActions,
PaginatedRecordings,
VoiceStatus,
} from "@/lib/types";
import type { TRPCClient } from "../trpc/types";
const BACKEND_URL =
process.env.GMW_BACKEND_URL?.replace(/\/+$/, "") || "http://127.0.0.1:4001";
let _client: TRPCClient | null = null;
function serverTrpc(): TRPCClient {
if (!_client) {
_client = createTRPCClient({
links: [
httpBatchLink({
url: `${BACKEND_URL}/trpc`,
fetch(url, init) {
return fetch(url, { ...init, cache: "no-store" });
},
}),
],
}) as unknown as TRPCClient;
}
return _client;
}
export class ApiServerError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
@@ -34,102 +55,48 @@ export class ApiServerError extends Error {
}
}
async function serverFetch<T>(
path: string,
init?: { timeoutMs?: number },
): Promise<T> {
const url = `${BACKEND_URL}${path}`;
const controller = new AbortController();
const timeout = setTimeout(
() => controller.abort(),
init?.timeoutMs ?? 8_000,
);
let res: Response;
try {
res = await fetch(url, {
headers: { Accept: "application/json" },
cache: "no-store",
signal: controller.signal,
});
} finally {
clearTimeout(timeout);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new ApiServerError(text || `HTTP ${res.status}`, res.status);
}
return res.json() as Promise<T>;
}
// ---- Dashboard ----
export async function getDashboardStats(): Promise<DashboardStats> {
return serverFetch<DashboardStats>("/api/dashboard/stats");
return serverTrpc().dashboard.stats.query() as unknown as Promise<DashboardStats>;
}
export async function getActivity(days = 14): Promise<DashboardActivity> {
return serverFetch<DashboardActivity>(`/api/dashboard/activity?days=${days}`);
return serverTrpc().dashboard.activity.query({
days,
}) as unknown as Promise<DashboardActivity>;
}
// ---- Media ----
export async function getMediaStatus(): Promise<MediaState> {
return serverFetch<MediaState>("/api/media/status");
return serverTrpc().media.status.query() as unknown as Promise<MediaState>;
}
// ---- Config ----
export async function getConfig(): Promise<AppConfig> {
return serverFetch<AppConfig>("/api/config");
return serverTrpc().config.get.query() as unknown as Promise<AppConfig>;
}
// ---- Moderation ----
export async function getModerationStats(): Promise<ModerationStats> {
return serverFetch<ModerationStats>("/api/moderation/stats");
return serverTrpc().moderation.stats.query() as unknown as Promise<ModerationStats>;
}
export async function getModerationActions(
limit = 100,
): Promise<ModerationAction[]> {
const res = await serverFetch<{ data: ModerationAction[] }>(
`/api/moderation/actions?limit=${limit}`,
);
export async function getModerationActions(limit = 100) {
const res = (await serverTrpc().moderation.actions.query({
limit,
})) as unknown as PaginatedModerationActions;
return res.data;
}
// ---- Voice ----
export async function getGuilds(): Promise<Guild[]> {
return serverFetch<Guild[]>("/api/guilds");
return serverTrpc().voice.guilds.query() as unknown as Promise<Guild[]>;
}
export async function getVoiceStatus(): Promise<VoiceStatus> {
return serverFetch<VoiceStatus>("/api/voice/status");
return serverTrpc().voice.status.query() as unknown as Promise<VoiceStatus>;
}
// ---- Recordings ----
export async function getRecordings(limit = 50): Promise<PaginatedRecordings> {
return serverFetch<PaginatedRecordings>(`/api/recordings?limit=${limit}`);
}
// ---- Messages ----
export interface MessagePageResult {
data: import("@/lib/types").MessageRecord[];
nextCursor: string | null;
}
export async function getMessages(
guildId: string,
channelId?: string,
cursor?: string,
): Promise<MessagePageResult> {
const params = new URLSearchParams({ guildId });
if (channelId) params.set("channelId", channelId);
if (cursor) params.set("cursor", cursor);
return serverFetch<MessagePageResult>(`/api/messages?${params.toString()}`);
return serverTrpc().recordings.list.query({
limit,
}) as unknown as Promise<PaginatedRecordings>;
}
+4 -3
View File
@@ -1,8 +1,9 @@
import { trpc } from "@/lib/trpc/client";
import type { UiState } from "@/lib/types";
import { api } from "./client";
export const uiStateApi = {
get: () => api.get<UiState>("/api/ui-state"),
get: () => trpc.uiState.get.query() as unknown as Promise<UiState>,
save: (state: UiState) => api.post<{ ok: boolean }>("/api/ui-state", state),
save: (state: UiState) =>
trpc.uiState.update.mutate(state) as unknown as Promise<{ ok: boolean }>,
};
+14 -10
View File
@@ -1,21 +1,25 @@
import { trpc } from "@/lib/trpc/client";
import type { Channel, Guild, VoiceStatus } from "@/lib/types";
import { api } from "./client";
export const voiceApi = {
// Guilds
getGuilds: () => api.get<Guild[]>("/api/guilds"),
getGuilds: () => trpc.voice.guilds.query() as unknown as Promise<Guild[]>,
getTextChannels: (guildId: string) =>
api.get<Channel[]>(`/api/guilds/${guildId}/channels`),
trpc.voice.textChannels.query({ guildId }) as unknown as Promise<Channel[]>,
getVoiceChannels: (guildId: string) =>
api.get<Channel[]>(`/api/guilds/${guildId}/voice-channels`),
trpc.voice.voiceChannels.query({
guildId,
}) as unknown as Promise<Channel[]>,
// Voice connection
getStatus: () => api.get<VoiceStatus>("/api/voice/status"),
getStatus: () => trpc.voice.status.query() as unknown as Promise<VoiceStatus>,
connect: (guildId: string, channelId: string) =>
api.post<VoiceStatus>("/api/voice/connect", { guildId, channelId }),
disconnect: () => api.post<VoiceStatus>("/api/voice/disconnect", {}),
trpc.voice.connect.mutate({
guildId,
channelId,
}) as unknown as Promise<VoiceStatus>,
disconnect: () =>
trpc.voice.disconnect.mutate() as unknown as Promise<VoiceStatus>,
sendCommand: (command: string) =>
api.post<{ success: boolean; command: string }>("/api/voice/command", {
command,
}),
trpc.voice.command.mutate({ command }) as unknown as Promise<unknown>,
};
+33
View File
@@ -0,0 +1,33 @@
"use client";
import { createTRPCClient, createWSClient, wsLink } from "@trpc/client";
import type { TRPCClient } from "./types";
/**
* WebSocket URL for the tRPC data RPC endpoint (/trpc), served by the backend
* on the same host as the page (nginx proxies it). Upgrades httpws and
* derives wss:// when the page is served over https.
*/
function resolveWsUrl(): string {
if (typeof window === "undefined") return "ws://localhost/trpc";
const proto = window.location.protocol === "https:" ? "wss" : "ws";
return `${proto}://${window.location.host}/trpc`;
}
/**
* tRPC client over WebSocket (browser only). `createTRPCClient` (no router
* generic) is an untyped client; we assert it into the loose `TRPCClient`
* shape so `.dashboard.stats.query(...)` etc. typecheck. See ./types for why
* the client shape is intentionally untyped.
*/
const wsClient =
typeof window === "undefined"
? null
: createWSClient({ url: resolveWsUrl() });
export const trpc: TRPCClient = wsClient
? (createTRPCClient({
links: [wsLink({ client: wsClient })],
}) as unknown as TRPCClient)
: // SSR fallback — api/* callers are client components, never run server-side.
(undefined as unknown as TRPCClient);
+15
View File
@@ -0,0 +1,15 @@
/**
* Loosely-typed tRPC client shape shared by the browser (wsLink) and
* server-side (httpLink) clients. The real router lives on the backend; we do
* NOT import its type here (coupling the FE typecheck to the BE source tree
* and its `@/` alias layout is fragile). Instead we describe the minimal shape
* the api/* wrappers rely on: any path segment yields an object with `query`
* and `mutate`, and arbitrary further nesting is allowed. Leaf results are
* asserted to the frontend's local types inside the api/* wrappers.
*/
export type TRPCClient = {
[k: string]: TRPCClient;
} & {
query(input?: unknown): Promise<unknown>;
mutate(input?: unknown): Promise<unknown>;
};