Files
glchat/web/src/api/gateway.ts
T
grendervill 1b1a679827 feat(api): ручки Фаз(ы) 1 — профиль, серверы, роли, админ инстанса
REST-слой Фазы 1 на huma (OpenAPI 3.1 генерируется из кода):

- профиль: GET/PATCH /users/@me, смена пароля со step-up, публичный профиль,
  завершение онбординга (новая миграция 00003 с onboarding_completed_at);
- серверы: создание/изменение/удаление, join/leave, список серверов
  пользователя, журнал действий;
- комнаты: список с учётом прав, создание/изменение/удаление;
- участники: список с профилями и ролями, никнейм, тайм-аут, исключение;
- роли: CRUD, выдача/снятие с проверкой иерархии и запретом выдачи прав выше
  собственных;
- админ инстанса: публичная информация, настройки, серверы, пользователи,
  аудит, выдача прав администратора со step-up; обход лимитов фиксируется в
  аудите отдельной записью limits.bypass;
- движок прав: участие в сервере стало обязательным условием (IsMember),
  не участник не получает прав роли @user; калькулятор прав общий для API и
  Gateway, инвалидация кэша после изменений;
- Gateway: браузерный клиент аутентифицируется cookie на рукопожатии, IDENTIFY
  без токена использует её; события GUILD/CHANNEL/MEMBER/ROLE рассылаются из
  ручек, USER_UPDATE — адресно;
- ошибки huma отдаются в едином конверте {"error":{"code","message"}}.

Тесты: 8 сценариев API (профиль, жизненный цикл сервера и права, лимиты и
обход админом, иерархия ролей, тайм-аут, скрытие комнаты оверрайдом,
членство в движке прав, cookie-идентификация Gateway).
2026-09-19 21:50:06 +03:00

634 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { Channel, Role, UserStatus } from './types';
/**
* Клиент шлюза (WebSocket) Фазы 1.
*
* Контракт: сервер присылает HELLO (op 10) с интервалом heartbeat, клиент
* отвечает IDENTIFY (op 2, пустой токен — браузер аутентифицируется cookie
* на рукопожатии), затем получает READY/RESUMED. Разрыв соединения лечится
* экспоненциальной задержкой и RESUME (op 3) с последним полученным `s`.
*
* Модуль намеренно не зависит от React и Zustand: он принимает колбэки,
* поэтому его можно проверить юнит-тестом с подставным WebSocket.
*/
export const GatewayOp = {
DISPATCH: 0,
HEARTBEAT: 1,
IDENTIFY: 2,
RESUME: 3,
INVALID_SESSION: 4,
HELLO: 10,
HEARTBEAT_ACK: 11,
RECONNECT: 12,
} as const;
export const GATEWAY_CLOSE_INVALID_SESSION = 4000;
export interface GatewayHello {
heartbeat_interval_ms: number;
session_id: string;
}
/** Сервер в снапшоте READY: каналы/роли/права приходят сразу. */
export interface GatewayGuild {
id: string;
name: string;
owner_id: string;
is_main: boolean;
icon_file_id?: string;
channels: Channel[];
roles: Role[];
member_ids: string[];
my_role_ids: string[];
my_nickname?: string;
my_permissions?: string[];
}
export interface GatewayUser {
id: string;
username: string;
display_name: string;
avatar_file_id?: string;
is_instance_admin: boolean;
badges: string[];
status?: UserStatus;
custom_status?: string;
custom_status_emoji?: string;
}
export interface GatewaySnapshot {
user: GatewayUser;
guilds: GatewayGuild[];
dm_channels: Channel[];
read_states: unknown[];
session_id: string;
heartbeat_interval_ms: number;
}
export interface GatewayDispatch {
op: 0;
t: string;
s: number;
d: unknown;
}
export type GatewayStatus = 'idle' | 'connecting' | 'connected' | 'reconnecting' | 'disconnected';
export interface GatewayHandlers {
/** Любое событие DISPATCH (READY, RESUMED и события гильдий/каналов). */
onDispatch?: (event: GatewayDispatch) => void;
onStatus?: (status: GatewayStatus) => void;
/** Сессия недействительна — нужно разлогинить пользователя. */
onInvalidSession?: () => void;
/** RESUMED без снапшота: данные нужно перечитать по REST. */
onResumeIncomplete?: () => void;
}
export interface GatewayLogger {
warn: (message: string) => void;
error: (message: string) => void;
}
export interface GatewayConnectionOptions {
url: string;
handlers?: GatewayHandlers;
/** Инъекция для тестов; по умолчанию берётся глобальный WebSocket. */
socketFactory?: (url: string) => WebSocket;
logger?: GatewayLogger;
}
const DEFAULT_HEARTBEAT_MS = 45_000;
const MAX_BACKOFF_MS = 30_000;
const BASE_BACKOFF_MS = 1_000;
interface GatewayPacket {
op: number;
t?: string;
s?: number;
d?: unknown;
}
function isGatewayPacket(value: unknown): value is GatewayPacket {
return (
typeof value === 'object' &&
value !== null &&
typeof (value as { op?: unknown }).op === 'number'
);
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined;
}
function asNumber(value: unknown): number | undefined {
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
}
function asStringArray(value: unknown): string[] {
return Array.isArray(value)
? value.filter((item): item is string => typeof item === 'string')
: [];
}
function parseChannel(value: unknown): Channel | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
const type = asString(record['type']);
if (id === undefined || name === undefined) {
return null;
}
if (type !== 'text' && type !== 'voice' && type !== 'category') {
return null;
}
const channel: Channel = {
id,
name,
type,
position: asNumber(record['position']) ?? 0,
};
const guildId = asString(record['guild_id']);
const parentId = asString(record['parent_id']);
const userLimit = asNumber(record['user_limit']);
const slowmode = asNumber(record['slowmode_seconds']);
if (guildId !== undefined) {
channel.guild_id = guildId;
}
if (parentId !== undefined) {
channel.parent_id = parentId;
}
if (userLimit !== undefined) {
channel.user_limit = userLimit;
}
if (slowmode !== undefined) {
channel.slowmode_seconds = slowmode;
}
if (typeof record['can_send'] === 'boolean') {
channel.can_send = record['can_send'];
}
if (typeof record['can_connect'] === 'boolean') {
channel.can_connect = record['can_connect'];
}
if (typeof record['can_view'] === 'boolean') {
channel.can_view = record['can_view'];
}
return channel;
}
function parseRole(value: unknown): Role | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
if (id === undefined || name === undefined) {
return null;
}
const permissions = record['permissions'];
return {
id,
name,
color: asNumber(record['color']) ?? 0,
position: asNumber(record['position']) ?? 0,
permissions:
typeof permissions === 'string'
? permissions
: typeof permissions === 'number'
? String(permissions)
: '0',
is_default: record['is_default'] === true,
hoist: record['hoist'] === true,
mentionable: record['mentionable'] === true,
};
}
function parseGatewayUser(value: unknown): GatewayUser | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const username = asString(record['username']);
if (id === undefined || username === undefined) {
return null;
}
const user: GatewayUser = {
id,
username,
display_name: asString(record['display_name']) ?? username,
is_instance_admin: record['is_instance_admin'] === true,
badges: asStringArray(record['badges']),
};
const avatar = asString(record['avatar_file_id']);
const status = asString(record['status']);
const customStatus = asString(record['custom_status']);
const customEmoji = asString(record['custom_status_emoji']);
if (avatar !== undefined) {
user.avatar_file_id = avatar;
}
if (status !== undefined) {
user.status = status as UserStatus;
}
if (customStatus !== undefined) {
user.custom_status = customStatus;
}
if (customEmoji !== undefined) {
user.custom_status_emoji = customEmoji;
}
return user;
}
function parseGuild(value: unknown): GatewayGuild | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const id = asString(record['id']);
const name = asString(record['name']);
if (id === undefined || name === undefined) {
return null;
}
const channels = Array.isArray(record['channels'])
? record['channels'].map(parseChannel).filter((item): item is Channel => item !== null)
: [];
const roles = Array.isArray(record['roles'])
? record['roles'].map(parseRole).filter((item): item is Role => item !== null)
: [];
const guild: GatewayGuild = {
id,
name,
owner_id: asString(record['owner_id']) ?? '',
is_main: record['is_main'] === true,
channels,
roles,
member_ids: asStringArray(record['member_ids']),
my_role_ids: asStringArray(record['my_role_ids']),
my_permissions: asStringArray(record['my_permissions']),
};
const icon = asString(record['icon_file_id']);
const nickname = asString(record['my_nickname']);
if (icon !== undefined) {
guild.icon_file_id = icon;
}
if (nickname !== undefined) {
guild.my_nickname = nickname;
}
return guild;
}
/** Разбирает снапшот READY, отбрасывая всё, что не совпало с контрактом. */
export function parseGatewaySnapshot(value: unknown): GatewaySnapshot | null {
const record = asRecord(value);
if (record === null) {
return null;
}
const user = parseGatewayUser(record['user']);
if (user === null) {
return null;
}
const guilds = Array.isArray(record['guilds'])
? record['guilds'].map(parseGuild).filter((item): item is GatewayGuild => item !== null)
: [];
const dmChannels = Array.isArray(record['dm_channels'])
? record['dm_channels'].map(parseChannel).filter((item): item is Channel => item !== null)
: [];
return {
user,
guilds,
dm_channels: dmChannels,
read_states: Array.isArray(record['read_states']) ? record['read_states'] : [],
session_id: asString(record['session_id']) ?? '',
heartbeat_interval_ms: asNumber(record['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS,
};
}
export function gatewayUrl(): string {
if (typeof window === 'undefined') {
return '/gateway';
}
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
return `${protocol}//${window.location.host}/gateway`;
}
export type GatewaySend = (packet: { op: number; d: unknown }) => void;
export class GatewayConnection {
private readonly url: string;
private readonly handlers: GatewayHandlers;
private readonly socketFactory: (url: string) => WebSocket;
private readonly logger: GatewayLogger;
private socket: WebSocket | null = null;
private status: GatewayStatus = 'idle';
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private helloTimeout: ReturnType<typeof setTimeout> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private heartbeatIntervalMs = DEFAULT_HEARTBEAT_MS;
private awaitingAck = false;
private attempt = 0;
private sessionId: string | null = null;
private lastSeq = 0;
private closedByUser = false;
constructor(options: GatewayConnectionOptions) {
this.url = options.url;
this.handlers = options.handlers ?? {};
this.logger = options.logger ?? { warn: () => undefined, error: () => undefined };
this.socketFactory =
options.socketFactory ??
((url) => {
if (typeof WebSocket === 'undefined') {
throw new Error('WebSocket is not available in this environment');
}
return new WebSocket(url);
});
}
getStatus(): GatewayStatus {
return this.status;
}
getSessionId(): string | null {
return this.sessionId;
}
getLastSeq(): number {
return this.lastSeq;
}
connect(): void {
this.closedByUser = false;
if (this.socket !== null) {
return;
}
this.open(undefined);
}
/** Плановое закрытие: без переподключения. */
close(): void {
this.closedByUser = true;
if (this.reconnectTimer !== null) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.stopHeartbeat();
const socket = this.socket;
this.socket = null;
if (socket !== null) {
socket.onopen = null;
socket.onmessage = null;
socket.onclose = null;
socket.onerror = null;
try {
socket.close();
} catch {
// Сокет мог уже закрыться — это не ошибка.
}
}
this.setStatus('disconnected');
}
private open(reason: unknown): void {
this.setStatus(this.attempt === 0 ? 'connecting' : 'reconnecting');
let socket: WebSocket;
try {
socket = this.socketFactory(this.url);
} catch (error) {
this.logger.warn(`gateway: cannot open socket (${String(error)})`);
this.scheduleReconnect();
return;
}
this.socket = socket;
if (reason !== undefined) {
this.logger.warn(`gateway: reconnecting (${String(reason)})`);
}
socket.onopen = () => {
// Ждём HELLO; если его нет — соединение считаем нерабочим.
this.helloTimeout = setTimeout(() => {
this.logger.warn('gateway: HELLO timeout');
this.dropSocket();
}, 15_000);
};
socket.onmessage = (event: MessageEvent) => {
this.handleMessage(event.data);
};
socket.onerror = () => {
this.logger.warn('gateway: socket error');
};
socket.onclose = (event: CloseEvent) => {
const wasCurrent = this.socket === socket;
this.socket = null;
this.stopHeartbeat();
if (!wasCurrent || this.closedByUser) {
return;
}
if (event.code === GATEWAY_CLOSE_INVALID_SESSION) {
this.setStatus('disconnected');
this.handlers.onInvalidSession?.();
return;
}
this.scheduleReconnect();
};
}
private dropSocket(): void {
const socket = this.socket;
this.socket = null;
this.stopHeartbeat();
if (socket !== null) {
socket.onclose = null;
try {
socket.close();
} catch {
// Игнорируем: сокет уже не нужен.
}
}
this.scheduleReconnect();
}
private scheduleReconnect(): void {
if (this.closedByUser || this.reconnectTimer !== null) {
return;
}
this.attempt += 1;
const backoff = Math.min(BASE_BACKOFF_MS * 2 ** (this.attempt - 1), MAX_BACKOFF_MS);
const jitter = Math.floor(Math.random() * 250);
this.setStatus('reconnecting');
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this.open('backoff');
}, backoff + jitter);
}
private setStatus(status: GatewayStatus): void {
if (this.status === status) {
return;
}
this.status = status;
this.handlers.onStatus?.(status);
}
private send(op: number, d: unknown): void {
const socket = this.socket;
if (socket === null || socket.readyState !== 1) {
return;
}
socket.send(JSON.stringify({ op, d }));
}
private handleMessage(raw: unknown): void {
if (typeof raw !== 'string') {
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(raw) as unknown;
} catch {
this.logger.warn('gateway: cannot parse frame');
return;
}
if (!isGatewayPacket(parsed)) {
return;
}
switch (parsed.op) {
case GatewayOp.HELLO: {
const data = asRecord(parsed.d);
this.heartbeatIntervalMs =
asNumber(data?.['heartbeat_interval_ms']) ?? DEFAULT_HEARTBEAT_MS;
this.sessionId = asString(data?.['session_id']) ?? this.sessionId;
if (this.helloTimeout !== null) {
clearTimeout(this.helloTimeout);
this.helloTimeout = null;
}
this.identify();
this.startHeartbeat();
return;
}
case GatewayOp.HEARTBEAT_ACK: {
this.awaitingAck = false;
return;
}
case GatewayOp.DISPATCH: {
const seq = asNumber(parsed.s) ?? 0;
if (seq > this.lastSeq) {
this.lastSeq = seq;
}
const type = asString(parsed.t) ?? '';
if (type === 'READY') {
this.attempt = 0;
}
if (type === 'RESUMED' && parsed.d === null) {
this.handlers.onResumeIncomplete?.();
}
this.setStatus('connected');
this.handlers.onDispatch?.({
op: 0,
t: type,
s: seq,
d: parsed.d,
});
return;
}
case GatewayOp.INVALID_SESSION: {
const resumable = parsed.d === true;
if (resumable) {
this.logger.warn('gateway: invalid session, retrying resume');
this.dropSocket();
return;
}
this.sessionId = null;
this.lastSeq = 0;
this.closedByUser = true;
this.setStatus('disconnected');
this.handlers.onInvalidSession?.();
return;
}
case GatewayOp.RECONNECT: {
this.dropSocket();
return;
}
default:
// Неизвестные opcode игнорируем: контракт может расширяться.
return;
}
}
private identify(): void {
// Пустой токен допустим: браузер аутентифицируется cookie на рукопожатии.
const token = '';
if (this.sessionId !== null && this.lastSeq > 0) {
this.send(GatewayOp.RESUME, { token, resume_seq: this.lastSeq });
return;
}
this.send(GatewayOp.IDENTIFY, { token, resume_seq: 0 });
}
private startHeartbeat(): void {
this.stopHeartbeat();
this.awaitingAck = false;
this.heartbeatTimer = setInterval(() => {
if (this.awaitingAck) {
this.logger.warn('gateway: heartbeat was not acknowledged');
this.dropSocket();
return;
}
this.awaitingAck = true;
this.send(GatewayOp.HEARTBEAT, null);
}, this.heartbeatIntervalMs);
}
private stopHeartbeat(): void {
if (this.heartbeatTimer !== null) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
if (this.helloTimeout !== null) {
clearTimeout(this.helloTimeout);
this.helloTimeout = null;
}
}
}
let sharedConnection: GatewayConnection | null = null;
export function getGatewayConnection(): GatewayConnection | null {
return sharedConnection;
}
/** Создаёт (или переиспользует) единственное соединение с шлюзом. */
export function connectGateway(options: {
url?: string;
handlers: GatewayHandlers;
socketFactory?: (url: string) => WebSocket;
}): GatewayConnection {
if (sharedConnection !== null) {
sharedConnection.close();
}
const socketFactory = options.socketFactory;
sharedConnection = new GatewayConnection({
url: options.url ?? gatewayUrl(),
handlers: options.handlers,
...(socketFactory === undefined ? {} : { socketFactory }),
});
sharedConnection.connect();
return sharedConnection;
}
export function disconnectGateway(): void {
sharedConnection?.close();
sharedConnection = null;
}