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
+135
View File
@@ -0,0 +1,135 @@
/**
* Server-only data layer.
*
* 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.
*
* 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.
*/
import type {
AppConfig,
DashboardActivity,
DashboardStats,
Guild,
MediaState,
ModerationAction,
ModerationStats,
PaginatedRecordings,
VoiceStatus,
} from "@/lib/types";
const BACKEND_URL =
process.env.GMW_BACKEND_URL?.replace(/\/+$/, "") || "http://127.0.0.1:4001";
export class ApiServerError extends Error {
statusCode: number;
constructor(message: string, statusCode: number) {
super(message);
this.name = "ApiServerError";
this.statusCode = statusCode;
}
}
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");
}
export async function getActivity(days = 14): Promise<DashboardActivity> {
return serverFetch<DashboardActivity>(`/api/dashboard/activity?days=${days}`);
}
// ---- Media ----
export async function getMediaStatus(): Promise<MediaState> {
return serverFetch<MediaState>("/api/media/status");
}
// ---- Config ----
export async function getConfig(): Promise<AppConfig> {
return serverFetch<AppConfig>("/api/config");
}
// ---- Moderation ----
export async function getModerationStats(): Promise<ModerationStats> {
return serverFetch<ModerationStats>("/api/moderation/stats");
}
export async function getModerationActions(
limit = 100,
): Promise<ModerationAction[]> {
const res = await serverFetch<{ data: ModerationAction[] }>(
`/api/moderation/actions?limit=${limit}`,
);
return res.data;
}
// ---- Voice ----
export async function getGuilds(): Promise<Guild[]> {
return serverFetch<Guild[]>("/api/guilds");
}
export async function getVoiceStatus(): Promise<VoiceStatus> {
return serverFetch<VoiceStatus>("/api/voice/status");
}
// ---- 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()}`);
}
+6
View File
@@ -11,6 +11,12 @@ export interface VoiceStatus {
activeChannelId?: string | null;
activeChannelName?: string | null;
connections: GuildVoiceEntry[];
/**
* Authoritative shared voice snapshot — who is present / speaking right
* now, aggregated server-side from the gateway's `voice_active_user`
* deltas. All browsers converge on this same list.
*/
activeSpeakers?: ActiveSpeaker[];
}
export interface ActiveSpeaker {
+6
View File
@@ -42,6 +42,12 @@ export interface WsEventMap {
voice_recording_stopped: unknown;
voice_recording_uploaded: VoiceRecording;
voice_active_user: ActiveSpeaker;
/**
* Authoritative shared live-voice snapshot — `{ activeSpeakers: [...] }`.
* The backend sends this on WS connect (initial state) and clients replace
* their local list wholesale so every user converges on the same state.
*/
voice_state: { activeSpeakers: ActiveSpeaker[] };
/** NOT delivered as JSON — arrives only via onPcm() binary handler as PcmChunk */
voice_pcm_data: never;
voice_analyzed: unknown;