feat: realtime WebSocket layer for live events and streaming logs
- Backend: ConnectionManager, Redis-backed bus with in-process fallback, channel authorization, and typed emit helpers wired into workers (ai_runtime, sandbox_gateway), workforce/sandbox/billing/audit/error services. - Frontend: single shared WS client with backoff + heartbeat, RealtimeProvider, useLiveChannel/useLiveEvent hooks, live indicators on task detail, manager chat, dashboard, traces, sandbox terminal, admin errors, and billing. - Contracts: WsEvent/WsServerFrame/WsClientFrame types mirroring the port. All 255 backend tests pass; web typecheck and build pass.
This commit is contained in:
@@ -25,6 +25,7 @@ import {
|
||||
} from "@agy/ui";
|
||||
import type { TableColumn } from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
|
||||
/**
|
||||
* Platform-owner error console.
|
||||
@@ -101,6 +102,63 @@ export default function AdminErrorsPage() {
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [notice, setNotice] = React.useState<string | null>(null);
|
||||
|
||||
// Live feed: new errors appear in the console as they happen.
|
||||
useLiveChannel(["admin"], (event) => {
|
||||
if (event.type === "error.recorded") {
|
||||
const data = event.data as {
|
||||
error_id: string | null;
|
||||
severity: ErrorSeverity;
|
||||
source: ErrorSource;
|
||||
kind: string;
|
||||
message: string;
|
||||
organization_id: string | null;
|
||||
};
|
||||
const id = data.error_id ?? event.id;
|
||||
setEvents((current) => {
|
||||
if (current.some((e) => e.id === id)) return current;
|
||||
return [
|
||||
{
|
||||
id,
|
||||
kind: data.kind,
|
||||
message: data.message,
|
||||
stack: "",
|
||||
severity: data.severity,
|
||||
source: data.source,
|
||||
error_type: data.kind,
|
||||
fingerprint: id,
|
||||
status_code: null,
|
||||
method: "",
|
||||
path: "",
|
||||
organization_id: data.organization_id,
|
||||
user_id: null,
|
||||
run_id: null,
|
||||
request_id: "",
|
||||
ip: "",
|
||||
user_agent: "",
|
||||
metadata_json: {},
|
||||
resolved_at: null,
|
||||
resolved_note: "",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
...current,
|
||||
];
|
||||
});
|
||||
setTotal((current) => current + 1);
|
||||
setSummary((current) =>
|
||||
current
|
||||
? {
|
||||
...current,
|
||||
total_open: current.total_open + 1,
|
||||
open_by_severity: {
|
||||
...current.open_by_severity,
|
||||
[data.severity]: (current.open_by_severity[data.severity] ?? 0) + 1,
|
||||
},
|
||||
}
|
||||
: current,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const UNIT_LABELS: Record<string, string> = {
|
||||
@@ -69,6 +70,44 @@ export default function BillingPage() {
|
||||
const [actionError, setActionError] = React.useState<string | null>(null);
|
||||
const [checkoutUrl, setCheckoutUrl] = React.useState<string | null>(null);
|
||||
|
||||
// Live feed: wallet balance and usage events update as they happen.
|
||||
useLiveChannel(
|
||||
orgId ? [`org:${orgId}`] : [],
|
||||
(event) => {
|
||||
switch (event.type) {
|
||||
case "billing.wallet": {
|
||||
const data = event.data as { organization_id: string; balance: number };
|
||||
setSummary((current) =>
|
||||
current ? { ...current, balance: data.balance, low_balance: Boolean(data.balance <= current.low_balance_threshold) } : current,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "usage.recorded": {
|
||||
const data = event.data as {
|
||||
run_id: string | null;
|
||||
unit: string;
|
||||
quantity: number;
|
||||
};
|
||||
setEvents((current) => [
|
||||
{
|
||||
id: event.id,
|
||||
run_id: data.run_id,
|
||||
unit: data.unit as UsageEvent["unit"],
|
||||
quantity: data.quantity,
|
||||
unit_price: 0,
|
||||
total_price: 0,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
...current,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
UsageBar,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -24,6 +25,32 @@ export default function DashboardPage() {
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// Live feed: audit events stream into the timeline; usage updates the bar.
|
||||
useLiveChannel(
|
||||
activeOrganization ? [`org:${activeOrganization.id}`] : [],
|
||||
(event) => {
|
||||
switch (event.type) {
|
||||
case "audit.recorded": {
|
||||
const data = event.data as { action: string; target_type: string; target_id: string };
|
||||
setAudit((current) => [
|
||||
{
|
||||
id: event.id,
|
||||
action: data.action,
|
||||
actor_user_id: null,
|
||||
target_type: data.target_type,
|
||||
target_id: data.target_id,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
...current,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { Employee, Message, Task, Thread } from "@agy/contracts";
|
||||
import type { Employee, Message, Task, TaskStatus, Thread } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/**
|
||||
@@ -48,6 +49,63 @@ export default function ManagerPage() {
|
||||
|
||||
const bottomRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Live feed: new messages in the active thread, and tasks created from chat.
|
||||
const liveChannels = React.useMemo(() => {
|
||||
const base: string[] = [];
|
||||
if (orgId) base.push(`org:${orgId}`);
|
||||
if (activeThreadId) base.push(`thread:${activeThreadId}`);
|
||||
return base;
|
||||
}, [orgId, activeThreadId]);
|
||||
|
||||
useLiveChannel(liveChannels, (event) => {
|
||||
switch (event.type) {
|
||||
case "message.created": {
|
||||
const data = event.data as {
|
||||
thread_id: string;
|
||||
message: { id: string; role: string; content: string; employee_id?: string | null };
|
||||
};
|
||||
if (data.thread_id === activeThreadId) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
id: data.message.id,
|
||||
thread_id: data.thread_id,
|
||||
role: data.message.role as Message["role"],
|
||||
content: data.message.content,
|
||||
employee_id: data.message.employee_id ?? null,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "task.created": {
|
||||
const data = event.data as { task_id: string; title: string; status: string };
|
||||
setTasks((current) => {
|
||||
if (current.some((t) => t.id === data.task_id)) return current;
|
||||
return [
|
||||
{
|
||||
id: data.task_id,
|
||||
organization_id: orgId ?? "",
|
||||
project_id: null,
|
||||
thread_id: activeThreadId,
|
||||
title: data.title,
|
||||
description: "",
|
||||
status: data.status as TaskStatus,
|
||||
assigned_employee_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
...current,
|
||||
];
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { Employee, RunDetail, TaskDetail, TaskStatus, ToolCall } from "@agy/contracts";
|
||||
import type { Employee, RunDetail, RunStatus, TaskDetail, TaskStatus, ToolCall } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
@@ -64,6 +65,112 @@ export default function TaskDetailPage({
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [actionError, setActionError] = React.useState<string | null>(null);
|
||||
|
||||
// Live feed: task status, run steps, approvals, artifacts — all push.
|
||||
const channels = React.useMemo(() => {
|
||||
const base: string[] = [];
|
||||
if (orgId) base.push(`org:${orgId}`);
|
||||
if (taskId) base.push(`task:${taskId}`);
|
||||
if (selectedRun?.id) base.push(`run:${selectedRun.id}`);
|
||||
return base;
|
||||
}, [orgId, taskId, selectedRun?.id]);
|
||||
|
||||
useLiveChannel(channels, (event) => {
|
||||
switch (event.type) {
|
||||
case "task.status": {
|
||||
const data = event.data as { task_id: string; status: string };
|
||||
if (data.task_id === taskId) {
|
||||
setTask((current) => (current ? { ...current, status: data.status as TaskStatus } : current));
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "run.step": {
|
||||
const data = event.data as {
|
||||
run_id: string;
|
||||
task_id: string;
|
||||
step: { id: string; index: number; kind: string; title: string; payload: Record<string, unknown> };
|
||||
};
|
||||
setSelectedRun((current) => {
|
||||
if (!current || current.id !== data.run_id) return current;
|
||||
return {
|
||||
...current,
|
||||
steps: [
|
||||
...current.steps,
|
||||
{
|
||||
id: data.step.id,
|
||||
index: data.step.index,
|
||||
kind: data.step.kind,
|
||||
title: data.step.title,
|
||||
payload_json: data.step.payload,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "run.status": {
|
||||
const data = event.data as { run_id: string; task_id: string; status: string };
|
||||
setSelectedRun((current) =>
|
||||
current && current.id === data.run_id
|
||||
? { ...current, status: data.status as RunDetail["status"] }
|
||||
: current,
|
||||
);
|
||||
setTask((current) => {
|
||||
if (!current) return current;
|
||||
return {
|
||||
...current,
|
||||
runs: current.runs.map((r) =>
|
||||
r.id === data.run_id ? { ...r, status: data.status as RunStatus } : r,
|
||||
),
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "approval.pending": {
|
||||
const data = event.data as { tool_call_id: string; tool_key: string; run_id: string };
|
||||
setApprovals((current) => {
|
||||
if (current.some((a) => a.id === data.tool_call_id)) return current;
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id: data.tool_call_id,
|
||||
tool_key: data.tool_key,
|
||||
status: "awaiting_approval",
|
||||
requires_approval: true,
|
||||
arguments_json: {},
|
||||
result_json: {},
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "artifact.created": {
|
||||
const data = event.data as { artifact_id: string; run_id: string; name: string };
|
||||
setSelectedRun((current) => {
|
||||
if (!current || current.id !== data.run_id) return current;
|
||||
return {
|
||||
...current,
|
||||
artifacts: [
|
||||
...current.artifacts,
|
||||
{
|
||||
id: data.artifact_id,
|
||||
name: data.name,
|
||||
kind: "text",
|
||||
size_bytes: 0,
|
||||
content_ref: "",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { AuditEvent, Run, RunDetail } from "@agy/contracts";
|
||||
import type { AuditEvent, Run, RunDetail, RunStatus } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
Timeline,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/** How many tasks to fan out into. Bounds the request count. */
|
||||
@@ -108,6 +109,71 @@ export default function TracesPage() {
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [detailLoading, setDetailLoading] = React.useState(false);
|
||||
|
||||
// Live feed: run steps and status changes update both the list and the detail.
|
||||
useLiveChannel(
|
||||
orgId ? [`org:${orgId}`] : [],
|
||||
(event) => {
|
||||
switch (event.type) {
|
||||
case "run.step": {
|
||||
const data = event.data as {
|
||||
run_id: string;
|
||||
task_id: string;
|
||||
step: { id: string; index: number; kind: string; title: string; payload: Record<string, unknown> };
|
||||
};
|
||||
setSelected((current) => {
|
||||
if (!current || current.id !== data.run_id) return current;
|
||||
return {
|
||||
...current,
|
||||
steps: [
|
||||
...current.steps,
|
||||
{
|
||||
id: data.step.id,
|
||||
index: data.step.index,
|
||||
kind: data.step.kind,
|
||||
title: data.step.title,
|
||||
payload_json: data.step.payload,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "run.status": {
|
||||
const data = event.data as { run_id: string; task_id: string; status: string };
|
||||
setRows((current) =>
|
||||
current.map((row) =>
|
||||
row.run.id === data.run_id ? { ...row, run: { ...row.run, status: data.status as RunStatus } } : row,
|
||||
),
|
||||
);
|
||||
setSelected((current) =>
|
||||
current && current.id === data.run_id
|
||||
? { ...current, status: data.status as RunDetail["status"] }
|
||||
: current,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case "audit.recorded": {
|
||||
const data = event.data as { action: string; target_type: string; target_id: string };
|
||||
setAudit((current) => [
|
||||
{
|
||||
id: event.id,
|
||||
action: data.action,
|
||||
actor_user_id: null,
|
||||
target_type: data.target_type,
|
||||
target_id: data.target_id,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
...current,
|
||||
]);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { RealtimeProvider } from "@/lib/realtime";
|
||||
import { SessionProvider } from "@/lib/session";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -11,7 +12,10 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className="dark">
|
||||
<body>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
<SessionProvider>
|
||||
{/* One socket for the whole app: every live feed rides on it. */}
|
||||
<RealtimeProvider>{children}</RealtimeProvider>
|
||||
</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { api } from "@/lib/api";
|
||||
import { CommandPalette } from "@/components/CommandPalette";
|
||||
import { NAV_ITEMS } from "@/lib/nav";
|
||||
import { APP_VERSION_LABEL } from "@/lib/version";
|
||||
import { useRealtime } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/**
|
||||
@@ -38,6 +39,38 @@ function VersionBadge() {
|
||||
);
|
||||
}
|
||||
|
||||
/** A tiny dot that tells the user whether the live feed is connected. */
|
||||
function LiveIndicator() {
|
||||
const { status } = useRealtime();
|
||||
const tone =
|
||||
status === "live"
|
||||
? "success"
|
||||
: status === "connecting" || status === "reconnecting"
|
||||
? "warning"
|
||||
: "neutral";
|
||||
const label =
|
||||
status === "live"
|
||||
? "زنده"
|
||||
: status === "connecting"
|
||||
? "در حال اتصال…"
|
||||
: status === "reconnecting"
|
||||
? "در حال اتصال مجدد…"
|
||||
: "آفلاین";
|
||||
return (
|
||||
<span className="flex items-center gap-1.5" title={label}>
|
||||
<span
|
||||
className={cx(
|
||||
"inline-block h-1.5 w-1.5 rounded-full",
|
||||
tone === "success" && "bg-emerald-400",
|
||||
tone === "warning" && "bg-amber-400 animate-pulse",
|
||||
tone === "neutral" && "bg-slate-500",
|
||||
)}
|
||||
/>
|
||||
<span className="text-[10px] text-slate-500">{label}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Credit badge — reads the organization's real balance, never a placeholder. */
|
||||
function CreditBadge({ organizationId }: { organizationId: string | undefined }) {
|
||||
const [balance, setBalance] = React.useState<number | null>(null);
|
||||
@@ -195,6 +228,9 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
||||
<span className="hidden sm:inline-flex">
|
||||
<CreditBadge organizationId={activeOrganization?.id} />
|
||||
</span>
|
||||
<span className="hidden sm:inline-flex">
|
||||
<LiveIndicator />
|
||||
</span>
|
||||
<CommandPalette isPlatformOwner={user?.is_platform_owner} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
Stat,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
|
||||
const STATUS_TONES = {
|
||||
ready: "success",
|
||||
@@ -316,6 +317,53 @@ function SessionPanel({
|
||||
const [snapshotName, setSnapshotName] = React.useState("");
|
||||
const [snapshotBusy, setSnapshotBusy] = React.useState(false);
|
||||
|
||||
// Live feed: command output and session lifecycle events push live.
|
||||
useLiveChannel(
|
||||
[`session:${session.id}`],
|
||||
(event) => {
|
||||
switch (event.type) {
|
||||
case "sandbox.command": {
|
||||
const data = event.data as {
|
||||
run_id: string;
|
||||
command: string;
|
||||
status: string;
|
||||
exit_code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
duration_ms: number;
|
||||
truncated: boolean;
|
||||
};
|
||||
setRuns((current) => {
|
||||
if (current.some((r) => r.id === data.run_id)) return current;
|
||||
return [
|
||||
...current,
|
||||
{
|
||||
id: data.run_id,
|
||||
command: data.command,
|
||||
exit_code: data.exit_code,
|
||||
stdout: data.stdout,
|
||||
stderr: data.stderr,
|
||||
duration_ms: data.duration_ms,
|
||||
truncated: data.truncated,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "sandbox.session": {
|
||||
const data = event.data as { session_id: string; status: string };
|
||||
if (data.session_id === session.id) {
|
||||
onSessionChange({ ...session, status: data.status });
|
||||
}
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const [previewPort, setPreviewPort] = React.useState("8080");
|
||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
||||
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Realtime session state.
|
||||
*
|
||||
* The provider owns the one socket for the whole app and exposes two hooks:
|
||||
*
|
||||
* * ``useRealtime()`` — the connection status (for the live indicator) and
|
||||
* the subscribe primitives, for the rare component that needs raw access.
|
||||
* * ``useLiveChannel(channels, onEvent)`` — what every page actually uses:
|
||||
* subscribe for as long as the component is mounted or the channels change,
|
||||
* and keep the callback stable so an inline arrow does not resubscribe on
|
||||
* every render.
|
||||
*
|
||||
* The socket follows the session: it connects once a token exists and drops
|
||||
* when the user logs out, so no page has to think about auth.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import type { WsEvent, WsEventType } from "@agy/contracts";
|
||||
import { getRealtimeClient, type RealtimeStatus } from "@/lib/ws";
|
||||
import { getStoredToken } from "@/lib/api";
|
||||
|
||||
interface RealtimeContextValue {
|
||||
status: RealtimeStatus;
|
||||
subscribe: (channels: string[]) => void;
|
||||
unsubscribe: (channels: string[]) => void;
|
||||
}
|
||||
|
||||
const RealtimeContext = React.createContext<RealtimeContextValue | null>(null);
|
||||
|
||||
export function RealtimeProvider({ children }: { children: React.ReactNode }) {
|
||||
const client = React.useMemo(() => getRealtimeClient(), []);
|
||||
const [status] = useRealtimeState(client);
|
||||
|
||||
// The connection follows the token: present -> live, gone -> closed.
|
||||
React.useEffect(() => {
|
||||
const connect = () => {
|
||||
if (getStoredToken()) client.connect();
|
||||
else client.disconnect();
|
||||
};
|
||||
connect();
|
||||
// ``storage`` fires when another tab logs in or out; polling covers the
|
||||
// same-tab case where the token is written and read within one tick.
|
||||
const interval = setInterval(connect, 2_000);
|
||||
window.addEventListener("storage", connect);
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener("storage", connect);
|
||||
client.disconnect();
|
||||
};
|
||||
}, [client]);
|
||||
|
||||
const value = React.useMemo<RealtimeContextValue>(
|
||||
() => ({
|
||||
status,
|
||||
subscribe: (channels) => client.subscribe(channels),
|
||||
unsubscribe: (channels) => client.unsubscribe(channels),
|
||||
}),
|
||||
[status, client],
|
||||
);
|
||||
|
||||
return <RealtimeContext.Provider value={value}>{children}</RealtimeContext.Provider>;
|
||||
}
|
||||
|
||||
function useRealtimeState(client: ReturnType<typeof getRealtimeClient>): [RealtimeStatus] {
|
||||
const [status, setStatus] = React.useState<RealtimeStatus>(() => client.getStatus());
|
||||
React.useEffect(() => client.onStatus(setStatus), [client]);
|
||||
return [status];
|
||||
}
|
||||
|
||||
export function useRealtime(): RealtimeContextValue {
|
||||
const value = React.useContext(RealtimeContext);
|
||||
// A missing provider is a wiring bug, not a runtime condition: fail loudly
|
||||
// in development rather than silently showing a page that never updates.
|
||||
if (!value) {
|
||||
throw new Error("useRealtime must be used inside <RealtimeProvider>");
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to channels for the lifetime of the component.
|
||||
*
|
||||
* ``onEvent`` is kept in a ref, so the subscription is not re-established
|
||||
* because the callback identity changed — the classic cause of a live feed
|
||||
* that resubscribes on every render and drops events in between.
|
||||
*/
|
||||
export function useLiveChannel(
|
||||
channels: string[],
|
||||
onEvent: (event: WsEvent) => void,
|
||||
): void {
|
||||
const client = React.useMemo(() => getRealtimeClient(), []);
|
||||
const handler = React.useRef(onEvent);
|
||||
React.useEffect(() => {
|
||||
handler.current = onEvent;
|
||||
}, [onEvent]);
|
||||
|
||||
// A stable key: the effect re-runs when the channel *set* changes, not when
|
||||
// a new array with the same contents is passed.
|
||||
const channelKey = channels.join("\u0000");
|
||||
|
||||
React.useEffect(() => {
|
||||
const active = channelKey ? channelKey.split("\u0000") : [];
|
||||
if (active.length === 0) return;
|
||||
client.subscribe(active);
|
||||
const off = client.onEvent((event) => handler.current(event));
|
||||
return () => {
|
||||
off();
|
||||
client.unsubscribe(active);
|
||||
};
|
||||
}, [client, channelKey]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to one event type only — the common case for a page that renders
|
||||
* exactly one live surface (a timeline, a terminal, a counter).
|
||||
*/
|
||||
export function useLiveEvent(
|
||||
channels: string[],
|
||||
type: WsEventType,
|
||||
onEvent: (event: WsEvent) => void,
|
||||
): void {
|
||||
const handler = React.useRef(onEvent);
|
||||
React.useEffect(() => {
|
||||
handler.current = onEvent;
|
||||
}, [onEvent]);
|
||||
|
||||
useLiveChannel(
|
||||
channels,
|
||||
React.useCallback((event: WsEvent) => {
|
||||
if (event.type === type) handler.current(event);
|
||||
}, [type]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* The realtime client — one WebSocket for the whole app.
|
||||
*
|
||||
* Design decisions worth knowing before editing:
|
||||
*
|
||||
* * **One socket, shared.** Pages subscribe and unsubscribe through
|
||||
* ``RealtimeProvider``; they never own a connection. A dashboard and a task
|
||||
* page both being live must not mean two sockets and two auth round-trips.
|
||||
* * **The token is a function, not a value.** ``getToken`` is read at connect
|
||||
* time, so a login/logout cycle in another tab reconnects correctly instead
|
||||
* of pushing on a dead credential.
|
||||
* * **Reconnect with backoff, capped.** Networks flap; a reconnect storm is
|
||||
* how a flapping network becomes an outage. The cap is deliberately low
|
||||
* enough that "live" feels live again within seconds.
|
||||
* * **Heartbeat.** A silent middlebox will happily hold a dead socket open
|
||||
* forever. A ping every ``HEARTBEAT_INTERVAL_MS`` plus a pong timeout moves
|
||||
* that failure from "the page quietly stops updating" to "the socket
|
||||
* reconnects and updates resume".
|
||||
*/
|
||||
|
||||
import type { WsClientFrame, WsEvent, WsServerFrame } from "@agy/contracts";
|
||||
import { getStoredToken } from "@/lib/api";
|
||||
|
||||
/** Where the socket lives: the API origin, upgraded from http(s) to ws(s). */
|
||||
export function websocketUrl(path = "/api/ws"): string {
|
||||
const base =
|
||||
process.env.NEXT_PUBLIC_API_URL ??
|
||||
(typeof window !== "undefined" ? window.location.origin : "http://localhost:8000");
|
||||
const url = new URL(base);
|
||||
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
||||
url.pathname = path;
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export type RealtimeStatus = "idle" | "connecting" | "live" | "reconnecting" | "offline";
|
||||
|
||||
const MIN_BACKOFF_MS = 500;
|
||||
const MAX_BACKOFF_MS = 8_000;
|
||||
const HEARTBEAT_INTERVAL_MS = 25_000;
|
||||
/** If no pong arrives within this window the socket is considered dead. */
|
||||
const PONG_TIMEOUT_MS = 10_000;
|
||||
|
||||
type EventListener = (event: WsEvent) => void;
|
||||
type StatusListener = (status: RealtimeStatus) => void;
|
||||
|
||||
export class RealtimeClient {
|
||||
private socket: WebSocket | null = null;
|
||||
private status: RealtimeStatus = "idle";
|
||||
private readonly listeners = new Set<EventListener>();
|
||||
private readonly statusListeners = new Set<StatusListener>();
|
||||
private readonly subscriptions = new Set<string>();
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pongTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private closedByUs = false;
|
||||
|
||||
constructor(private readonly getToken: () => string | null = getStoredToken) {}
|
||||
|
||||
// -- lifecycle -----------------------------------------------------------
|
||||
|
||||
connect(): void {
|
||||
const token = this.getToken();
|
||||
if (!token) {
|
||||
// No session yet: nothing to push. The provider reconnects on login.
|
||||
this.setStatus("idle");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.socket &&
|
||||
(this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.closedByUs = false;
|
||||
this.setStatus(this.reconnectAttempts > 0 ? "reconnecting" : "connecting");
|
||||
|
||||
const socket = new WebSocket(`${websocketUrl()}?token=${encodeURIComponent(token)}`);
|
||||
this.socket = socket;
|
||||
|
||||
socket.onopen = () => {
|
||||
this.reconnectAttempts = 0;
|
||||
this.setStatus("live");
|
||||
this.startHeartbeat();
|
||||
// Re-subscribe: a new socket knows nothing about what this client cared
|
||||
// about, and pages do not (and should not) re-run their effects to fix it.
|
||||
if (this.subscriptions.size > 0) {
|
||||
this.send({ action: "subscribe", channels: [...this.subscriptions] });
|
||||
}
|
||||
};
|
||||
|
||||
socket.onmessage = (message) => this.handleFrame(message.data);
|
||||
|
||||
socket.onclose = () => {
|
||||
this.stopHeartbeat();
|
||||
this.socket = null;
|
||||
if (this.closedByUs) return;
|
||||
this.scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = () => {
|
||||
// A close always follows an error; the close handler does the work.
|
||||
// This only stops an unhandled rejection in stricter runtimes.
|
||||
};
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.closedByUs = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.stopHeartbeat();
|
||||
this.reconnectAttempts = 0;
|
||||
if (this.socket) {
|
||||
this.socket.close();
|
||||
this.socket = null;
|
||||
}
|
||||
this.setStatus("idle");
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
this.setStatus("reconnecting");
|
||||
const delay = Math.min(
|
||||
MAX_BACKOFF_MS,
|
||||
MIN_BACKOFF_MS * 2 ** this.reconnectAttempts,
|
||||
);
|
||||
this.reconnectAttempts += 1;
|
||||
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
// -- subscriptions ---------------------------------------------------------
|
||||
|
||||
subscribe(channels: string[]): void {
|
||||
const fresh = channels.filter((channel) => !this.subscriptions.has(channel));
|
||||
if (fresh.length === 0) return;
|
||||
fresh.forEach((channel) => this.subscriptions.add(channel));
|
||||
this.send({ action: "subscribe", channels: fresh });
|
||||
}
|
||||
|
||||
unsubscribe(channels: string[]): void {
|
||||
const active = channels.filter((channel) => this.subscriptions.has(channel));
|
||||
if (active.length === 0) return;
|
||||
active.forEach((channel) => this.subscriptions.delete(channel));
|
||||
this.send({ action: "unsubscribe", channels: active });
|
||||
}
|
||||
|
||||
activeSubscriptions(): string[] {
|
||||
return [...this.subscriptions];
|
||||
}
|
||||
|
||||
// -- listeners --------------------------------------------------------------
|
||||
|
||||
onEvent(listener: EventListener): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
onStatus(listener: StatusListener): () => void {
|
||||
this.statusListeners.add(listener);
|
||||
return () => this.statusListeners.delete(listener);
|
||||
}
|
||||
|
||||
getStatus(): RealtimeStatus {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
// -- frames -----------------------------------------------------------------
|
||||
|
||||
private send(frame: WsClientFrame): void {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(frame));
|
||||
}
|
||||
// A frame sent before the socket is open is not an error: on open, the
|
||||
// full subscription set is replayed anyway.
|
||||
}
|
||||
|
||||
private handleFrame(raw: unknown): void {
|
||||
if (typeof raw !== "string") return;
|
||||
let frame: WsServerFrame;
|
||||
try {
|
||||
frame = JSON.parse(raw) as WsServerFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (frame.type) {
|
||||
case "event":
|
||||
this.emitEvent(frame.event);
|
||||
break;
|
||||
case "pong":
|
||||
this.stopPongTimer();
|
||||
break;
|
||||
case "subscribed":
|
||||
// A rejected channel is a server answer, not a client error: the page
|
||||
// keeps its initial data and the console shows why it is not live.
|
||||
if (frame.rejected.length > 0) {
|
||||
console.warn("realtime: channels rejected by the server", frame.rejected);
|
||||
}
|
||||
break;
|
||||
case "error":
|
||||
console.warn("realtime: server error frame", frame.message);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private emitEvent(event: WsEvent): void {
|
||||
for (const listener of [...this.listeners]) {
|
||||
try {
|
||||
listener(event);
|
||||
} catch (error) {
|
||||
// One broken page must not silence every other page's feed.
|
||||
console.error("realtime: event listener failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setStatus(status: RealtimeStatus): void {
|
||||
if (this.status === status) return;
|
||||
this.status = status;
|
||||
for (const listener of [...this.statusListeners]) {
|
||||
try {
|
||||
listener(status);
|
||||
} catch (error) {
|
||||
console.error("realtime: status listener failed", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -- heartbeat -----------------------------------------------------------------
|
||||
|
||||
private startHeartbeat(): void {
|
||||
this.stopHeartbeat();
|
||||
this.heartbeatTimer = setInterval(() => {
|
||||
this.send({ action: "ping" });
|
||||
this.pongTimer = setTimeout(() => {
|
||||
// No pong: the socket is a zombie. Close it so the reconnect path runs.
|
||||
this.socket?.close();
|
||||
}, PONG_TIMEOUT_MS);
|
||||
}, HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatTimer) {
|
||||
clearInterval(this.heartbeatTimer);
|
||||
this.heartbeatTimer = null;
|
||||
}
|
||||
this.stopPongTimer();
|
||||
}
|
||||
|
||||
private stopPongTimer(): void {
|
||||
if (this.pongTimer) {
|
||||
clearTimeout(this.pongTimer);
|
||||
this.pongTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedClient: RealtimeClient | null = null;
|
||||
|
||||
/** The process-wide client the provider owns. */
|
||||
export function getRealtimeClient(): RealtimeClient {
|
||||
if (!sharedClient) {
|
||||
sharedClient = new RealtimeClient();
|
||||
}
|
||||
return sharedClient;
|
||||
}
|
||||
Reference in New Issue
Block a user