fix(build): make oRPC/tRPC dist runnable under node ESM (deploy crashloop)

flake.nix only rewrote @/ aliases but left extensionless relative imports
(./router) in compiled dist/. node dist/index.js (how prod runs) cannot
resolve extensionless ESM specifiers -> ERR_MODULE_NOT_FOUND -> backend
crashlooped (444 restarts, port 4001 dead). Extract the fixer into a shared
scripts/fix-imports.mjs that appends .js to extensionless relative imports and
rewrites @/ aliases, and wire it into backend + discord-gateway build phases.

Verified: fresh tsc + fixer -> node dist/index.js boots; oRPC over /trpc
serves both HTTP POST and WebSocket (config/dashboard/voice/moderation/
media/chatbot/analysis) end-to-end against Postgres + Redis. next build
passes with the oRPC client + partysocket.
This commit is contained in:
asepharyana
2026-08-16 14:45:02 +07:00
parent 2fa1827f17
commit 726a8e116b
27 changed files with 646 additions and 417 deletions
+2 -1
View File
@@ -10,12 +10,13 @@
"format": "biome format --write"
},
"dependencies": {
"@trpc/client": "^11.18.0",
"@orpc/client": "1.15.0",
"clsx": "^2.1.1",
"lucide-react": "^1.27.0",
"motion": "^12.0.0",
"next": "16.2.12",
"next-themes": "^0.4.6",
"partysocket": "1.3.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"swr": "^2.4.2",
+99 -25
View File
@@ -8,9 +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)
'@orpc/client':
specifier: 1.15.0
version: 1.15.0
clsx:
specifier: ^2.1.1
version: 2.1.1
@@ -26,6 +26,9 @@ importers:
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
partysocket:
specifier: 1.3.0
version: 1.3.0(react@19.2.4)
react:
specifier: 19.2.4
version: 19.2.4
@@ -433,6 +436,26 @@ packages:
cpu: [x64]
os: [win32]
'@orpc/client@1.15.0':
resolution: {integrity: sha512-Qt0FdPSGySdQwy83iUWOq+Iqhw2gM9qHtyxfBbcw1mwOz1s4O2BwUFA3ymVTLIRRNYRgPrQLaBZhp8CacqeePg==}
'@orpc/shared@1.15.0':
resolution: {integrity: sha512-A3/JE7pQYSrrRm6/WYJxV3GBhpMdJRPM3h47slQtWBUZe9Sao5En5WBc4tISGQNP2emcez6qIvDziSgD2T+img==}
peerDependencies:
'@opentelemetry/api': '>=1.9.0'
peerDependenciesMeta:
'@opentelemetry/api':
optional: true
'@orpc/standard-server-fetch@1.15.0':
resolution: {integrity: sha512-XYVfgmIt71YrPJSI7RKRiNWjcyktPYHpevkjeohGNBP5aSMAUL95quZArAWr+XOPp4U56XRao54I6wFMZa4How==}
'@orpc/standard-server-peer@1.15.0':
resolution: {integrity: sha512-fsTN+FrdPkseVx99yRenGJYSp0xOrEk9fODk8oh3zzricNYzlZ8GGgCYtDNgt9vFIR/J05dc+Nk76KhcPBQpLw==}
'@orpc/standard-server@1.15.0':
resolution: {integrity: sha512-bje/xn6thDqJY/JQ7xoOjmD1KWE4FsyZOPgnPxwqTZLx/r00stRhLuFvk1hDEGj9UlG7NLZEijDVx0wqvcyDzA==}
'@puppeteer/browsers@3.2.0':
resolution: {integrity: sha512-LlBrE8oqGfU7b1Nk2d5Q1SbuPhZxTj0cJEMDPEws28OjNMELlflekmPPuf4FnK03x0ZRjKaYwJElUcKK4kyqJA==}
engines: {node: '>=22.12.0'}
@@ -541,19 +564,6 @@ 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==}
@@ -661,6 +671,9 @@ packages:
resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==}
engines: {node: '>=6'}
event-target-polyfill@0.0.4:
resolution: {integrity: sha512-Gs6RLjzlLRdT8X9ZipJdIZI/Y6/HhRLyq9RdDlCsnpxr/+Nn6bU2EFGuC94GjxqhM+Nmij2Vcq98yoHrU8uNFQ==}
fflate@0.8.3:
resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==}
@@ -864,6 +877,14 @@ packages:
resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==}
engines: {node: '>=18'}
partysocket@1.3.0:
resolution: {integrity: sha512-1zToNyolZFK/7nuAw/K2bZrNzFqaZyRoCEkS+9vG6WSC5ikrN6qWRe96q6ImU51uptz2r+dAwSkwhJVdQi4LiA==}
peerDependencies:
react: '>=17'
peerDependenciesMeta:
react:
optional: true
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -879,6 +900,10 @@ packages:
resolution: {integrity: sha512-wgBBj7dU5ceGyoT2PCrJpkYOhxPY8mDOmcSKZP92Cj5GgqQ3kv/UxzawnOLxiYuupe4bDf/yiQm3bzs/1nI0rQ==}
engines: {node: '>=22.12.0'}
radash@12.1.1:
resolution: {integrity: sha512-h36JMxKRqrAxVD8201FrCpyeNuUY9Y5zZwujr20fFO77tpUtGa6EZzfKw/3WaiBX95fq7+MpsuMLNdSnORAwSA==}
engines: {node: '>=14.18.0'}
react-dom@19.2.4:
resolution: {integrity: sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==}
peerDependencies:
@@ -938,6 +963,10 @@ packages:
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
tagged-tag@1.0.0:
resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==}
engines: {node: '>=20'}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
@@ -954,6 +983,10 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
type-fest@5.8.0:
resolution: {integrity: sha512-YGYEVz3Fm5iy/AybuA0oyNFq7H4CgQNfRp/qfe8nurE1kuCeNm3/vfm9X4Mtl+qLyaKJUh5xrFZwogr41SMjYA==}
engines: {node: '>=20'}
typed-query-selector@2.12.2:
resolution: {integrity: sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==}
@@ -1314,6 +1347,40 @@ snapshots:
'@next/swc-win32-x64-msvc@16.2.12':
optional: true
'@orpc/client@1.15.0':
dependencies:
'@orpc/shared': 1.15.0
'@orpc/standard-server': 1.15.0
'@orpc/standard-server-fetch': 1.15.0
'@orpc/standard-server-peer': 1.15.0
transitivePeerDependencies:
- '@opentelemetry/api'
'@orpc/shared@1.15.0':
dependencies:
radash: 12.1.1
type-fest: 5.8.0
'@orpc/standard-server-fetch@1.15.0':
dependencies:
'@orpc/shared': 1.15.0
'@orpc/standard-server': 1.15.0
transitivePeerDependencies:
- '@opentelemetry/api'
'@orpc/standard-server-peer@1.15.0':
dependencies:
'@orpc/shared': 1.15.0
'@orpc/standard-server': 1.15.0
transitivePeerDependencies:
- '@opentelemetry/api'
'@orpc/standard-server@1.15.0':
dependencies:
'@orpc/shared': 1.15.0
transitivePeerDependencies:
- '@opentelemetry/api'
'@puppeteer/browsers@3.2.0':
dependencies:
modern-tar: 0.8.4
@@ -1392,15 +1459,6 @@ 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':
@@ -1496,6 +1554,8 @@ snapshots:
escalade@3.2.0: {}
event-target-polyfill@0.0.4: {}
fflate@0.8.3: {}
framer-motion@12.43.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
@@ -1647,6 +1707,12 @@ snapshots:
node-releases@2.0.51:
optional: true
partysocket@1.3.0(react@19.2.4):
dependencies:
event-target-polyfill: 0.0.4
optionalDependencies:
react: 19.2.4
picocolors@1.1.1: {}
postcss@8.4.31:
@@ -1675,6 +1741,8 @@ snapshots:
- utf-8-validate
- yauzl
radash@12.1.1: {}
react-dom@19.2.4(react@19.2.4):
dependencies:
react: 19.2.4
@@ -1752,6 +1820,8 @@ snapshots:
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
tagged-tag@1.0.0: {}
tailwind-merge@3.6.0: {}
tailwindcss@4.3.3: {}
@@ -1762,6 +1832,10 @@ snapshots:
tslib@2.8.1: {}
type-fest@5.8.0:
dependencies:
tagged-tag: 1.0.0
typed-query-selector@2.12.2: {}
typescript@5.9.3: {}
+9 -9
View File
@@ -1,22 +1,22 @@
import { trpc } from "@/lib/trpc/client";
import { orpc } from "@/lib/orpc/client";
import type { ChatbotHistoryRow, ChatbotResponse } from "@/lib/types";
export const chatbotApi = {
send: (message: string, guildId?: string, userId?: string) =>
trpc.chatbot.chat.mutate({
orpc.chatbot.chat({
message,
context: guildId ? { guildId } : undefined,
userId,
}) as unknown as Promise<ChatbotResponse>,
getHistory: (userId?: string) =>
trpc.chatbot.history.query({
limit: 50,
userId,
}) as unknown as Promise<{ history: ChatbotHistoryRow[]; total: number }>,
orpc.chatbot.history({ limit: 50, userId }) as unknown as Promise<{
history: ChatbotHistoryRow[];
total: number;
}>,
clearHistory: (userId?: string) =>
trpc.chatbot.clearHistory.mutate({
userId,
}) as unknown as Promise<{ ok: boolean }>,
orpc.chatbot.clearHistory({ userId }) as unknown as Promise<{
ok: boolean;
}>,
};
+2 -2
View File
@@ -1,6 +1,6 @@
import { trpc } from "@/lib/trpc/client";
import { orpc } from "@/lib/orpc/client";
import type { AppConfig } from "@/lib/types";
export const configApi = {
get: () => trpc.config.get.query() as unknown as Promise<AppConfig>,
get: () => orpc.config.get() as unknown as Promise<AppConfig>,
};
+9 -14
View File
@@ -1,4 +1,4 @@
import { trpc } from "@/lib/trpc/client";
import { orpc } from "@/lib/orpc/client";
import type {
DashboardActivity,
DashboardChannelDetail,
@@ -11,45 +11,40 @@ import type {
} from "@/lib/types";
export const dashboardApi = {
getStats: () =>
trpc.dashboard.stats.query() as unknown as Promise<DashboardStats>,
getStats: () => orpc.dashboard.stats() as unknown as Promise<DashboardStats>,
getActivity: (days = 14) =>
trpc.dashboard.activity.query({
days,
}) as unknown as Promise<DashboardActivity>,
orpc.dashboard.activity({ days }) as unknown as Promise<DashboardActivity>,
listUsers: (limit?: number, cursor?: string, search?: string) =>
trpc.dashboard.users.query({
orpc.dashboard.users({
limit,
cursor,
search,
}) as unknown as Promise<PaginatedUsers>,
getUserDetail: (userId: string) =>
trpc.dashboard.userDetail.query({
orpc.dashboard.userDetail({
userId,
}) as unknown as Promise<DashboardUserDetail>,
listChannels: (limit?: number, search?: string, guildId?: string) =>
trpc.dashboard.channels.query({
orpc.dashboard.channels({
limit,
search,
guildId,
}) as unknown as Promise<PaginatedChannels>,
getChannelDetail: (channelId: string) =>
trpc.dashboard.channelDetail.query({
orpc.dashboard.channelDetail({
channelId,
}) as unknown as Promise<DashboardChannelDetail>,
getTopReactions: (limit = 20) =>
trpc.dashboard.reactions.query({ limit }) as unknown as Promise<
orpc.dashboard.reactions({ limit }) as unknown as Promise<
TopReactedMessage[]
>,
getTopReactors: (limit = 20) =>
trpc.dashboard.reactors.query({ limit }) as unknown as Promise<
TopReactor[]
>,
orpc.dashboard.reactors({ limit }) as unknown as Promise<TopReactor[]>,
};
+5 -4
View File
@@ -1,7 +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";
// oRPC client is browser-only (websocket RPCLink + partysocket). Re-export it
// for convenience / for any code that wants to call oRPC directly instead of
// going through the api/* wrappers. Server-side RSC data lives in ./server
// (fetch RPCLink).
export { orpc } from "../orpc/client";
export { chatbotApi } from "./chatbot";
export { configApi } from "./config";
export { dashboardApi } from "./dashboard";
+6 -6
View File
@@ -1,12 +1,12 @@
import { trpc } from "@/lib/trpc/client";
import { orpc } from "@/lib/orpc/client";
import type { MediaState } from "@/lib/types";
export const mediaApi = {
getStatus: () => trpc.media.status.query() as unknown as Promise<MediaState>,
getStatus: () => orpc.media.status() as unknown as Promise<MediaState>,
queue: (source: string, mode: string) =>
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>,
orpc.media.queue({ source, mode }) as unknown as Promise<MediaState>,
skip: () => orpc.media.skip() as unknown as Promise<MediaState>,
stop: () => orpc.media.stop() as unknown as Promise<MediaState>,
loop: (loop: boolean) =>
trpc.media.loop.mutate({ loop }) as unknown as Promise<MediaState>,
orpc.media.loop({ loop }) as unknown as Promise<MediaState>,
};
+9 -9
View File
@@ -1,4 +1,4 @@
import { trpc } from "@/lib/trpc/client";
import { orpc } from "@/lib/orpc/client";
import type { AttachmentRecord, MessageRecord } from "@/lib/types";
export const messagesApi = {
@@ -8,7 +8,7 @@ export const messagesApi = {
channelId?: string,
cursor?: string,
) =>
trpc.messages.list.query({
orpc.messages.list({
guildId,
limit,
channelId,
@@ -19,7 +19,7 @@ export const messagesApi = {
}>,
getByChannel: (channelId: string, limit?: number, cursor?: string) =>
trpc.messages.byChannel.query({
orpc.messages.byChannel({
channelId,
query: { channelId, limit, cursor },
}) as unknown as Promise<{
@@ -28,10 +28,10 @@ export const messagesApi = {
}>,
getDetail: (id: string) =>
trpc.messages.detail.query({ id }) as unknown as Promise<MessageRecord>,
orpc.messages.detail({ id }) as unknown as Promise<MessageRecord>,
getImages: (guildId: string, limit?: number) =>
trpc.messages.images.query({ guildId, limit }) as unknown as Promise<{
orpc.messages.images({ guildId, limit }) as unknown as Promise<{
data: MessageRecord[];
nextCursor: string | null;
}>,
@@ -42,7 +42,7 @@ export const messagesApi = {
cursor?: string,
messageId?: string,
) =>
trpc.messages.attachmentsByChannel.query({
orpc.messages.attachmentsByChannel({
channelId,
query: { channelId, limit, cursor, messageId },
}) as unknown as Promise<{
@@ -51,15 +51,15 @@ export const messagesApi = {
}>,
getReview: (limit?: number, channelId?: string) =>
trpc.messages.review.query({ limit, channelId }) as unknown as Promise<{
orpc.messages.review({ limit, channelId }) as unknown as Promise<{
results: MessageRecord[];
limit: number;
cursor: null;
}>,
// Analysis search (formerly /api/analysis/search → tRPC analysis.search)
// Analysis search (formerly /api/analysis/search → oRPC analysis.search)
search: (q: string, limit?: number) =>
trpc.analysis.search.query({ q, limit }) as unknown as Promise<{
orpc.analysis.search({ q, limit }) as unknown as Promise<{
results: MessageRecord[];
}>,
};
+3 -3
View File
@@ -1,9 +1,9 @@
import { trpc } from "@/lib/trpc/client";
import { orpc } from "@/lib/orpc/client";
import type { ModerationStats, PaginatedModerationActions } from "@/lib/types";
export const moderationApi = {
getStats: () =>
trpc.moderation.stats.query() as unknown as Promise<ModerationStats>,
orpc.moderation.stats() as unknown as Promise<ModerationStats>,
listActions: (
limit?: number,
@@ -11,7 +11,7 @@ export const moderationApi = {
actionType?: string,
cursor?: string,
) =>
trpc.moderation.actions.query({
orpc.moderation.actions({
limit,
status,
actionType,
+3 -5
View File
@@ -1,4 +1,4 @@
import { trpc } from "@/lib/trpc/client";
import { orpc } from "@/lib/orpc/client";
import type { PaginatedRecordings } from "@/lib/types";
export const recordingsApi = {
@@ -8,7 +8,7 @@ export const recordingsApi = {
userId?: string,
cursor?: string,
) =>
trpc.recordings.list.query({
orpc.recordings.list({
limit,
channelId,
userId,
@@ -16,7 +16,5 @@ export const recordingsApi = {
}) as unknown as Promise<PaginatedRecordings>,
delete: (id: string) =>
trpc.recordings.delete.mutate({ id }) as unknown as Promise<{
ok: boolean;
}>,
orpc.recordings.delete({ id }) as unknown as Promise<{ ok: boolean }>,
};
+27 -28
View File
@@ -1,18 +1,19 @@
/**
* Server-only data layer (React Server Components / route handlers).
*
* 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.
* The dashboard is fully oRPC-native: this module talks to the backend's
* oRPC HTTP endpoint (/trpc) via a fetch RPCLink client the same appRouter
* the browser reaches over WebSocket. No legacy REST `/api/*` is used.
*
* The client is loosely typed (see ./types TRPCClient); results are asserted
* The client is loosely typed (see ./types ORPCClient); 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.
* `@/lib/orpc/client` (websocket RPCLink) via the `@/lib/api/*` wrappers.
*/
import { createTRPCClient, httpBatchLink } from "@trpc/client";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/fetch";
import type {
AppConfig,
DashboardActivity,
@@ -24,24 +25,22 @@ import type {
PaginatedRecordings,
VoiceStatus,
} from "@/lib/types";
import type { TRPCClient } from "../trpc/types";
import type { ORPCClient } from "../orpc/types";
const BACKEND_URL =
process.env.GMW_BACKEND_URL?.replace(/\/+$/, "") || "http://127.0.0.1:4001";
let _client: TRPCClient | null = null;
function serverTrpc(): TRPCClient {
let _client: ORPCClient | null = null;
function serverOrpc(): ORPCClient {
if (!_client) {
_client = createTRPCClient({
links: [
httpBatchLink({
url: `${BACKEND_URL}/trpc`,
fetch(url, init) {
return fetch(url, { ...init, cache: "no-store" });
},
}),
],
}) as unknown as TRPCClient;
const link = new RPCLink({
url: `${BACKEND_URL}/trpc`,
// Always bypass Next.js fetch cache for live dashboard data.
fetch(url, init) {
return fetch(url, { ...init, cache: "no-store" });
},
});
_client = createORPCClient(link) as unknown as ORPCClient;
}
return _client;
}
@@ -57,30 +56,30 @@ export class ApiServerError extends Error {
// ---- Dashboard ----
export async function getDashboardStats(): Promise<DashboardStats> {
return serverTrpc().dashboard.stats.query() as unknown as Promise<DashboardStats>;
return serverOrpc().dashboard.stats() as unknown as Promise<DashboardStats>;
}
export async function getActivity(days = 14): Promise<DashboardActivity> {
return serverTrpc().dashboard.activity.query({
return serverOrpc().dashboard.activity({
days,
}) as unknown as Promise<DashboardActivity>;
}
// ---- Media ----
export async function getMediaStatus(): Promise<MediaState> {
return serverTrpc().media.status.query() as unknown as Promise<MediaState>;
return serverOrpc().media.status() as unknown as Promise<MediaState>;
}
// ---- Config ----
export async function getConfig(): Promise<AppConfig> {
return serverTrpc().config.get.query() as unknown as Promise<AppConfig>;
return serverOrpc().config.get() as unknown as Promise<AppConfig>;
}
// ---- Moderation ----
export async function getModerationStats(): Promise<ModerationStats> {
return serverTrpc().moderation.stats.query() as unknown as Promise<ModerationStats>;
return serverOrpc().moderation.stats() as unknown as Promise<ModerationStats>;
}
export async function getModerationActions(limit = 100) {
const res = (await serverTrpc().moderation.actions.query({
const res = (await serverOrpc().moderation.actions({
limit,
})) as unknown as PaginatedModerationActions;
return res.data;
@@ -88,15 +87,15 @@ export async function getModerationActions(limit = 100) {
// ---- Voice ----
export async function getGuilds(): Promise<Guild[]> {
return serverTrpc().voice.guilds.query() as unknown as Promise<Guild[]>;
return serverOrpc().voice.guilds() as unknown as Promise<Guild[]>;
}
export async function getVoiceStatus(): Promise<VoiceStatus> {
return serverTrpc().voice.status.query() as unknown as Promise<VoiceStatus>;
return serverOrpc().voice.status() as unknown as Promise<VoiceStatus>;
}
// ---- Recordings ----
export async function getRecordings(limit = 50): Promise<PaginatedRecordings> {
return serverTrpc().recordings.list.query({
return serverOrpc().recordings.list({
limit,
}) as unknown as Promise<PaginatedRecordings>;
}
+3 -3
View File
@@ -1,9 +1,9 @@
import { trpc } from "@/lib/trpc/client";
import { orpc } from "@/lib/orpc/client";
import type { UiState } from "@/lib/types";
export const uiStateApi = {
get: () => trpc.uiState.get.query() as unknown as Promise<UiState>,
get: () => orpc.uiState.get() as unknown as Promise<UiState>,
save: (state: UiState) =>
trpc.uiState.update.mutate(state) as unknown as Promise<{ ok: boolean }>,
orpc.uiState.update(state) as unknown as Promise<{ ok: boolean }>,
};
+8 -11
View File
@@ -1,25 +1,22 @@
import { trpc } from "@/lib/trpc/client";
import { orpc } from "@/lib/orpc/client";
import type { Channel, Guild, VoiceStatus } from "@/lib/types";
export const voiceApi = {
// Guilds
getGuilds: () => trpc.voice.guilds.query() as unknown as Promise<Guild[]>,
getGuilds: () => orpc.voice.guilds() as unknown as Promise<Guild[]>,
getTextChannels: (guildId: string) =>
trpc.voice.textChannels.query({ guildId }) as unknown as Promise<Channel[]>,
orpc.voice.textChannels({ guildId }) as unknown as Promise<Channel[]>,
getVoiceChannels: (guildId: string) =>
trpc.voice.voiceChannels.query({
guildId,
}) as unknown as Promise<Channel[]>,
orpc.voice.voiceChannels({ guildId }) as unknown as Promise<Channel[]>,
// Voice connection
getStatus: () => trpc.voice.status.query() as unknown as Promise<VoiceStatus>,
getStatus: () => orpc.voice.status() as unknown as Promise<VoiceStatus>,
connect: (guildId: string, channelId: string) =>
trpc.voice.connect.mutate({
orpc.voice.connect({
guildId,
channelId,
}) as unknown as Promise<VoiceStatus>,
disconnect: () =>
trpc.voice.disconnect.mutate() as unknown as Promise<VoiceStatus>,
disconnect: () => orpc.voice.disconnect() as unknown as Promise<VoiceStatus>,
sendCommand: (command: string) =>
trpc.voice.command.mutate({ command }) as unknown as Promise<unknown>,
orpc.voice.command({ command }) as unknown as Promise<unknown>,
};
+36
View File
@@ -0,0 +1,36 @@
"use client";
import { createORPCClient } from "@orpc/client";
import { RPCLink } from "@orpc/client/websocket";
import PartySocket from "partysocket";
import type { ORPCClient } from "./types";
/**
* oRPC client over WebSocket (browser only).
*
* oRPC's own WebSocket RPCLink takes a caller-provided socket and does NOT
* auto-reconnect, so we back it with partysocket a reconnecting WebSocket
* whose `host` + `basePath: "trpc"` resolves to exactly `/trpc`, matching the
* backend's oRPC WS mount. partysocket keeps a stable socket identity across
* reconnects, which is what oRPC's RPCLink expects.
*
* The client is loosely typed (see ./types): we do NOT import the backend
* router's type into the FE (fragile coupling). api/* wrappers assert leaf
* results to the frontend's local types.
*/
const orpc: ORPCClient = (() => {
if (typeof window === "undefined") {
// SSR fallback — api/* callers are client components, never run server-side.
return undefined as unknown as ORPCClient;
}
const socket = new PartySocket({
host: window.location.host,
basePath: "trpc",
});
const link = new RPCLink({ websocket: socket });
return createORPCClient(link) as unknown as ORPCClient;
})();
export { orpc };
+12
View File
@@ -0,0 +1,12 @@
/**
* Loosely-typed oRPC client shape shared by the browser (websocket RPCLink) and
* server-side (fetch RPCLink) 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, and leaves are
* callable functions returning a Promise. Leaf results are asserted to the
* frontend's local types inside the api/* wrappers.
*/
export type ORPCClient = {
[k: string]: ORPCClient;
} & ((input?: unknown) => Promise<unknown>);
-33
View File
@@ -1,33 +0,0 @@
"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
@@ -1,15 +0,0 @@
/**
* 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>;
};