feat(web): wire the SPA to the live bus via the gateway (drop mock)
Replace the mock data source with a real data layer that talks to the webgw gateway over REST + SSE. The UI components keep their look and props; only where the data comes from changed. - src/api.ts: the single repository layer. fetch wrappers (same-origin cookie) for login/logout/me and rooms list/create/join/send, plus streamRoom() which opens an EventSource and yields each decrypted message. Wire->UI mappers (roomFromWire, messageFromWire). - src/types.ts: add the gateway wire shapes (MeInfo, RoomWire, MsgWire) next to the existing UI types. - App.tsx: probe /api/me on mount to resume an existing session; otherwise show Login. Logout calls the gateway. - Login.tsx: the password field now unlocks the gateway session (operator passphrase); shows a basic error and a loading state. Wallet-per-browser is phase 2. - ChatShell.tsx: load rooms from /api/rooms with loading / empty / error states; same Flex layout. - ChatPanel.tsx: stream messages over SSE for the active room (dedup by id), composer sends through the gateway; no optimistic insert (the peer's own echo returns over SSE with the real frame id). - vite.config.ts: dev proxy /api (REST + SSE) -> the gateway on :8481. mock.ts is left untouched (no longer imported) to avoid churn with the parallel styling work on master. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+35
-2
@@ -1,11 +1,44 @@
|
|||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { Center, Loader } from "@mantine/core";
|
||||||
import { Login } from "./Login";
|
import { Login } from "./Login";
|
||||||
import { ChatShell } from "./ChatShell";
|
import { ChatShell } from "./ChatShell";
|
||||||
|
import { api } from "./api";
|
||||||
import type { User } from "./types";
|
import type { User } from "./types";
|
||||||
|
|
||||||
|
// shortEndpoint hace legible el endpoint id del operador para mostrarlo como
|
||||||
|
// handle por defecto cuando no se escribió uno en el login.
|
||||||
|
function shortEndpoint(ep: string) {
|
||||||
|
return ep.slice(0, 8);
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
|
const [checking, setChecking] = useState(true);
|
||||||
|
|
||||||
|
// Al montar, comprueba si ya hay una sesión viva en el gateway (cookie). Si la
|
||||||
|
// hay, entra directo; si no (401), muestra el login.
|
||||||
|
useEffect(() => {
|
||||||
|
api
|
||||||
|
.me()
|
||||||
|
.then((me) =>
|
||||||
|
setUser({ id: me.endpoint, handle: shortEndpoint(me.endpoint) }),
|
||||||
|
)
|
||||||
|
.catch(() => {})
|
||||||
|
.finally(() => setChecking(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = () => {
|
||||||
|
void api.logout().catch(() => {});
|
||||||
|
setUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (checking) {
|
||||||
|
return (
|
||||||
|
<Center h="100vh" bg="dark.9">
|
||||||
|
<Loader color="brand" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!user) return <Login onLogin={setUser} />;
|
if (!user) return <Login onLogin={setUser} />;
|
||||||
return <ChatShell user={user} onLogout={() => setUser(null)} />;
|
return <ChatShell user={user} onLogout={logout} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-23
@@ -19,7 +19,8 @@ import {
|
|||||||
IconDotsVertical,
|
IconDotsVertical,
|
||||||
IconPaperclip,
|
IconPaperclip,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
import type { Message, Room, User } from "./types";
|
import { api, streamRoom } from "./api";
|
||||||
|
import type { Message, Room } from "./types";
|
||||||
|
|
||||||
function initials(s: string) {
|
function initials(s: string) {
|
||||||
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
return s.replace(/[^a-z0-9]/gi, "").slice(0, 2).toUpperCase() || "?";
|
||||||
@@ -54,22 +55,30 @@ function MessageRow({ msg }: { msg: Message }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({
|
export function ChatPanel({ room }: { room: Room | undefined }) {
|
||||||
room,
|
|
||||||
user,
|
|
||||||
}: {
|
|
||||||
room: Room | undefined;
|
|
||||||
user: User;
|
|
||||||
}) {
|
|
||||||
const [draft, setDraft] = useState("");
|
const [draft, setDraft] = useState("");
|
||||||
const [extra, setExtra] = useState<Record<string, Message[]>>({});
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [sendError, setSendError] = useState<string | null>(null);
|
||||||
const viewport = useRef<HTMLDivElement>(null);
|
const viewport = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const msgs = room ? [...room.messages, ...(extra[room.id] ?? [])] : [];
|
// Abre el stream SSE de la room activa. El gateway entrega historia (rooms
|
||||||
|
// persistidas) y luego mensajes en vivo, ya descifrados. Dedup por id porque
|
||||||
|
// un re-render no debe duplicar y el eco del propio envío llega por aquí.
|
||||||
|
useEffect(() => {
|
||||||
|
setMessages([]);
|
||||||
|
setSendError(null);
|
||||||
|
if (!room) return;
|
||||||
|
const close = streamRoom(room.id, (m) => {
|
||||||
|
setMessages((prev) =>
|
||||||
|
prev.some((p) => p.id === m.id) ? prev : [...prev, m],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return close;
|
||||||
|
}, [room?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
viewport.current?.scrollTo({ top: viewport.current.scrollHeight });
|
||||||
}, [room?.id, msgs.length]);
|
}, [room?.id, messages.length]);
|
||||||
|
|
||||||
if (!room) {
|
if (!room) {
|
||||||
return (
|
return (
|
||||||
@@ -79,18 +88,19 @@ export function ChatPanel({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const send = () => {
|
const send = async () => {
|
||||||
const body = draft.trim();
|
const body = draft.trim();
|
||||||
if (!body) return;
|
if (!body) return;
|
||||||
const msg: Message = {
|
|
||||||
id: `local-${Date.now()}`,
|
|
||||||
sender: user.handle,
|
|
||||||
body,
|
|
||||||
ts: Date.now(),
|
|
||||||
mine: true,
|
|
||||||
};
|
|
||||||
setExtra((e) => ({ ...e, [room.id]: [...(e[room.id] ?? []), msg] }));
|
|
||||||
setDraft("");
|
setDraft("");
|
||||||
|
setSendError(null);
|
||||||
|
try {
|
||||||
|
// No optimista: el mensaje propio vuelve por SSE con su id real (mine:true),
|
||||||
|
// evitando duplicados.
|
||||||
|
await api.send(room.id, body);
|
||||||
|
} catch (e) {
|
||||||
|
setDraft(body); // restaura el borrador si el envío falló
|
||||||
|
setSendError(e instanceof Error ? e.message : "No se pudo enviar");
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -126,13 +136,18 @@ export function ChatPanel({
|
|||||||
|
|
||||||
<ScrollArea style={{ flex: 1 }} viewportRef={viewport}>
|
<ScrollArea style={{ flex: 1 }} viewportRef={viewport}>
|
||||||
<Stack gap="lg" p="md">
|
<Stack gap="lg" p="md">
|
||||||
{msgs.map((m) => (
|
{messages.map((m) => (
|
||||||
<MessageRow key={m.id} msg={m} />
|
<MessageRow key={m.id} msg={m} />
|
||||||
))}
|
))}
|
||||||
</Stack>
|
</Stack>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<Divider color="dark.4" />
|
<Divider color="dark.4" />
|
||||||
|
{sendError && (
|
||||||
|
<Text c="red" size="xs" px="sm" pt={4}>
|
||||||
|
{sendError}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
<Group p="sm" gap="xs" wrap="nowrap">
|
<Group p="sm" gap="xs" wrap="nowrap">
|
||||||
<ActionIcon variant="subtle" color="gray" size="lg">
|
<ActionIcon variant="subtle" color="gray" size="lg">
|
||||||
<IconPaperclip size={18} />
|
<IconPaperclip size={18} />
|
||||||
@@ -143,14 +158,14 @@ export function ChatPanel({
|
|||||||
placeholder={`Mensaje a ${room.name}`}
|
placeholder={`Mensaje a ${room.name}`}
|
||||||
value={draft}
|
value={draft}
|
||||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && send()}
|
onKeyDown={(e) => e.key === "Enter" && void send()}
|
||||||
/>
|
/>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
size="lg"
|
size="lg"
|
||||||
radius="xl"
|
radius="xl"
|
||||||
variant="filled"
|
variant="filled"
|
||||||
color="brand"
|
color="brand"
|
||||||
onClick={send}
|
onClick={() => void send()}
|
||||||
disabled={!draft.trim()}
|
disabled={!draft.trim()}
|
||||||
>
|
>
|
||||||
<IconSend size={18} />
|
<IconSend size={18} />
|
||||||
|
|||||||
+56
-7
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { Flex, Box } from "@mantine/core";
|
import { Flex, Box, Center, Loader, Stack, Text, Button } from "@mantine/core";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
import { ChatPanel } from "./ChatPanel";
|
import { ChatPanel } from "./ChatPanel";
|
||||||
import { MOCK_ROOMS } from "./mock";
|
import { api } from "./api";
|
||||||
import type { User } from "./types";
|
import type { Room, User } from "./types";
|
||||||
|
|
||||||
export function ChatShell({
|
export function ChatShell({
|
||||||
user,
|
user,
|
||||||
@@ -12,10 +12,59 @@ export function ChatShell({
|
|||||||
user: User;
|
user: User;
|
||||||
onLogout: () => void;
|
onLogout: () => void;
|
||||||
}) {
|
}) {
|
||||||
const [rooms] = useState(MOCK_ROOMS);
|
const [rooms, setRooms] = useState<Room[]>([]);
|
||||||
const [activeId, setActiveId] = useState<string>(rooms[0]?.id ?? "");
|
const [activeId, setActiveId] = useState<string>("");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
api
|
||||||
|
.listRooms()
|
||||||
|
.then((rs) => {
|
||||||
|
setRooms(rs);
|
||||||
|
setActiveId((cur) => cur || rs[0]?.id || "");
|
||||||
|
setError(null);
|
||||||
|
})
|
||||||
|
.catch((e) => setError(e?.message ?? "No se pudieron cargar las rooms"))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
const active = rooms.find((r) => r.id === activeId);
|
const active = rooms.find((r) => r.id === activeId);
|
||||||
|
|
||||||
|
// El panel derecho muestra el estado de carga/error/empty sin tocar el layout.
|
||||||
|
let panel = <ChatPanel room={active} />;
|
||||||
|
if (loading && rooms.length === 0) {
|
||||||
|
panel = (
|
||||||
|
<Center h="100%">
|
||||||
|
<Loader color="brand" />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
} else if (error) {
|
||||||
|
panel = (
|
||||||
|
<Center h="100%">
|
||||||
|
<Stack align="center" gap="sm">
|
||||||
|
<Text c="red" size="sm">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
<Button variant="light" color="brand" onClick={load}>
|
||||||
|
Reintentar
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
} else if (rooms.length === 0) {
|
||||||
|
panel = (
|
||||||
|
<Center h="100%">
|
||||||
|
<Text c="dimmed">No perteneces a ninguna room todavía</Text>
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex h="100vh" w="100vw" style={{ overflow: "hidden" }}>
|
<Flex h="100vh" w="100vw" style={{ overflow: "hidden" }}>
|
||||||
<Box
|
<Box
|
||||||
@@ -36,7 +85,7 @@ export function ChatShell({
|
|||||||
/>
|
/>
|
||||||
</Box>
|
</Box>
|
||||||
<Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}>
|
<Box flex={1} h="100%" bg="dark.7" style={{ minWidth: 0 }}>
|
||||||
<ChatPanel room={active} user={user} />
|
{panel}
|
||||||
</Box>
|
</Box>
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
|
|||||||
+30
-5
@@ -11,15 +11,29 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import { IconShieldLock, IconKey } from "@tabler/icons-react";
|
import { IconShieldLock, IconKey } from "@tabler/icons-react";
|
||||||
|
import { api, ApiError } from "./api";
|
||||||
import type { User } from "./types";
|
import type { User } from "./types";
|
||||||
|
|
||||||
export function Login({ onLogin }: { onLogin: (u: User) => void }) {
|
export function Login({ onLogin }: { onLogin: (u: User) => void }) {
|
||||||
const [handle, setHandle] = useState("");
|
const [handle, setHandle] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
const ready = handle.trim().length > 0 && password.length > 0;
|
const ready = handle.trim().length > 0 && password.length > 0;
|
||||||
const connect = () => {
|
const connect = async () => {
|
||||||
const h = handle.trim();
|
if (!ready || busy) return;
|
||||||
if (ready) onLogin({ id: h, handle: h });
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// La contraseña desbloquea la sesión del gateway (passphrase del operador).
|
||||||
|
// El handle es solo el nombre a mostrar en esta iteración (wallet = fase 2).
|
||||||
|
const me = await api.login(password);
|
||||||
|
const h = handle.trim() || me.endpoint.slice(0, 8);
|
||||||
|
onLogin({ id: me.endpoint, handle: h });
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof ApiError ? e.message : "No se pudo conectar al gateway");
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -52,9 +66,20 @@ export function Login({ onLogin }: { onLogin: (u: User) => void }) {
|
|||||||
leftSection={<IconKey size={16} />}
|
leftSection={<IconKey size={16} />}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.currentTarget.value)}
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && connect()}
|
onKeyDown={(e) => e.key === "Enter" && void connect()}
|
||||||
/>
|
/>
|
||||||
<Button w="100%" size="md" onClick={connect} disabled={!ready}>
|
{error && (
|
||||||
|
<Text c="red" size="sm" ta="center">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
w="100%"
|
||||||
|
size="md"
|
||||||
|
onClick={() => void connect()}
|
||||||
|
disabled={!ready}
|
||||||
|
loading={busy}
|
||||||
|
>
|
||||||
Conectar
|
Conectar
|
||||||
</Button>
|
</Button>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
+131
@@ -0,0 +1,131 @@
|
|||||||
|
// La única capa por la que la SPA habla con el bus. Cada llamada va al gateway Go
|
||||||
|
// bajo /api; el gateway mantiene la sesión `pkg/client` (peer autenticado del
|
||||||
|
// bus), cifra/descifra por room y traduce a REST/SSE. El navegador nunca firma,
|
||||||
|
// nunca habla NATS y nunca ve una clave privada: solo guarda una cookie de
|
||||||
|
// sesión opaca (HttpOnly) que el gateway emite tras el login.
|
||||||
|
import type { MeInfo, Message, MsgWire, Room, RoomWire } from "./types";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
status: number;
|
||||||
|
constructor(message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function req<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
// same-origin envía la cookie de sesión automáticamente (también detrás del
|
||||||
|
// proxy de vite en dev).
|
||||||
|
credentials: "same-origin",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
...init,
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
let body: unknown = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
body = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
body = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
body && typeof body === "object" && "error" in body
|
||||||
|
? String((body as { error: unknown }).error)
|
||||||
|
: `HTTP ${res.status}`;
|
||||||
|
throw new ApiError(msg, res.status);
|
||||||
|
}
|
||||||
|
return body as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
// roomFromWire mapea la fila del gateway al tipo Room que consume la UI. Los
|
||||||
|
// mensajes NO viven aquí: llegan por stream(). lastMessage/lastTs/unread se
|
||||||
|
// rellenan de forma neutra para no inventar datos (la cabecera de la sidebar se
|
||||||
|
// alimentará del stream en una iteración futura).
|
||||||
|
export function roomFromWire(r: RoomWire): Room {
|
||||||
|
return {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name || r.subject,
|
||||||
|
encrypted: r.encrypt,
|
||||||
|
lastMessage: "",
|
||||||
|
lastTs: 0,
|
||||||
|
unread: 0,
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// messageFromWire mapea un frame descifrado del SSE al tipo Message de la UI.
|
||||||
|
export function messageFromWire(m: MsgWire): Message {
|
||||||
|
return {
|
||||||
|
id: m.id,
|
||||||
|
sender: m.sender,
|
||||||
|
body: m.body,
|
||||||
|
ts: m.ts,
|
||||||
|
mine: m.mine,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// ---- sesión -------------------------------------------------------------
|
||||||
|
// login desbloquea la sesión del gateway con la passphrase del operador. El
|
||||||
|
// gateway responde con una cookie de sesión; me() comprueba si ya hay una.
|
||||||
|
login: (passphrase: string) =>
|
||||||
|
req<MeInfo>("/api/login", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ passphrase }),
|
||||||
|
}),
|
||||||
|
logout: () => req<{ status: string }>("/api/logout", { method: "POST" }),
|
||||||
|
me: () => req<MeInfo>("/api/me"),
|
||||||
|
|
||||||
|
// ---- rooms --------------------------------------------------------------
|
||||||
|
listRooms: async (): Promise<Room[]> => {
|
||||||
|
const wire = await req<RoomWire[]>("/api/rooms");
|
||||||
|
return wire.map(roomFromWire);
|
||||||
|
},
|
||||||
|
// createRoom: {subject, encrypted} basta — el gateway deriva la policy
|
||||||
|
// Matrix-like (cifrada + persistida + firmada) por defecto.
|
||||||
|
createRoom: async (subject: string, encrypted = true): Promise<Room> => {
|
||||||
|
const r = await req<RoomWire>("/api/rooms", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ subject, encrypted }),
|
||||||
|
});
|
||||||
|
return roomFromWire(r);
|
||||||
|
},
|
||||||
|
join: (roomID: string) =>
|
||||||
|
req<{ status: string }>(
|
||||||
|
`/api/rooms/${encodeURIComponent(roomID)}/join`,
|
||||||
|
{ method: "POST" },
|
||||||
|
),
|
||||||
|
send: (roomID: string, body: string) =>
|
||||||
|
req<{ status: string }>(
|
||||||
|
`/api/rooms/${encodeURIComponent(roomID)}/send`,
|
||||||
|
{ method: "POST", body: JSON.stringify({ body }) },
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
// streamRoom abre el SSE de una room y llama onMessage por cada frame descifrado
|
||||||
|
// (historia primero en rooms persistidas, luego en vivo). Devuelve una función
|
||||||
|
// de cierre. EventSource manda la cookie de sesión automáticamente y reconecta
|
||||||
|
// solo si la conexión cae; onError se invoca en cada corte para que la UI pueda
|
||||||
|
// reflejar el estado.
|
||||||
|
export function streamRoom(
|
||||||
|
roomID: string,
|
||||||
|
onMessage: (m: Message) => void,
|
||||||
|
onError?: (e: Event) => void,
|
||||||
|
): () => void {
|
||||||
|
const es = new EventSource(
|
||||||
|
`/api/rooms/${encodeURIComponent(roomID)}/stream`,
|
||||||
|
);
|
||||||
|
es.onmessage = (ev) => {
|
||||||
|
try {
|
||||||
|
const wire = JSON.parse(ev.data) as MsgWire;
|
||||||
|
onMessage(messageFromWire(wire));
|
||||||
|
} catch {
|
||||||
|
// frame malformado: se ignora, el stream sigue.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (onError) es.onerror = onError;
|
||||||
|
return () => es.close();
|
||||||
|
}
|
||||||
+33
-3
@@ -1,5 +1,5 @@
|
|||||||
// Tipos de dominio de la UI. En la iteración 1 se llenan con datos mock;
|
// Tipos de dominio de la UI. Los datos vienen del gateway Go (REST/SSE), que es
|
||||||
// más adelante vendrán del gateway (REST/SSE) que es un peer del bus.
|
// un peer autenticado del bus. El navegador nunca firma ni habla NATS.
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -8,7 +8,7 @@ export interface User {
|
|||||||
|
|
||||||
export interface Message {
|
export interface Message {
|
||||||
id: string;
|
id: string;
|
||||||
sender: string; // handle
|
sender: string; // endpoint id del remitente (handle legible es fase 2)
|
||||||
body: string;
|
body: string;
|
||||||
ts: number; // epoch ms
|
ts: number; // epoch ms
|
||||||
mine?: boolean;
|
mine?: boolean;
|
||||||
@@ -23,3 +23,33 @@ export interface Room {
|
|||||||
unread: number;
|
unread: number;
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- formas de la API del gateway (wire) ---------------------------------
|
||||||
|
|
||||||
|
// MeInfo es la identidad del operador que el gateway encarna (GET /api/me).
|
||||||
|
export interface MeInfo {
|
||||||
|
endpoint: string;
|
||||||
|
sign_pub: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// RoomWire es la fila de room que devuelve el gateway (GET /api/rooms). No trae
|
||||||
|
// mensajes: estos llegan por SSE (GET /api/rooms/{id}/stream).
|
||||||
|
export interface RoomWire {
|
||||||
|
id: string;
|
||||||
|
subject: string;
|
||||||
|
name: string;
|
||||||
|
epoch: number;
|
||||||
|
encrypt: boolean;
|
||||||
|
persist: boolean;
|
||||||
|
sign_msgs: boolean;
|
||||||
|
role: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MsgWire es un mensaje ya descifrado que el gateway empuja por SSE.
|
||||||
|
export interface MsgWire {
|
||||||
|
id: string;
|
||||||
|
sender: string;
|
||||||
|
body: string;
|
||||||
|
ts: number;
|
||||||
|
mine: boolean;
|
||||||
|
}
|
||||||
|
|||||||
+8
-1
@@ -3,5 +3,12 @@ import react from "@vitejs/plugin-react";
|
|||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
server: { host: true, port: 5181 },
|
// En dev, /api (REST + SSE) se proxea al gateway Go (cmd/webgw, puerto 8481).
|
||||||
|
// El proxy hace streaming, así que el SSE de /api/rooms/{id}/stream funciona a
|
||||||
|
// través de él. En producción el gateway sirve el dist embebido y no hay proxy.
|
||||||
|
server: {
|
||||||
|
host: true,
|
||||||
|
port: 5181,
|
||||||
|
proxy: { "/api": "http://127.0.0.1:8481" },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user