drop/server/composables/community-chat.ts
wdunn001 dcf6507af5
Some checks failed
Server CI / Typecheck (push) Failing after 6m26s
Server CI / Lint (push) Failing after 6m32s
community/chat: register a websocket error handler
WebSocketHandler throws on the literal "unauthenticated" server response
when no .error() handler is registered, before its listen() callback ever
runs -- and since this composable connects at module scope (Nuxt bundles
auto-imported composables into a shared chunk), that happened on every
signed-out page, /auth/signin included. Found live: a browser check of
/auth/signin hung on an uncaught exception from this exact path.
2026-08-03 01:55:23 -04:00

84 lines
3.2 KiB
TypeScript

// Thin wrapper around the house WebSocketHandler (composables/ws.ts, same
// class server/composables/notifications.ts already uses) for the
// community chat/presence socket. WebSocketHandler only gives a single
// raw-text listen() callback and a send(); this adds envelope parsing
// ({t, d}, matching server/api/v1/community/ws.get.ts's protocol) and a
// pub/sub layer on TOP of that one connection, so multiple components
// (room list unread badges, an open chat pane, a friends-online widget) can
// each listen for just the message types they care about without stepping
// on each other.
export interface ChatEnvelope<T = unknown> {
t: string;
d: T;
}
export type ChatEnvelopeListener = (envelope: ChatEnvelope) => void;
const socket = new WebSocketHandler("/api/v1/community/ws");
const listeners = new Set<ChatEnvelopeListener>();
const subscribedTopics = new Set<string>();
// WebSocketHandler (composables/ws.ts) connects the moment this module is
// evaluated -- which, since Nuxt bundles auto-imported composables into a
// shared chunk, can happen on pages the user isn't authenticated on yet
// (e.g. /auth/signin itself). Its own onmessage handler special-cases the
// literal string "unauthenticated" and THROWS if no .error() handler is
// registered, before ever reaching the listen() callback below -- an
// unregistered error handler here isn't a hypothetical, it's an uncaught
// exception on every signed-out page load. Swallow it: the socket will
// reconnect with real auth once the user actually signs in and this
// module's connection attempt (or a future one) runs again.
socket.error(() => {
// Expected and harmless while signed out; nothing to surface to the user.
});
socket.listen((raw) => {
let envelope: ChatEnvelope;
try {
envelope = JSON.parse(raw);
} catch {
return;
}
for (const listener of listeners) listener(envelope);
});
function send(t: string, d: unknown) {
socket.send(JSON.stringify({ t, d }));
}
export function useCommunityChatSocket() {
function on(listener: ChatEnvelopeListener) {
listeners.add(listener);
return () => listeners.delete(listener);
}
// Ref-counted-ish at the topic-string level: fine for this app's scale
// (a handful of rooms open across a few components at once), and simpler
// than real refcounting. Re-subscribing to an already-subscribed topic is
// a harmless no-op server-side.
function subscribe(topics: string[]) {
const fresh = topics.filter((t) => !subscribedTopics.has(t));
if (fresh.length === 0) return;
fresh.forEach((t) => subscribedTopics.add(t));
send("sub", { topics: fresh });
}
function unsubscribe(topics: string[]) {
topics.forEach((t) => subscribedTopics.delete(t));
send("unsub", { topics });
}
function sendChatMessage(roomId: string, body: string, clientNonce: string, replyToId?: string) {
send("chat.send", { roomId, body, clientNonce, replyToId });
}
function sendTyping(roomId: string) {
send("chat.typing", { roomId });
}
function markRead(roomId: string, lastReadMessageId: string) {
send("chat.read", { roomId, lastReadMessageId });
}
return { on, subscribe, unsubscribe, sendChatMessage, sendTyping, markRead };
}