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
+26 -3
View File
@@ -20,9 +20,10 @@ export function hashUserId(userId: string): number {
const STATUS_KEY = ["voice-status"] as const;
export function useVoiceStatus() {
export function useVoiceStatus(initialData?: VoiceStatus) {
return useSWR<VoiceStatus>(STATUS_KEY, () => voiceApi.getStatus(), {
shouldRetryOnError: false,
fallbackData: initialData,
});
}
@@ -32,10 +33,31 @@ export function useVoiceChannels(guildId: string) {
);
}
export function useSpeakers() {
const [speakers, setSpeakers] = useState<ActiveSpeaker[]>([]);
/**
* Live shared speaker state.
*
* Seeded from the server-authored snapshot (`initial` — the voice status the
* server rendered, which includes the authoritative active speakers). From
* there the WS keeps it converged across ALL users:
* - `voice_state` → authoritative FULL replacement (e.g. a late join seeds
* every client with the same list);
* - `voice_active_user` → incremental upsert of a single speaker delta.
*
* This replaces the old per-browser model where each tab accumulated speakers
* only from events it happened to receive while mounted.
*/
export function useSpeakers(initialStatusActive?: ActiveSpeaker[]) {
const [speakers, setSpeakers] = useState<ActiveSpeaker[]>(
initialStatusActive ?? [],
);
const subscribe = useCallback((ws: WsHook) => {
const unsubSnapshot = ws.on("voice_state", (data) => {
const state = data as { activeSpeakers?: ActiveSpeaker[] };
if (Array.isArray(state?.activeSpeakers)) {
setSpeakers(state.activeSpeakers);
}
});
const unsub = ws.on("voice_active_user", (data) => {
const speaker = data as ActiveSpeaker;
setSpeakers((prev) => {
@@ -49,6 +71,7 @@ export function useSpeakers() {
});
});
return () => {
unsubSnapshot();
unsub();
setSpeakers([]);
};