refactor progress

This commit is contained in:
Hampus Kraft
2026-02-17 12:22:36 +00:00
parent cb31608523
commit d5abd1a7e4
8257 changed files with 1190207 additions and 761040 deletions

View File

@@ -0,0 +1,45 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {RateLimitMiddleware} from '@fluxer/api/src/middleware/RateLimitMiddleware';
import {OpenAPI} from '@fluxer/api/src/middleware/ResponseTypeMiddleware';
import {RateLimitConfigs} from '@fluxer/api/src/RateLimitConfig';
import type {HonoApp} from '@fluxer/api/src/types/HonoEnv';
import {GatewayBotResponse} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
export function GatewayController(app: HonoApp) {
app.get(
'/gateway/bot',
RateLimitMiddleware(RateLimitConfigs.GATEWAY_BOT_INFO),
OpenAPI({
operationId: 'get_gateway_bot',
summary: 'Get gateway information',
responseSchema: GatewayBotResponse,
statusCode: 200,
security: [],
tags: ['Gateway'],
description:
'Retrieves gateway connection information and recommended shard count for establishing WebSocket connections.',
}),
async (ctx) => {
const gatewayRequestService = ctx.get('gatewayRequestService');
return ctx.json(await gatewayRequestService.getBotGatewayInfo(ctx.req.header('Authorization') ?? null));
},
);
}

View File

@@ -0,0 +1,95 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {Config} from '@fluxer/api/src/Config';
import type {BotAuthService} from '@fluxer/api/src/oauth/BotAuthService';
import {InvalidGatewayAuthTokenError} from '@fluxer/errors/src/domains/auth/InvalidGatewayAuthTokenError';
import {MissingGatewayAuthorizationError} from '@fluxer/errors/src/domains/auth/MissingGatewayAuthorizationError';
import type {GatewayBotResponse as GatewayBotResponseType} from '@fluxer/schema/src/domains/gateway/GatewaySchemas';
import {recordCounter} from '@fluxer/telemetry/src/Metrics';
type TokenType = 'user' | 'bot' | 'unknown';
function parseTokenType(raw: string): TokenType {
if (raw.startsWith('flx_')) return 'user';
const dotIndex = raw.indexOf('.');
if (dotIndex > 0 && dotIndex < raw.length - 1) {
const beforeDot = raw.slice(0, dotIndex);
if (/^\d+$/.test(beforeDot)) return 'bot';
}
return 'unknown';
}
function extractToken(authHeader: string | null): string {
if (!authHeader) return '';
const lower = authHeader.toLowerCase();
if (lower.startsWith('bot ')) return authHeader.slice(4).trim();
if (lower.startsWith('bearer ')) return authHeader.slice(7).trim();
return authHeader.trim();
}
export class GatewayRequestService {
constructor(private readonly botAuthService: BotAuthService) {}
async getBotGatewayInfo(authHeader: string | null): Promise<GatewayBotResponseType> {
const token = extractToken(authHeader);
if (!token) {
recordCounter({
name: 'gateway.connection',
dimensions: {status: 'failed', transport: 'bot', reason: 'missing_token'},
});
throw new MissingGatewayAuthorizationError();
}
const tokenType = parseTokenType(token);
if (tokenType !== 'bot') {
recordCounter({
name: 'gateway.connection',
dimensions: {status: 'failed', transport: 'bot', reason: 'invalid_token_type'},
});
throw new InvalidGatewayAuthTokenError();
}
try {
await this.botAuthService.validateBotToken(token);
recordCounter({
name: 'gateway.connection',
dimensions: {status: 'success', transport: 'bot'},
});
} catch (error) {
recordCounter({
name: 'gateway.connection',
dimensions: {status: 'failed', transport: 'bot', reason: 'invalid_token'},
});
throw error;
}
return {
url: Config.endpoints.gateway,
shards: 1,
session_start_limit: {
total: 1000,
remaining: 999,
reset_after: 14400000,
max_concurrency: 1,
},
};
}
}

View File

@@ -0,0 +1,173 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createChannelID, createGuildID, createUserID} from '@fluxer/api/src/BrandedTypes';
import {GatewayRpcMethodErrorCodes} from '@fluxer/api/src/infrastructure/GatewayRpcError';
import {GatewayService} from '@fluxer/api/src/infrastructure/GatewayService';
import {createGatewayRpcMethodErrorHandler} from '@fluxer/api/src/test/msw/handlers/GatewayRpcHandlers';
import {server} from '@fluxer/api/src/test/msw/server';
import {CallAlreadyExistsError} from '@fluxer/errors/src/domains/channel/CallAlreadyExistsError';
import {InvalidChannelTypeForCallError} from '@fluxer/errors/src/domains/channel/InvalidChannelTypeForCallError';
import {NoActiveCallError} from '@fluxer/errors/src/domains/channel/NoActiveCallError';
import {UnknownChannelError} from '@fluxer/errors/src/domains/channel/UnknownChannelError';
import {BadGatewayError} from '@fluxer/errors/src/domains/core/BadGatewayError';
import {GatewayTimeoutError} from '@fluxer/errors/src/domains/core/GatewayTimeoutError';
import {MissingPermissionsError} from '@fluxer/errors/src/domains/core/MissingPermissionsError';
import {ServiceUnavailableError} from '@fluxer/errors/src/domains/core/ServiceUnavailableError';
import {UnknownGuildError} from '@fluxer/errors/src/domains/guild/UnknownGuildError';
import {UserNotInVoiceError} from '@fluxer/errors/src/domains/user/UserNotInVoiceError';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it} from 'vitest';
describe('GatewayRpcService Error Handling', () => {
const TEST_GUILD_ID = createGuildID(123456789n);
const TEST_USER_ID = createUserID(987654321n);
const TEST_CHANNEL_ID = createChannelID(111222333n);
let gatewayService: GatewayService;
beforeAll(() => {
server.listen({onUnhandledRequest: 'error'});
});
afterAll(() => {
server.close();
});
beforeEach(() => {
gatewayService = new GatewayService();
});
afterEach(() => {
server.resetHandlers();
gatewayService.destroy();
});
it('transforms guild_not_found RPC error to UnknownGuildError', async () => {
server.use(createGatewayRpcMethodErrorHandler('guild.get_data', GatewayRpcMethodErrorCodes.GUILD_NOT_FOUND));
await expect(
gatewayService.getGuildData({
guildId: TEST_GUILD_ID,
userId: TEST_USER_ID,
}),
).rejects.toThrow(UnknownGuildError);
});
it('transforms forbidden RPC error to MissingPermissionsError', async () => {
server.use(createGatewayRpcMethodErrorHandler('guild.get_data', GatewayRpcMethodErrorCodes.FORBIDDEN));
await expect(
gatewayService.getGuildData({
guildId: TEST_GUILD_ID,
userId: TEST_USER_ID,
}),
).rejects.toThrow(MissingPermissionsError);
});
it('transforms guild_not_found RPC error to UnknownGuildError for non-batched calls', async () => {
server.use(createGatewayRpcMethodErrorHandler('guild.get_counts', GatewayRpcMethodErrorCodes.GUILD_NOT_FOUND));
await expect(gatewayService.getGuildCounts(TEST_GUILD_ID)).rejects.toThrow(UnknownGuildError);
});
it('transforms call_already_exists RPC error to CallAlreadyExistsError', async () => {
server.use(createGatewayRpcMethodErrorHandler('call.create', GatewayRpcMethodErrorCodes.CALL_ALREADY_EXISTS));
await expect(gatewayService.createCall(TEST_CHANNEL_ID, '123', 'us-east', [], [])).rejects.toThrow(
CallAlreadyExistsError,
);
});
it('transforms call_not_found RPC error to NoActiveCallError', async () => {
server.use(createGatewayRpcMethodErrorHandler('call.delete', GatewayRpcMethodErrorCodes.CALL_NOT_FOUND));
await expect(gatewayService.deleteCall(TEST_CHANNEL_ID)).rejects.toThrow(NoActiveCallError);
});
it('transforms channel_not_found RPC error to UnknownChannelError', async () => {
server.use(createGatewayRpcMethodErrorHandler('call.get', GatewayRpcMethodErrorCodes.CHANNEL_NOT_FOUND));
await expect(gatewayService.getCall(TEST_CHANNEL_ID)).rejects.toThrow(UnknownChannelError);
});
it('transforms channel_not_voice RPC error to InvalidChannelTypeForCallError', async () => {
server.use(createGatewayRpcMethodErrorHandler('call.get', GatewayRpcMethodErrorCodes.CHANNEL_NOT_VOICE));
await expect(gatewayService.getCall(TEST_CHANNEL_ID)).rejects.toThrow(InvalidChannelTypeForCallError);
});
it('transforms user_not_in_voice RPC error to UserNotInVoiceError', async () => {
server.use(
createGatewayRpcMethodErrorHandler('guild.update_member_voice', GatewayRpcMethodErrorCodes.USER_NOT_IN_VOICE),
);
await expect(
gatewayService.updateMemberVoice({
guildId: TEST_GUILD_ID,
userId: TEST_USER_ID,
mute: false,
deaf: false,
}),
).rejects.toThrow(UserNotInVoiceError);
});
it('transforms timeout RPC error to GatewayTimeoutError', async () => {
server.use(createGatewayRpcMethodErrorHandler('guild.get_data', GatewayRpcMethodErrorCodes.TIMEOUT));
await expect(
gatewayService.getGuildData({
guildId: TEST_GUILD_ID,
userId: TEST_USER_ID,
}),
).rejects.toThrow(GatewayTimeoutError);
});
it('does not open circuit breaker for mapped gateway business errors', async () => {
server.use(createGatewayRpcMethodErrorHandler('guild.get_data', GatewayRpcMethodErrorCodes.GUILD_NOT_FOUND));
for (let attempt = 0; attempt < 6; attempt += 1) {
await expect(
gatewayService.getGuildData({
guildId: TEST_GUILD_ID,
userId: TEST_USER_ID,
}),
).rejects.toThrow(UnknownGuildError);
}
});
it('opens circuit breaker for repeated gateway internal errors', async () => {
server.use(createGatewayRpcMethodErrorHandler('guild.get_data', GatewayRpcMethodErrorCodes.INTERNAL_ERROR));
for (let attempt = 0; attempt < 5; attempt += 1) {
await expect(
gatewayService.getGuildData({
guildId: TEST_GUILD_ID,
userId: TEST_USER_ID,
}),
).rejects.toThrow(BadGatewayError);
}
await expect(
gatewayService.getGuildData({
guildId: TEST_GUILD_ID,
userId: TEST_USER_ID,
}),
).rejects.toThrow(ServiceUnavailableError);
});
});

View File

@@ -0,0 +1,48 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {decodeGatewayTcpFrames, encodeGatewayTcpFrame} from '@fluxer/api/src/infrastructure/GatewayTcpFrameCodec';
import {describe, expect, test} from 'vitest';
describe('GatewayTcpFrameCodec', () => {
test('encodes and decodes a single frame', () => {
const frame = {type: 'ping'};
const encoded = encodeGatewayTcpFrame(frame);
const decoded = decodeGatewayTcpFrames(encoded);
expect(decoded.frames).toEqual([frame]);
expect(decoded.remainder.length).toBe(0);
});
test('decodes multiple frames with trailing partial frame', () => {
const frameA = {type: 'request', id: '1', method: 'process.node_stats', params: {}};
const frameB = {type: 'pong'};
const encodedA = encodeGatewayTcpFrame(frameA);
const encodedB = encodeGatewayTcpFrame(frameB);
const partial = Buffer.from('5\n{"ty', 'utf8');
const combined = Buffer.concat([encodedA, encodedB, partial]);
const decoded = decodeGatewayTcpFrames(combined);
expect(decoded.frames).toEqual([frameA, frameB]);
expect(decoded.remainder.equals(partial)).toBe(true);
});
test('throws on invalid frame length', () => {
const invalid = Buffer.from('x\n{}', 'utf8');
expect(() => decodeGatewayTcpFrames(invalid)).toThrow('Invalid Gateway TCP frame length');
});
});

View File

@@ -0,0 +1,309 @@
/*
* Copyright (C) 2026 Fluxer Contributors
*
* This file is part of Fluxer.
*
* Fluxer is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Fluxer is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Fluxer. If not, see <https://www.gnu.org/licenses/>.
*/
import {createServer, type Server, type Socket} from 'node:net';
import type {ILogger} from '@fluxer/api/src/ILogger';
import {decodeGatewayTcpFrames, encodeGatewayTcpFrame} from '@fluxer/api/src/infrastructure/GatewayTcpFrameCodec';
import {GatewayTcpRpcTransport} from '@fluxer/api/src/infrastructure/GatewayTcpRpcTransport';
import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest';
interface TestTcpServer {
server: Server;
port: number;
}
interface ConnectionContext {
socket: Socket;
getBuffer: () => Buffer<ArrayBufferLike>;
setBuffer: (buffer: Buffer<ArrayBufferLike>) => void;
}
function createNoopLogger(): ILogger {
return {
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
fatal: vi.fn(),
child: vi.fn(() => createNoopLogger()),
};
}
async function startTestServer(
handler: (context: ConnectionContext, frame: Record<string, unknown>) => void,
): Promise<TestTcpServer> {
const server = createServer((socket) => {
let buffer: Buffer<ArrayBufferLike> = Buffer.alloc(0);
const context: ConnectionContext = {
socket,
getBuffer: () => buffer,
setBuffer: (nextBuffer) => {
buffer = nextBuffer;
},
};
socket.on('data', (chunk: Buffer<ArrayBufferLike> | string) => {
const chunkBuffer = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk;
const combined = Buffer.concat([context.getBuffer(), chunkBuffer]);
const decoded = decodeGatewayTcpFrames(combined);
context.setBuffer(decoded.remainder);
for (const frame of decoded.frames) {
handler(context, frame as Record<string, unknown>);
}
});
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to get TCP server address');
}
return {server, port: address.port};
}
async function stopTestServer(server: Server): Promise<void> {
await new Promise<void>((resolve) => {
server.close(() => resolve());
});
}
function sendFrame(socket: Socket, frame: Record<string, unknown>): void {
socket.write(encodeGatewayTcpFrame(frame));
}
describe('GatewayTcpRpcTransport', () => {
let tcpServer: TestTcpServer | null = null;
let transport: GatewayTcpRpcTransport | null = null;
afterEach(async () => {
if (transport) {
await transport.destroy();
transport = null;
}
if (tcpServer) {
await stopTestServer(tcpServer.server);
tcpServer = null;
}
});
beforeEach(() => {
vi.useRealTimers();
});
test('multiplexes concurrent requests and matches out-of-order responses', async () => {
let requestOneId: string | null = null;
let requestTwoId: string | null = null;
tcpServer = await startTestServer((context, frame) => {
if (frame.type === 'hello') {
sendFrame(context.socket, {
type: 'hello_ack',
protocol: 'fluxer.rpc.tcp.v1',
ping_interval_ms: 60000,
});
return;
}
if (frame.type === 'request') {
const requestId = String(frame.id);
const method = String(frame.method);
if (method === 'first') {
requestOneId = requestId;
}
if (method === 'second') {
requestTwoId = requestId;
}
if (requestOneId && requestTwoId) {
sendFrame(context.socket, {
type: 'response',
id: requestTwoId,
ok: true,
result: 'second-result',
});
sendFrame(context.socket, {
type: 'response',
id: requestOneId,
ok: true,
result: 'first-result',
});
}
}
});
transport = new GatewayTcpRpcTransport({
host: '127.0.0.1',
port: tcpServer.port,
authorization: 'Bearer test-secret',
connectTimeoutMs: 300,
requestTimeoutMs: 2000,
defaultPingIntervalMs: 60000,
logger: createNoopLogger(),
});
const firstPromise = transport.call('first', {});
const secondPromise = transport.call('second', {});
const [firstResult, secondResult] = await Promise.all([firstPromise, secondPromise]);
expect(firstResult).toBe('first-result');
expect(secondResult).toBe('second-result');
});
test('reconnects automatically after server-side disconnect', async () => {
let connectionCount = 0;
let firstRequestSeen = false;
tcpServer = await startTestServer((context, frame) => {
if (frame.type === 'hello') {
connectionCount += 1;
sendFrame(context.socket, {
type: 'hello_ack',
protocol: 'fluxer.rpc.tcp.v1',
ping_interval_ms: 60000,
});
return;
}
if (frame.type === 'request') {
const requestId = String(frame.id);
if (!firstRequestSeen) {
firstRequestSeen = true;
sendFrame(context.socket, {
type: 'response',
id: requestId,
ok: true,
result: 'first-response',
});
setTimeout(() => {
context.socket.destroy();
}, 5);
return;
}
sendFrame(context.socket, {
type: 'response',
id: requestId,
ok: true,
result: 'second-response',
});
}
});
transport = new GatewayTcpRpcTransport({
host: '127.0.0.1',
port: tcpServer.port,
authorization: 'Bearer test-secret',
connectTimeoutMs: 300,
requestTimeoutMs: 2000,
defaultPingIntervalMs: 60000,
logger: createNoopLogger(),
});
const firstResult = await transport.call('first', {});
expect(firstResult).toBe('first-response');
await new Promise((resolve) => setTimeout(resolve, 20));
const secondResult = await transport.call('second', {});
expect(secondResult).toBe('second-response');
expect(connectionCount).toBeGreaterThanOrEqual(2);
});
test('rejects new requests when pending queue is full', async () => {
tcpServer = await startTestServer((context, frame) => {
if (frame.type === 'hello') {
sendFrame(context.socket, {
type: 'hello_ack',
protocol: 'fluxer.rpc.tcp.v1',
ping_interval_ms: 60000,
});
return;
}
if (frame.type !== 'request') {
return;
}
const requestId = String(frame.id);
const method = String(frame.method);
if (method === 'first') {
setTimeout(() => {
sendFrame(context.socket, {
type: 'response',
id: requestId,
ok: true,
result: 'first-response',
});
}, 40);
return;
}
sendFrame(context.socket, {
type: 'response',
id: requestId,
ok: true,
result: 'unexpected-response',
});
});
transport = new GatewayTcpRpcTransport({
host: '127.0.0.1',
port: tcpServer.port,
authorization: 'Bearer test-secret',
connectTimeoutMs: 300,
requestTimeoutMs: 2000,
defaultPingIntervalMs: 60000,
maxPendingRequests: 1,
logger: createNoopLogger(),
});
const firstPromise = transport.call('first', {});
await expect(transport.call('second', {})).rejects.toThrow('Gateway TCP request queue is full');
await expect(firstPromise).resolves.toBe('first-response');
});
test('closes the connection when the input buffer exceeds limit', async () => {
tcpServer = await startTestServer((context, frame) => {
if (frame.type === 'hello') {
sendFrame(context.socket, {
type: 'hello_ack',
protocol: 'fluxer.rpc.tcp.v1',
ping_interval_ms: 60000,
});
return;
}
if (frame.type === 'request') {
context.socket.write(Buffer.from('9999999999999999999999999999999999999999999999999999999999999999999999'));
}
});
transport = new GatewayTcpRpcTransport({
host: '127.0.0.1',
port: tcpServer.port,
authorization: 'Bearer test-secret',
connectTimeoutMs: 300,
requestTimeoutMs: 2000,
defaultPingIntervalMs: 60000,
maxBufferBytes: 64,
logger: createNoopLogger(),
});
await expect(transport.call('overflow', {})).rejects.toThrow('Gateway TCP input buffer exceeded maximum size');
});
});