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.
84 lines
3.2 KiB
TypeScript
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 };
|
|
}
|