feat(frontend): rebuild as SSR with server-authoritative shared state

Rombak total alur data frontend: dari static-export CSR (tiap browser
fetch sendiri + akumulasi state voice per-tab) jadi server-side rendering.

Frontend (Next.js):
- next.config: output export -> standalone; halaman jadi server components
- server data layer baru src/lib/api/server.ts (GMW_BACKEND_URL, no window)
- dashboard/media/messages/moderation/recordings/voice page -> RSC yang
  fetch backend di render-time, seed ke client view (SWR fallbackData)
- hook-hook utama terima initialData -> first paint data server, revalidate
  SWR setelahnya, tanpa spinner-blank-load
- messages: guild/channel/tab/selected dibaca dari URL di server, page awal
  di-fetch server-side

Shared realtime state (voice) server-authoritative:
- backend src/modules/voice/live-speaker.ts: agregat voice_active_user dari
  gateway jadi snapshot authoritatif (single source of truth semua browser)
- GET /api/voice/status kini include activeSpeakers
- WS initial states kirim voice_state snapshot saat connect (late join
  langsung dapat state yang sama, bukan daftar kosong)
- useSpeakers seed dari server snapshot + voice_state full-replace +
  voice_active_user delta upsert

Deploy:
- flake.nix: frontend package build SSR standalone (server.js wrapper,
  GMW_FRONTEND_PORT=4017); proxy nginx template proxy / -> Next server,
  /api + /ws tetap ke backend :4001
This commit is contained in:
asepharyana
2026-08-07 10:44:03 +07:00
parent aa440eda69
commit f20889868d
32 changed files with 1556 additions and 956 deletions
+22
View File
@@ -2,9 +2,11 @@ import Redis from "ioredis";
import { config } from "../shared/config/index.js";
import {
DISCORD_CHANNEL_TO_WS_EVENT,
DISCORD_VOICE_ACTIVE_USER,
DISCORD_VOICE_PCM,
} from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { recordSpeaker } from "../modules/voice/live-speaker.js";
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
const logger = createChildLogger("ws.redis-bridge");
@@ -62,6 +64,26 @@ function handleSubscriptionMessage(channel: string, message: string): void {
}
}
// Aggregate live-voice state authoritatively BEFORE broadcasting.
// Every browser hears the same `voice_active_user` deltas, so the backend
// can maintain the single shared snapshot for late-joining clients.
if (channel === DISCORD_VOICE_ACTIVE_USER) {
const speaker = data as {
userId?: string;
username?: string;
avatar?: string | null;
speaking?: boolean;
};
if (speaker?.userId) {
recordSpeaker({
userId: speaker.userId,
username: speaker.username,
avatar: speaker.avatar,
speaking: Boolean(speaker.speaking),
});
}
}
logger.debug({ channel, eventType }, "Broadcasting Redis event");
broadcastEvent(eventType, data);
}
+16
View File
@@ -66,6 +66,22 @@ async function sendInitialStates(ws: WebSocket): Promise<void> {
} catch (err) {
logger.warn({ err }, "Failed to send initial media_state");
}
// Send initial live-voice snapshot (shared authoritative state — a browser
// joining mid-call sees the same speakers as everyone else, not an empty DB).
try {
const { getActiveSpeakers } = await import(
"../modules/voice/live-speaker.js"
);
ws.send(
JSON.stringify({
type: "voice_state",
state: { activeSpeakers: getActiveSpeakers() },
}),
);
} catch (err) {
logger.warn({ err }, "Failed to send initial voice_state");
}
}
export function closeWebSocketServer(): void {