Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 39 additions & 4 deletions apps/website/src/api/ws.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
import type { InferRouteContract, getWsTicketRoute } from '@chatsift/api';
import type { InferRouteContract, getWsTicketRoute, publicAMAWsTicketRoute } from '@chatsift/api';
import { apiFetch } from './fetch';
import { REALTIME_CLIENT_ID } from './realtimeClientId';

type GetWsTicketContract = InferRouteContract<typeof getWsTicketRoute>;
type GetWsTicketResult = GetWsTicketContract['response'];

type PublicWsTicketContract = InferRouteContract<typeof publicAMAWsTicketRoute>;
type PublicWsTicketResult = PublicWsTicketContract['response'];

/**
* How a client obtains a fresh gateway ticket. Injected rather than hardcoded because the public answers page
* has no session to mint one from and goes through its own share-token endpoint instead (#323) -- everything
* else about the connection (backoff, re-subscribe, self-echo suppression) is identical between the two.
*/
type TicketMinter = () => Promise<string>;

interface ServerMessage {
channel: string;
type: 'invalidate';
Expand All @@ -31,7 +41,7 @@ function wsURL(): string {
* missed while disconnected, so a reconnect fires every still-subscribed channel's listeners once immediately,
* on top of re-sending `subscribe` for each of them server-side.
*/
class RealtimeClient {
export class RealtimeClient {
private readonly channels = new Map<string, Set<InvalidateListener>>();

private connecting = false;
Expand All @@ -42,6 +52,8 @@ class RealtimeClient {

private socket: WebSocket | null = null;

public constructor(private readonly mintTicket: TicketMinter) {}

public subscribe(channel: string, onInvalidate: InvalidateListener): () => void {
let listeners = this.channels.get(channel);
if (!listeners) {
Expand Down Expand Up @@ -74,7 +86,7 @@ class RealtimeClient {

void (async () => {
try {
const { ticket } = await apiFetch<GetWsTicketResult>('get', '/v3/ws/ticket');
const ticket = await this.mintTicket();
// `clientId` (same value `fetch.ts` sends as `RealtimeClientIdHeader` on every mutation, see
// `realtimeClientId.ts`) tags this specific socket so the server can skip echoing an invalidate
// signal back to the tab whose own mutation caused it.
Expand Down Expand Up @@ -208,4 +220,27 @@ class RealtimeClient {
}
}

export const realtimeClient = new RealtimeClient();
/**
* The session-backed client every authenticated page uses -- one socket per tab, shared across channels.
*/
export const realtimeClient = new RealtimeClient(
async () => (await apiFetch<GetWsTicketResult>('get', '/v3/ws/ticket')).ticket,
);

/**
* A client for the public answers page (#323), scoped to one share token. Deliberately *not* the singleton
* above: that one mints its ticket from the session, and this page is reachable (and normally read) with no
* session at all -- knowing the share token is the whole authorization, exactly as it is for the page's own
* data fetch.
*
* Constructing one is inert (the socket only opens on the first `subscribe`), but the instance still has to be
* stable across renders, since `useRealtimeInvalidate` re-subscribes when it changes -- callers should go
* through `usePublicRealtimeClient` rather than calling this in a render body.
*/
export function createPublicRealtimeClient(shareToken: string): RealtimeClient {
return new RealtimeClient(
async () =>
(await apiFetch<PublicWsTicketResult>('get', `/v3/ama/public/${encodeURIComponent(shareToken)}/ws-ticket`))
.ticket,
);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
'use client';

import { useQueryClient } from '@tanstack/react-query';
import { useParams } from 'next/navigation';
import { FaExclamationCircle } from 'react-icons/fa';
import { queryKeys } from '@/api/queryClient';
import type { PublicUserInfo } from '@/api/routes/ama';
import { usePublicAMAAnswers } from '@/api/routes/ama';
import { EmptyState } from '@/components/common/EmptyState';
import { GenericAvatar } from '@/components/common/GenericAvatar';
import { Skeleton } from '@/components/common/Skeleton';
import { usePublicRealtimeClient } from '@/hooks/usePublicRealtimeClient';
import { useRealtimeInvalidate } from '@/hooks/useRealtimeInvalidate';
import { formatDate } from '@/utils/util';

function PublicUserBadge({ user }: { readonly user: PublicUserInfo }) {
Expand All @@ -31,8 +35,22 @@ function PublicUserBadge({ user }: { readonly user: PublicUserInfo }) {
*/
export function PublicAnswers() {
const { shareToken } = useParams<{ shareToken: string }>();
const queryClient = useQueryClient();
const realtimeClient = usePublicRealtimeClient(shareToken);
const { data, isLoading, error } = usePublicAMAAnswers(shareToken);

// Live updates for this page too (#323) -- someone watching along while an AMA runs shouldn't have to
// refresh to see the next answer land. The channel comes off the response rather than being built here:
// `amaPublicAnswersChannel` needs the ama id, and a share-token viewer never learns it. Undefined until
// the first fetch resolves, which the hook already no-ops on.
useRealtimeInvalidate(
data?.realtimeChannel,
() => {
void queryClient.invalidateQueries({ queryKey: queryKeys.ama.publicAnswers(shareToken) });
},
realtimeClient,
);
Comment thread
didinele marked this conversation as resolved.

if (isLoading) {
return (
<div className="space-y-4">
Expand Down
16 changes: 9 additions & 7 deletions apps/website/src/app/dashboard/_components/GuildList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,16 @@ export function GuildList() {
}

const filtered = visible.filter((entry) => entry.guild.name.toLowerCase().includes(lower));
// Managed servers first, then guest-only ones -- guest access is a much narrower thing (one or two
// sessions someone added you to) and shouldn't outrank a server you actually run. `sortGuilds` works on
// bare `MeGuild`s, so the tier flags are re-attached by id afterwards.
// One pass over everything, guest-only guilds included (#321). This used to sort managed servers and
// guest ones into separate buckets and concat them, which parked guest cards at the very end of a long
// list -- for someone who was invited specifically to answer an AMA, that's the one card they came for.
// Nothing is lost by interleaving: each card carries a `Guest` badge and the banner below explains them.
// `sortGuilds` works on bare `MeGuild`s, so the tier flags are re-attached by id afterwards.
const byId = new Map(filtered.map((entry) => [entry.guild.id, entry]));
return [
...sortGuilds(filtered.filter((entry) => entry.canManage).map((entry) => entry.guild)),
...sortGuilds(filtered.filter((entry) => !entry.canManage).map((entry) => entry.guild)),
].map((guild) => ({ guild, isAmaGuestOnly: byId.get(guild.id)!.isAmaGuestOnly }));
return sortGuilds(filtered.map((entry) => entry.guild)).map((guild) => ({
guild,
isAmaGuestOnly: byId.get(guild.id)!.isAmaGuestOnly,
}));
}, [visible, searchQuery]);

// `me` is only `undefined` while the query is still in flight — a resolved-but-logged-out `me` never reaches
Expand Down
27 changes: 27 additions & 0 deletions apps/website/src/hooks/usePublicRealtimeClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
'use client';

import { useRef } from 'react';
import type { RealtimeClient } from '@/api/ws';
import { createPublicRealtimeClient } from '@/api/ws';

/**
* The public answers page's gateway client (#323), created once per share token and kept stable for as long as
* that token is the one on screen -- `useRealtimeInvalidate` tears down and re-subscribes whenever the client
* identity changes, so an unstable reference would churn a socket per render.
*
* A ref rather than `useMemo` because this is a correctness requirement, not a performance one: React is free
* to discard a `useMemo` result whenever it likes. Keyed on the token rather than created once for the
* component's lifetime because the App Router reuses this component across `/ama-answers/[shareToken]`
* navigations -- only the param changes, so a lifetime-scoped client would keep minting tickets for the
* previous AMA. The outgoing client needs no explicit teardown: its socket closes on its own once the last
* subscription drops, which the re-subscribe on the next render triggers.
*/
export function usePublicRealtimeClient(shareToken: string): RealtimeClient {
const ref = useRef<{ client: RealtimeClient; shareToken: string } | null>(null);

if (ref.current?.shareToken !== shareToken) {
ref.current = { client: createPublicRealtimeClient(shareToken), shareToken };
}

return ref.current.client;
}
15 changes: 12 additions & 3 deletions apps/website/src/hooks/useRealtimeInvalidate.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,24 @@
'use client';

import { useEffect, useRef } from 'react';
import type { RealtimeClient } from '@/api/ws';
import { realtimeClient } from '@/api/ws';

/**
* Subscribes to a WS gateway channel (`@chatsift/core`'s `realtimeChannels.ts` builders) for the lifetime of
* the component, re-running `onInvalidate` (a TanStack Query cache invalidation, typically) whenever the
* server signals something on that channel changed. `onInvalidate` is read through a ref so callers don't need
* to memoize it themselves.
*
* `client` defaults to the session-backed singleton, which is what every page under `/dashboard` wants. The
* public answers page passes its own share-token-backed client instead (`usePublicRealtimeClient`, #323) --
* it must be a stable reference across renders, since changing it re-runs the effect.
*/
export function useRealtimeInvalidate(channel: string | undefined, onInvalidate: () => void): void {
export function useRealtimeInvalidate(
channel: string | undefined,
onInvalidate: () => void,
client: RealtimeClient = realtimeClient,
): void {
const onInvalidateRef = useRef(onInvalidate);
onInvalidateRef.current = onInvalidate;

Expand All @@ -18,6 +27,6 @@ export function useRealtimeInvalidate(channel: string | undefined, onInvalidate:
return undefined;
}

return realtimeClient.subscribe(channel, () => onInvalidateRef.current());
}, [channel]);
return client.subscribe(channel, () => onInvalidateRef.current());
}, [channel, client]);
}
14 changes: 10 additions & 4 deletions apps/website/src/utils/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,17 @@ import type { MeGuild } from '@/api/routes/auth';

export const cn = (...inputs: ClassValue[]) => twMerge(clsx(inputs));

/**
* Most-configured servers first, then alphabetically.
*
* The name tiebreak isn't cosmetic (#321): this used to be `reverse().sort(byBotCount)`, and since `Array#sort`
* is stable, every equal-bot-count group silently fell back to reverse-`/me` order -- which puts AMA-guest-only
* guilds last, because `fetchMe` appends the ones the viewer isn't a Discord member of after everything else.
* Sorting on something intrinsic to the guild instead makes the order deterministic and independent of how the
* API happened to assemble the list.
*/
export const sortGuilds = (guilds: MeGuild[]) =>
guilds
.slice()
.reverse()
.sort((a, b) => b.bots.length - a.bots.length);
guilds.slice().sort((a, b) => b.bots.length - a.bots.length || a.name.localeCompare(b.name));

export const getGuildAcronym = (guildName: string) =>
guildName
Expand Down
57 changes: 57 additions & 0 deletions docs/roadmap/01-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -466,3 +466,60 @@ information):
- **No custom-ModMail-instance Terms addendum.** Considered because partner deployments (§8 above) share
ChatSift's Postgres/Redis, but ChatSift owns the Discord application on every instance (including branded
ones) — there's no separate data controller relationship to document.

## 10. Realtime WS gateway (#297/#298, #323)

A cache-invalidation bus, not an event log. The server never pushes data — only a bare
`{ type: 'invalidate', channel }` telling a subscribed browser that something behind a query key changed, at
which point the client refetches over normal HTTP. There's no replay or ordering guarantee for signals missed
while disconnected, so `apps/website/src/api/ws.ts` fires every still-subscribed channel's listeners once on
reconnect rather than trying to catch up.

**Transport.** `services/api/src/ws/server.ts` attaches a `ws` `WebSocketServer` to the same `http.Server`
polka already listens on, handling the `/v3/ws` upgrade path only. Fan-out goes through Redis pub/sub
(`REALTIME_INVALIDATE_CHANNEL`, `packages/private/backend-core/src/lib/realtimeBroadcast.ts`) rather than a
local `WsHub` reference, so a publisher doesn't have to be in the process a given browser socket is connected
to — which is also how `services/ama-bot`'s Discord interaction handlers, which never touch the API process,
publish at all. Signals are tagged with the originating browser tab's `clientId` so the tab whose own mutation
caused the change doesn't get told to refetch what it already invalidated.

**Publishing.** `defineRoute`'s `realtimeChannel` hook (`services/api/src/core/route.ts`) computes the
channel(s) from the request; `mountRoute` broadcasts after the handler resolves 2xx, so a handler with several
early-return branches doesn't need a publish call in each. It may return an array — an AMA answer, for
instance, lands on both the dashboard's channel and the public page's. Bot-side handlers call
`publishRealtimeInvalidate` directly. Either way, several channels go over as one batched call: the wire still
carries one message per channel (the subscriber dispatches on a single `channel`), but node-redis pipelines
them into a single round trip instead of `n` sequential ones — worth caring about because publishing happens
after the mutation has committed, on the request's critical path.

**Channels** are built in `packages/private/core/src/lib/realtimeChannels.ts` so both sides agree on the exact
string, same "one source of truth" reasoning as the route contracts.

**Authorization** (`services/api/src/ws/authorizeChannel.ts`) happens per `subscribe` frame against claims
baked into a short-lived (60s) JWT ticket, minted over normal HTTP before the socket opens — a browser
`WebSocket` handshake can't carry the session's `Authorization` header. Two independent paths:

1. **Guild-wide grant.** A guild-scoped channel is `<domain>:<guildId>:<...>`; a manager of that guild (or a
global admin) gets everything under it, no per-domain rule needed. The **three-segment minimum is
load-bearing**, not a parse guard — see below.
2. **Exact-match allowlist** (`WsTicketData.channels`), for access that isn't a guild-manager grant:
- **AMA guests** (#323). Guest access lives in `ama_sessions.guest_ids` and is deliberately independent of
`meCanManage` (a guest-only guild is synthesized with `meCanManage: false`, see `util/me.ts`), so it never
reaches `grants.adminGuilds` and path 1 can't see it. `routes/ws/getTicket.ts` resolves the guest's
sessions at mint time and lists their concrete channels — the WS mirror of `isAuthed`'s `'or-ama-guest'`
path. A `/dashboard`-scoped session's lookup is confined to its own guild, matching how its `adminGuilds`
already behaves.
- **The public answers page** (`/ama-answers/[shareToken]`). Unauthenticated — knowing the share token _is_
the authorization — so `routes/ama/questions/publicWsTicket.ts` trades a valid token for a ticket carrying
nothing but the one `amaPublicAnswersChannel` it resolves to. The frontend uses a separate
`RealtimeClient` for it (`usePublicRealtimeClient`), since the session-backed singleton mints from a
session this page normally doesn't have.

`amaPublicAnswersChannel` is `ama-public:<amaId>` — deliberately **guildless**, breaking the format above. That
page hides every raw Discord id it can, so handing an anonymous browser a guild snowflake would undo that for
nothing. The consequence is that it's reachable only via path 2, never inherited by whoever manages the guild;
path 1's segment-count check is what enforces that, rather than trusting snowflakes and small serial ama ids to
never collide.

Ticket claims are resolved once at mint time, so they're as stale as `adminGuilds` already was — bounded by the
60s TTL plus the client re-minting on every (re)connect.
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { beforeEach, expect, test, vi } from 'vitest';
import { REALTIME_INVALIDATE_CHANNEL, publishRealtimeInvalidate } from '../realtimeBroadcast.js';

const publish = vi.fn();
const warn = vi.fn();

// `realtimeBroadcast.ts` reaches for the redis client and logger through `getContext()` at call time, so
// stubbing the context is enough -- no real connection, and vitest hoists this above the static import above.
vi.mock('../context.js', () => ({
getContext: () => ({ redis: { publish }, logger: { warn } }),
}));

function published(): { channel: string; originClientId?: string; type: string }[] {
return publish.mock.calls.map((call) => JSON.parse(call[1] as string));
}

beforeEach(() => {
publish.mockReset().mockResolvedValue(1);
warn.mockReset();
});

test('publishes a single channel on the shared invalidate channel', async () => {
await publishRealtimeInvalidate('ama-questions:123:1');

expect(publish).toHaveBeenCalledOnce();
expect(publish.mock.calls[0]![0]).toBe(REALTIME_INVALIDATE_CHANNEL);
expect(published()).toStrictEqual([{ type: 'invalidate', channel: 'ama-questions:123:1' }]);
});

test('publishes one message per channel when given several', async () => {
// The WS subscriber dispatches on a single `channel`, so a batch is still n messages -- what the array
// form saves is issuing them in one tick (pipelined) rather than n sequential round trips.
await publishRealtimeInvalidate(['ama-questions:123:1', 'ama-public:1']);

expect(publish).toHaveBeenCalledTimes(2);
expect(published()).toStrictEqual([
{ type: 'invalidate', channel: 'ama-questions:123:1' },
{ type: 'invalidate', channel: 'ama-public:1' },
]);
});

test('tags every channel in a batch with the same originClientId', async () => {
await publishRealtimeInvalidate(['ama-questions:123:1', 'ama-public:1'], 'tab-abc');

expect(published()).toStrictEqual([
{ type: 'invalidate', channel: 'ama-questions:123:1', originClientId: 'tab-abc' },
{ type: 'invalidate', channel: 'ama-public:1', originClientId: 'tab-abc' },
]);
});

test('omits originClientId entirely when absent, rather than sending undefined', async () => {
await publishRealtimeInvalidate('ama-questions:123:1');

expect(published()[0]).not.toHaveProperty('originClientId');
});

test('is a no-op for an empty batch', async () => {
await publishRealtimeInvalidate([]);

expect(publish).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});

test('swallows and logs a failed publish instead of throwing at the caller', async () => {
// The mutation that triggered this has already committed by the time any call site gets here -- a missed
// live refresh must not turn into a failed request.
publish.mockRejectedValue(new Error('redis is down'));

await expect(publishRealtimeInvalidate(['ama-questions:123:1', 'ama-public:1'])).resolves.toBeUndefined();
expect(warn).toHaveBeenCalledOnce();
expect(warn.mock.calls[0]![0]).toMatchObject({ channels: ['ama-questions:123:1', 'ama-public:1'] });
});
Loading
Loading