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:
+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();
|
||||
}
|
||||
Reference in New Issue
Block a user