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
+22 -23
View File
@@ -1,4 +1,5 @@
import { nodeHTTPRequestHandler } from "@trpc/server/adapters/node-http";
import { RPCHandler } from "@orpc/server/node";
import { onError } from "@orpc/server";
import express, {
type Express,
type NextFunction,
@@ -9,13 +10,13 @@ import helmet from "helmet";
import { createChildLogger } from "@/shared/logger/index";
import { createHealthRouter } from "../modules/health/index.js";
import { errorHandler } from "../shared/middlewares/index.js";
import { appRouter } from "../trpc/routers";
import { appRouter } from "../orpc/router";
// Auth removed — dashboard is public.
// All data APIs (dashboard, messages, moderation, media, voice, recordings,
// analysis, chatbot, config, ui-state) now flow over tRPC, served on TWO
// analysis, chatbot, config, ui-state) now flow over oRPC, served on TWO
// transports sharing the /trpc path:
// - WebSocket (browser live RPCs) — see trpc/ws.ts
// - WebSocket (browser live RPCs) — see orpc/ws.ts
// - HTTP POST (server-side / RSC fetch) — handled below
// Only infra endpoints (health, prometheus metrics) remain plain HTTP.
@@ -31,7 +32,7 @@ export function createHttpApp(): Express {
}),
);
// Body parsing (still needed for any JSON POST; tRPC is WS-based)
// Body parsing (still needed for any JSON POST; oRPC is WS/HTTP-based)
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
@@ -60,29 +61,27 @@ export function createHttpApp(): Express {
// Infra-only HTTP endpoints
app.use("/api", createHealthRouter());
// tRPC over HTTP (server-side / RSC fetch). The context has no WebSocket
// here (that's the WS transport's job); procedures don't read ctx.conn, so
// a null conn is safe.
// NOTE: Express 5 (path-to-regexp v8) rejects the `"/trpc/*"` wildcard route,
// and `nodeHTTPRequestHandler` uses `opts.path` as the literal procedure
// path (it does NOT derive it from `req.url`). So we mount a plain
// middleware and compute the procedure path from the URL ourselves.
// oRPC over HTTP (server-side / RSC fetch). The same appRouter the browser
// reaches over the /trpc WebSocket. oRPC's node RPCHandler writes the full
// response itself; if no procedure matched we fall through to the 404 below.
const orpcHandler = new RPCHandler(appRouter, {
interceptors: [onError((error) => logger.error({ error }, "oRPC error"))],
});
app.use((req: Request, res: Response, next: NextFunction) => {
if (!req.path.startsWith("/trpc")) {
next();
return;
}
const procPath = req.url.replace(/^\/trpc\/?/, "").split("?")[0] || "/";
nodeHTTPRequestHandler({
router: appRouter,
createContext: () => ({ conn: null }),
req,
res,
path: procPath,
}).catch((err: unknown) => {
logger.error({ err }, "tRPC HTTP handler failed");
if (!res.headersSent) res.status(500).json({ error: "INTERNAL" });
});
orpcHandler
.handle(req, res, { prefix: "/trpc", context: {} })
.then(({ matched }) => {
if (!matched) next();
})
.catch((err: unknown) => {
logger.error({ err }, "oRPC HTTP handler failed");
if (!res.headersSent) res.status(500).json({ error: "INTERNAL" });
});
});
// 404 handler
+2 -2
View File
@@ -2,7 +2,7 @@ import { createServer, type Server } from "node:http";
import { createChildLogger } from "@/shared/logger/index";
import { config } from "../shared/config/index.js";
import { initializeDatabase } from "../shared/database/index.js";
import { createTRPCWebSocketServer } from "../trpc/ws.js";
import { createORPCWebSocketServer } from "../orpc/ws.js";
import { startRedisBridge } from "../ws/redis-bridge.js";
import { createWebSocketServer } from "../ws/server.js";
import { createHttpApp } from "./app.js";
@@ -19,7 +19,7 @@ export async function startHttpServer(): Promise<Server> {
// Attach WebSocket servers to the same HTTP server
createWebSocketServer(server); // /ws — voice PCM + gateway events
createTRPCWebSocketServer(server); // /trpc — structured data RPCs
createORPCWebSocketServer(server); // /trpc — structured data RPCs
// Start Redis pub/sub bridge to forward discord-gateway events to WS clients
await startRedisBridge();
@@ -1,20 +1,12 @@
import { os } from "@orpc/server";
import { z } from "zod";
import { analysisService } from "../modules/analysis/analysis.service";
import { chatRequestSchema } from "../modules/chatbot/chatbot.schema";
import { chatbotService } from "../modules/chatbot/chatbot.service";
// ── Service imports ──────────────────────────────────────────────
import { dashboardService } from "../modules/dashboard/dashboard.service";
import {
mediaLoopSchema,
mediaQueueSchema,
} from "../modules/media/media.schema";
import {
getStatus,
queue,
setLoop,
skip,
stop,
} from "../modules/media/media.service";
import { mediaLoopSchema, mediaQueueSchema } from "../modules/media/media.schema";
import { getStatus, queue, setLoop, skip, stop } from "../modules/media/media.service";
import { messageQuerySchema } from "../modules/messages/messages.schema";
import { messagesService } from "../modules/messages/messages.service";
import { moderationService } from "../modules/moderation/moderation.service";
@@ -30,17 +22,14 @@ import {
} from "../modules/voice/voice.service";
import { config } from "../shared/config/index";
import { publishCommandNoReply } from "../shared/redis/index";
import { logger, publicProcedure, router } from "./trpc";
// ── Dashboard ────────────────────────────────────────────────────
const dashboardRouter = router({
stats: publicProcedure.query(() => dashboardService.getStats()),
activity: publicProcedure
.input(
z.object({ days: z.coerce.number().int().min(1).max(90).default(14) }),
)
.query(({ input }) => dashboardService.getActivity(input.days)),
users: publicProcedure
const dashboardRouter = {
stats: os.handler(() => dashboardService.getStats()),
activity: os
.input(z.object({ days: z.coerce.number().int().min(1).max(90).default(14) }))
.handler(({ input }) => dashboardService.getActivity(input.days)),
users: os
.input(
z.object({
limit: z.coerce.number().int().positive().default(20),
@@ -48,17 +37,17 @@ const dashboardRouter = router({
search: z.string().optional(),
}),
)
.query(({ input }) =>
.handler(({ input }) =>
dashboardService.listUsers({
limit: input.limit,
cursor: input.cursor,
search: input.search,
}),
),
userDetail: publicProcedure
userDetail: os
.input(z.object({ userId: z.string() }))
.query(({ input }) => dashboardService.getUserDetail(input.userId)),
channels: publicProcedure
.handler(({ input }) => dashboardService.getUserDetail(input.userId)),
channels: os
.input(
z.object({
limit: z.coerce.number().int().positive().default(20),
@@ -66,82 +55,82 @@ const dashboardRouter = router({
guildId: z.string().optional(),
}),
)
.query(({ input }) =>
.handler(({ input }) =>
dashboardService.listChannels({
limit: input.limit,
search: input.search,
guildId: input.guildId,
}),
),
channelDetail: publicProcedure
channelDetail: os
.input(z.object({ channelId: z.string() }))
.query(({ input }) => dashboardService.getChannelDetail(input.channelId)),
reactions: publicProcedure
.handler(({ input }) => dashboardService.getChannelDetail(input.channelId)),
reactions: os
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
.query(({ input }) => dashboardService.getTopReactions(input.limit)),
reactors: publicProcedure
.handler(({ input }) => dashboardService.getTopReactions(input.limit)),
reactors: os
.input(z.object({ limit: z.coerce.number().int().positive().default(20) }))
.query(({ input }) => dashboardService.getTopReactors(input.limit)),
});
.handler(({ input }) => dashboardService.getTopReactors(input.limit)),
};
// ── Messages ─────────────────────────────────────────────────────
const messagesRouter = router({
list: publicProcedure
const messagesRouter = {
list: os
.input(messageQuerySchema)
.query(({ input }) => messagesService.listMessages(input)),
byChannel: publicProcedure
.handler(({ input }) => messagesService.listMessages(input)),
byChannel: os
.input(
z.object({
channelId: z.string(),
query: messageQuerySchema,
}),
)
.query(({ input }) =>
.handler(({ input }) =>
messagesService.getMessagesByChannel(input.channelId, input.query),
),
detail: publicProcedure
detail: os
.input(z.object({ id: z.string() }))
.query(({ input }) => messagesService.getMessageById(input.id)),
images: publicProcedure
.handler(({ input }) => messagesService.getMessageById(input.id)),
images: os
.input(
z.object({
guildId: z.string(),
limit: z.coerce.number().int().positive().default(50),
}),
)
.query(({ input }) =>
.handler(({ input }) =>
messagesService.getImageMessages(input.guildId, input.limit),
),
attachmentsByChannel: publicProcedure
attachmentsByChannel: os
.input(
z.object({
channelId: z.string(),
query: messageQuerySchema,
}),
)
.query(({ input }) =>
.handler(({ input }) =>
messagesService.getAttachmentsByChannel(input.channelId, input.query),
),
review: publicProcedure
review: os
.input(
z.object({
limit: z.coerce.number().int().positive().default(20),
channelId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.handler(async ({ input }) => {
const rows = await messagesService.getReviewMessages(
input.channelId,
input.limit,
);
return { results: rows, limit: input.limit, cursor: null };
}),
});
};
// ── Moderation ───────────────────────────────────────────────────
const moderationRouter = router({
stats: publicProcedure.query(() => moderationService.getStats()),
actions: publicProcedure
const moderationRouter = {
stats: os.handler(() => moderationService.getStats()),
actions: os
.input(
z.object({
limit: z.coerce.number().int().positive().default(50),
@@ -150,7 +139,7 @@ const moderationRouter = router({
cursor: z.coerce.number().int().optional(),
}),
)
.query(({ input }) =>
.handler(({ input }) =>
moderationService.listActions({
limit: input.limit,
status: input.status,
@@ -158,60 +147,64 @@ const moderationRouter = router({
cursor: input.cursor,
}),
),
});
};
// ── Media ────────────────────────────────────────────────────────
const mediaRouter = router({
status: publicProcedure.query(() => getStatus()),
queue: publicProcedure.input(mediaQueueSchema).mutation(async ({ input }) => {
await queue(input.source, input.mode);
return getStatus();
}),
skip: publicProcedure.mutation(async () => {
const mediaRouter = {
status: os.handler(() => getStatus()),
queue: os
.input(mediaQueueSchema)
.handler(async ({ input }) => {
await queue(input.source, input.mode);
return getStatus();
}),
skip: os.handler(async () => {
await skip();
return getStatus();
}),
stop: publicProcedure.mutation(async () => {
stop: os.handler(async () => {
await stop();
return getStatus();
}),
loop: publicProcedure.input(mediaLoopSchema).mutation(async ({ input }) => {
await setLoop(input.loop);
return getStatus();
}),
});
loop: os
.input(mediaLoopSchema)
.handler(async ({ input }) => {
await setLoop(input.loop);
return getStatus();
}),
};
// ── Voice ─────────────────────────────────────────────────────────
const voiceRouter = router({
guilds: publicProcedure.query(() => getGuilds()),
textChannels: publicProcedure
const voiceRouter = {
guilds: os.handler(() => getGuilds()),
textChannels: os
.input(z.object({ guildId: z.string() }))
.query(({ input }) => getTextChannels(input.guildId)),
voiceChannels: publicProcedure
.handler(({ input }) => getTextChannels(input.guildId)),
voiceChannels: os
.input(z.object({ guildId: z.string() }))
.query(({ input }) => getVoiceChannels(input.guildId)),
status: publicProcedure.query(() => getVoiceStatus()),
connect: publicProcedure
.handler(({ input }) => getVoiceChannels(input.guildId)),
status: os.handler(() => getVoiceStatus()),
connect: os
.input(z.object({ guildId: z.string(), channelId: z.string() }))
.mutation(async ({ input }) => {
.handler(async ({ input }) => {
await connectVoice(input.guildId, input.channelId);
return getVoiceStatus();
}),
disconnect: publicProcedure.mutation(async () => {
disconnect: os.handler(async () => {
await disconnectVoice();
return getVoiceStatus();
}),
command: publicProcedure
command: os
.input(z.object({ command: z.string().min(1) }))
.mutation(async ({ input }) => {
.handler(async ({ input }) => {
await publishCommandNoReply(input.command);
return { success: true, command: input.command };
}),
});
};
// ── Recordings ───────────────────────────────────────────────────
const recordingsRouter = router({
list: publicProcedure
const recordingsRouter = {
list: os
.input(
z.object({
limit: z.coerce.number().int().positive().default(50),
@@ -220,24 +213,24 @@ const recordingsRouter = router({
cursor: z.string().optional(),
}),
)
.query(({ input }) =>
.handler(({ input }) =>
recordingsService.getRecent(input.limit, {
channelId: input.channelId,
userId: input.userId,
cursor: input.cursor,
}),
),
delete: publicProcedure
delete: os
.input(z.object({ id: z.string() }))
.mutation(async ({ input }) => {
.handler(async ({ input }) => {
await recordingsService.deleteById(input.id);
return { ok: true };
}),
});
};
// ── Analysis (search) ──────────────────────────────────────────────
const analysisRouter = router({
search: publicProcedure
const analysisRouter = {
search: os
.input(
z.object({
q: z.string().default(""),
@@ -245,18 +238,18 @@ const analysisRouter = router({
limit: z.coerce.number().int().positive().default(20),
}),
)
.query(({ input }) =>
.handler(({ input }) =>
analysisService.search({
q: input.q,
channelId: input.channelId,
limit: input.limit,
}),
),
});
};
// ── Chatbot ───────────────────────────────────────────────────────
const chatbotRouter = router({
chat: publicProcedure
const chatbotRouter = {
chat: os
.input(
chatRequestSchema.extend({
// Per-device actor id; the old REST layer used an X-User-Id header.
@@ -264,7 +257,7 @@ const chatbotRouter = router({
userId: z.string().optional(),
}),
)
.mutation(async ({ input }) => {
.handler(async ({ input }) => {
const userId = input.userId ?? "anonymous";
const response = await chatbotService.processMessage(
input.message,
@@ -280,30 +273,30 @@ const chatbotRouter = router({
});
return { response, timestamp: new Date().toISOString() };
}),
history: publicProcedure
history: os
.input(
z.object({
limit: z.coerce.number().int().positive().max(100).default(50),
userId: z.string().optional(),
}),
)
.query(async ({ input }) => {
.handler(async ({ input }) => {
const userId = input.userId ?? "anonymous";
const history = await chatbotService.getChatHistory(userId, input.limit);
return { history, total: history.length };
}),
clearHistory: publicProcedure
clearHistory: os
.input(z.object({ userId: z.string().optional() }))
.mutation(async ({ input }) => {
.handler(async ({ input }) => {
const userId = input.userId ?? "anonymous";
await chatbotService.clearChatHistory(userId);
return { ok: true };
}),
});
};
// ── Config (public dashboard config snapshot) ──────────────────────
const configRouter = router({
get: publicProcedure.query(() => ({
const configRouter = {
get: os.handler(() => ({
monitorGuildId: config.MONITOR_GUILD_ID || null,
webserverPort: config.WEBSERVER_PORT,
nodeEnv: config.NODE_ENV,
@@ -318,18 +311,18 @@ const configRouter = router({
voiceChannelId: config.VOICE_CHANNEL_ID || null,
logLevel: config.LOG_LEVEL,
})),
});
};
// ── UI State ──────────────────────────────────────────────────────
const uiStateRouter = router({
get: publicProcedure.query(() => uiStateService.getState()),
update: publicProcedure
const uiStateRouter = {
get: os.handler(() => uiStateService.getState()),
update: os
.input(z.record(z.string(), z.unknown()))
.mutation(({ input }) => uiStateService.updateState(input)),
});
.handler(({ input }) => uiStateService.updateState(input)),
};
// ── Root router ───────────────────────────────────────────────────
export const appRouter = router({
export const appRouter = {
dashboard: dashboardRouter,
messages: messagesRouter,
moderation: moderationRouter,
@@ -340,8 +333,6 @@ export const appRouter = router({
chatbot: chatbotRouter,
config: configRouter,
uiState: uiStateRouter,
});
};
export type AppRouter = typeof appRouter;
logger.info("tRPC appRouter constructed");
@@ -1,14 +1,15 @@
import type { IncomingMessage, Server } from "node:http";
import type { Duplex } from "node:stream";
import { applyWSSHandler } from "@trpc/server/adapters/ws";
import { RPCHandler } from "@orpc/server/ws";
import { onError } from "@orpc/server";
import { WebSocketServer } from "ws";
import { createChildLogger } from "@/shared/logger/index";
import { appRouter } from "./routers";
import { appRouter } from "./router";
const logger = createChildLogger("trpc.ws");
const logger = createChildLogger("orpc.ws");
/**
* Attach the tRPC WebSocket handler to the shared HTTP server, on a path
* Attach the oRPC 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
@@ -21,27 +22,20 @@ const logger = createChildLogger("trpc.ws");
* 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");
},
export function createORPCWebSocketServer(server: Server): WebSocketServer {
const handler = new RPCHandler(appRouter, {
interceptors: [onError((error) => logger.error({ error }, "oRPC WS error"))],
});
const wss = new WebSocketServer({ noServer: true, perMessageDeflate: false });
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);
handler.upgrade(ws, { context: {} });
});
});
logger.info({ path: "/trpc" }, "tRPC WebSocket server attached");
logger.info({ path: "/trpc" }, "oRPC WebSocket server attached");
return wss;
}
-35
View File
@@ -1,35 +0,0 @@
import { initTRPC } from "@trpc/server";
import type { WebSocket } from "ws";
import { createChildLogger } from "@/shared/logger/index";
const logger = createChildLogger("trpc");
/**
* tRPC context. The WebSocket transport enriches each request with the raw
* socket so procedures can, if needed, inspect connection metadata. The
* dashboard is public (no auth), mirroring the previous REST layer.
*/
export interface TRPCContext {
conn: WebSocket | null;
}
const t = initTRPC.context<TRPCContext>().create({
errorFormatter({ shape, error }) {
return {
...shape,
data: {
...shape.data,
// Surface a stable code + message for client-side handling.
code: error.code,
stack: undefined,
},
};
},
});
export const router = t.router;
export const publicProcedure = t.procedure;
// Re-export so routers can import z from one place if desired.
export { z } from "zod";
export { logger };