Add gateway realtime fanout and cursor pagination docs

This commit is contained in:
Foxyz committed 2026-02-20 04:24:27 +03:00
1 parent 4399eafc90
commit ccf966ffc1
5 files changed
+800 -23

No files matched your search

+2
View File
@@ -3,6 +3,7 @@ import 'package:relic/relic.dart';
import 'config/config.dart';
import 'src/auth/auth_middleware.dart';
import 'src/gateway/gateway_handlers.dart';
import 'src/handlers/auth_handlers.dart';
import 'src/handlers/channel_handlers.dart';
import 'src/handlers/message_handlers.dart';
@@ -26,6 +27,7 @@ Future<void> startServer() async {
..get('/.well-known/users/:username', publicUserHandler)
..get('/.well-known/verify/:userId', publicVerifyHandler)
..get('/.well-known/server', publicServerInfoHandler)
..get('/gateway', gatewayHandler)
// ============ PROTECTED ENDPOINTS (requires auth) ============
..use('/api', jwtMiddleware)
+442
View File
@@ -0,0 +1,442 @@
import 'dart:async';
import 'dart:convert';
import 'package:relic/relic.dart';
import 'package:web_socket/web_socket.dart';
import '../auth/crypto_service.dart';
import '../state/app_state.dart';
final _gatewayRegistry = _GatewayRegistry();
/// WebSocket gateway with JWT auth and channel subscriptions.
Result gatewayHandler(Request req) {
final token = _extractGatewayToken(req);
if (token == null) {
return Response.unauthorized(
body: Body.fromString('Bearer token required'),
);
}
final payload = AuthService.verifyToken(token);
if (payload == null) {
return Response.forbidden(
body: Body.fromString('Invalid or expired token'),
);
}
final userId = payload['sub'] as String?;
final username = payload['username'] as String?;
if (userId == null || userId.isEmpty) {
return Response.forbidden(
body: Body.fromString('Invalid token payload'),
);
}
return WebSocketUpgrade((webSocket) {
webSocket.pingInterval = const Duration(seconds: 30);
final connection = _gatewayRegistry.register(
socket: webSocket,
userId: userId,
username: username,
);
_sendGatewayPayload(
connection.id,
{
'op': 'ready',
'connectionId': connection.id,
'userId': connection.userId,
if (connection.username != null) 'username': connection.username,
},
);
webSocket.events.listen(
(event) {
switch (event) {
case TextDataReceived(text: final text):
_handleGatewayTextMessage(connection.id, text);
case BinaryDataReceived():
_sendProtocolError(
connection.id,
'Binary payloads are not supported',
);
case CloseReceived():
_gatewayRegistry.unregister(connection.id);
}
},
onError: (_, __) {
_gatewayRegistry.unregister(connection.id);
},
onDone: () {
_gatewayRegistry.unregister(connection.id);
},
);
});
}
void broadcastMessageCreated({
required int channelId,
required Map<String, dynamic> message,
}) {
_gatewayRegistry.broadcastToChannel(channelId, {
'op': 'event',
'event': 'message.created',
'channelId': channelId,
'message': message,
'emittedAt': DateTime.now().toUtc().toIso8601String(),
});
}
void broadcastMessageUpdated({
required int channelId,
required Map<String, dynamic> message,
}) {
_gatewayRegistry.broadcastToChannel(channelId, {
'op': 'event',
'event': 'message.updated',
'channelId': channelId,
'message': message,
'emittedAt': DateTime.now().toUtc().toIso8601String(),
});
}
void broadcastMessageDeleted({
required int channelId,
required int messageId,
}) {
_gatewayRegistry.broadcastToChannel(channelId, {
'op': 'event',
'event': 'message.deleted',
'channelId': channelId,
'messageId': messageId,
'emittedAt': DateTime.now().toUtc().toIso8601String(),
});
}
String? _extractGatewayToken(Request req) {
final authHeader = req.headers.authorization;
if (authHeader is BearerAuthorizationHeader) {
final token = authHeader.token.trim();
if (token.isNotEmpty) {
return token;
}
}
final rawAuthorization = req.headers[Headers.authorizationHeader];
if (rawAuthorization != null && rawAuthorization.isNotEmpty) {
final value = rawAuthorization.first.trim();
if (value.toLowerCase().startsWith('bearer ')) {
final token = value.substring(7).trim();
if (token.isNotEmpty) {
return token;
}
}
}
final queryToken = req.url.queryParameters['token']?.trim();
if (queryToken != null && queryToken.isNotEmpty) {
return queryToken;
}
final queryAccessToken = req.url.queryParameters['access_token']?.trim();
if (queryAccessToken != null && queryAccessToken.isNotEmpty) {
return queryAccessToken;
}
return null;
}
void _handleGatewayTextMessage(String connectionId, String text) {
final connection = _gatewayRegistry.getConnection(connectionId);
if (connection == null) {
return;
}
final payload = _decodePayload(text);
if (payload == null) {
_sendProtocolError(connectionId, 'Invalid JSON payload');
return;
}
final rawOp = payload['op'];
if (rawOp is! String || rawOp.trim().isEmpty) {
_sendProtocolError(connectionId, '`op` is required');
return;
}
switch (rawOp.trim().toLowerCase()) {
case 'subscribe':
_handleSubscribe(connectionId, payload);
case 'unsubscribe':
_handleUnsubscribe(connectionId, payload);
case 'ping':
case 'heartbeat':
_sendGatewayPayload(connectionId, {
'op': 'pong',
'ts': DateTime.now().toUtc().toIso8601String(),
});
case 'pong':
return;
default:
_sendProtocolError(connectionId, 'Unsupported op `$rawOp`');
}
}
void _handleSubscribe(String connectionId, Map<String, dynamic> payload) {
final channelIds = _parseChannelIds(payload);
if (channelIds.isEmpty) {
_sendProtocolError(
connectionId,
'subscribe requires `channelId` or `channelIds`',
);
return;
}
final knownChannelIds = <int>[];
final unknownChannelIds = <int>[];
for (final channelId in channelIds) {
if (channelBox.get(channelId) == null) {
unknownChannelIds.add(channelId);
} else {
knownChannelIds.add(channelId);
}
}
final subscribed = _gatewayRegistry.subscribe(connectionId, knownChannelIds);
_sendGatewayPayload(connectionId, {
'op': 'subscribed',
'channelIds': subscribed,
'subscriptions': _gatewayRegistry.subscriptions(connectionId),
if (unknownChannelIds.isNotEmpty) 'unknownChannelIds': unknownChannelIds,
});
}
void _handleUnsubscribe(String connectionId, Map<String, dynamic> payload) {
final channelIds = _parseChannelIds(payload);
if (channelIds.isEmpty) {
_sendProtocolError(
connectionId,
'unsubscribe requires `channelId` or `channelIds`',
);
return;
}
final unsubscribed = _gatewayRegistry.unsubscribe(connectionId, channelIds);
_sendGatewayPayload(connectionId, {
'op': 'unsubscribed',
'channelIds': unsubscribed,
'subscriptions': _gatewayRegistry.subscriptions(connectionId),
});
}
Map<String, dynamic>? _decodePayload(String text) {
try {
final decoded = jsonDecode(text);
if (decoded is Map<String, dynamic>) {
return decoded;
}
if (decoded is Map) {
return decoded.map(
(key, value) => MapEntry(key.toString(), value),
);
}
return null;
} catch (_) {
return null;
}
}
Set<int> _parseChannelIds(Map<String, dynamic> payload) {
final channelIds = <int>{};
void parseAndAdd(Object? value) {
final parsed = switch (value) {
int id when id > 0 => id,
String id => int.tryParse(id),
_ => null,
};
if (parsed != null && parsed > 0) {
channelIds.add(parsed);
}
}
parseAndAdd(payload['channelId']);
final channelIdList = payload['channelIds'];
if (channelIdList is List) {
for (final value in channelIdList) {
parseAndAdd(value);
}
}
return channelIds;
}
void _sendProtocolError(String connectionId, String message) {
_sendGatewayPayload(connectionId, {
'op': 'error',
'message': message,
});
}
void _sendGatewayPayload(String connectionId, Map<String, dynamic> payload) {
final connection = _gatewayRegistry.getConnection(connectionId);
if (connection == null) {
return;
}
final sent = _safeSend(connection, payload);
if (!sent) {
_gatewayRegistry.unregister(connectionId);
}
}
bool _safeSend(_GatewayConnection connection, Map<String, dynamic> payload) {
try {
return connection.socket.trySendText(jsonEncode(payload));
} catch (_) {
return false;
}
}
class _GatewayRegistry {
final Map<String, _GatewayConnection> _connections = {};
final Map<int, Set<String>> _subscribersByChannel = {};
int _nextConnectionId = 1;
_GatewayConnection register({
required RelicWebSocket socket,
required String userId,
String? username,
}) {
final id = 'gw_${_nextConnectionId++}';
final connection = _GatewayConnection(
id: id,
socket: socket,
userId: userId,
username: username,
);
_connections[id] = connection;
return connection;
}
_GatewayConnection? getConnection(String connectionId) {
return _connections[connectionId];
}
List<int> subscriptions(String connectionId) {
final connection = _connections[connectionId];
if (connection == null) {
return const <int>[];
}
return connection.subscriptions.toList()..sort();
}
List<int> subscribe(String connectionId, Iterable<int> channelIds) {
final connection = _connections[connectionId];
if (connection == null) {
return const <int>[];
}
for (final channelId in channelIds) {
connection.subscriptions.add(channelId);
_subscribersByChannel.putIfAbsent(channelId, () => <String>{}).add(
connectionId,
);
}
return channelIds.toSet().toList()..sort();
}
List<int> unsubscribe(String connectionId, Iterable<int> channelIds) {
final connection = _connections[connectionId];
if (connection == null) {
return const <int>[];
}
final removed = <int>[];
for (final channelId in channelIds) {
final didRemove = connection.subscriptions.remove(channelId);
if (didRemove) {
removed.add(channelId);
}
final subscribers = _subscribersByChannel[channelId];
if (subscribers != null) {
subscribers.remove(connectionId);
if (subscribers.isEmpty) {
_subscribersByChannel.remove(channelId);
}
}
}
removed.sort();
return removed;
}
void unregister(String connectionId) {
final connection = _connections.remove(connectionId);
if (connection == null) {
return;
}
for (final channelId in connection.subscriptions) {
final subscribers = _subscribersByChannel[channelId];
if (subscribers == null) {
continue;
}
subscribers.remove(connectionId);
if (subscribers.isEmpty) {
_subscribersByChannel.remove(channelId);
}
}
if (!connection.socket.isClosed) {
unawaited(connection.socket.tryClose(1000, 'Disconnected'));
}
}
void broadcastToChannel(int channelId, Map<String, dynamic> payload) {
final subscribers = _subscribersByChannel[channelId];
if (subscribers == null || subscribers.isEmpty) {
return;
}
final deadConnections = <String>[];
final subscriberIds = subscribers.toList(growable: false);
for (final connectionId in subscriberIds) {
final connection = _connections[connectionId];
if (connection == null) {
deadConnections.add(connectionId);
continue;
}
final sent = _safeSend(connection, payload);
if (!sent) {
deadConnections.add(connectionId);
}
}
for (final connectionId in deadConnections) {
unregister(connectionId);
}
}
}
class _GatewayConnection {
final String id;
final RelicWebSocket socket;
final String userId;
final String? username;
final Set<int> subscriptions = <int>{};
_GatewayConnection({
required this.id,
required this.socket,
required this.userId,
required this.username,
});
}
+112 -23
View File
@@ -3,14 +3,20 @@ import 'package:chat_server/objectbox.g.dart';
import 'package:relic/relic.dart';
import '../auth/auth_middleware.dart';
import '../gateway/gateway_handlers.dart';
import '../state/app_state.dart';
import '../utils/extensions.dart';
const _defaultMessageLimit = 50;
const _maxMessageLimit = 100;
/// Get messages for a channel
/// Query params: channelId (required), limit, before
/// Query params: channelId (required), limit, before, after
Future<Response> getMessagesHandler(Request req) async {
final channelIdStr = req.url.queryParameters['channelId'];
final limitStr = req.url.queryParameters['limit'] ?? '50';
final limitStr = req.url.queryParameters['limit'];
final beforeStr = req.url.queryParameters['before'];
final afterStr = req.url.queryParameters['after'];
if (channelIdStr == null) {
return Response.badRequest(
@@ -18,8 +24,17 @@ Future<Response> getMessagesHandler(Request req) async {
);
}
if (beforeStr != null && afterStr != null) {
return Response.badRequest(
body: Body.fromString('Use either `before` or `after`, not both'),
);
}
final channelId = int.tryParse(channelIdStr);
final limit = int.tryParse(limitStr) ?? 50;
final parsedLimit =
limitStr == null ? _defaultMessageLimit : int.tryParse(limitStr);
int? before;
int? after;
if (channelId == null) {
return Response.badRequest(
@@ -27,6 +42,32 @@ Future<Response> getMessagesHandler(Request req) async {
);
}
if (parsedLimit == null || parsedLimit <= 0) {
return Response.badRequest(
body: Body.fromString('Invalid limit'),
);
}
final limit = parsedLimit > _maxMessageLimit ? _maxMessageLimit : parsedLimit;
if (beforeStr != null) {
before = int.tryParse(beforeStr);
if (before == null || before <= 0) {
return Response.badRequest(
body: Body.fromString('Invalid `before` cursor'),
);
}
}
if (afterStr != null) {
after = int.tryParse(afterStr);
if (after == null || after <= 0) {
return Response.badRequest(
body: Body.fromString('Invalid `after` cursor'),
);
}
}
// Check channel exists
final channel = channelBox.get(channelId);
if (channel == null) {
@@ -35,33 +76,50 @@ Future<Response> getMessagesHandler(Request req) async {
);
}
// Get messages
final query = messageBox
.query(ChatMessage_.channelId.equals(channelId))
.order(ChatMessage_.createdAt, flags: Order.descending)
.build();
var condition = ChatMessage_.channelId.equals(channelId);
if (before != null) {
condition = condition & ChatMessage_.id.lessThan(before);
} else if (after != null) {
condition = condition & ChatMessage_.id.greaterThan(after);
}
query.limit = limit;
final messages = query.find();
final queryBuilder = messageBox.query(condition);
if (after != null) {
queryBuilder.order(ChatMessage_.id);
} else {
queryBuilder.order(ChatMessage_.id, flags: Order.descending);
}
final query = queryBuilder.build();
query.limit = limit + 1;
final fetchedMessages = query.find();
query.close();
final hasMore = fetchedMessages.length > limit;
final messages =
hasMore ? fetchedMessages.sublist(0, limit) : fetchedMessages;
int? nextBefore;
int? nextAfter;
if (messages.isNotEmpty) {
if (after != null) {
nextBefore = messages.first.id;
nextAfter = messages.last.id;
} else {
nextBefore = messages.last.id;
nextAfter = messages.first.id;
}
}
return Response.ok(
body: {
'messages': messages
.map((m) => {
'id': m.id,
'content': m.content,
'authorId': m.authorUserId,
'authorUsername': m.authorUsername,
'authorAvatarUrl': m.authorAvatarUrl,
'authorHomeServer': m.authorHomeServer,
'channelId': m.channelId,
'createdAt': m.createdAt.toIso8601String(),
'editedAt': m.editedAt?.toIso8601String(),
})
.toList(),
'messages': messages.map(_messageToMap).toList(),
'channelId': channelId,
'limit': limit,
'hasMore': hasMore,
'nextBefore': nextBefore,
'nextAfter': nextAfter,
}.toBody,
);
}
@@ -123,6 +181,12 @@ Future<Response> sendMessageHandler(Request req) async {
);
final id = messageBox.put(message);
message.id = id;
broadcastMessageCreated(
channelId: channelId,
message: _messageToMap(message),
);
return Response.ok(
body: {
@@ -192,6 +256,11 @@ Future<Response> editMessageHandler(Request req) async {
message.editedAt = DateTime.now();
messageBox.put(message);
broadcastMessageUpdated(
channelId: message.channelId,
message: _messageToMap(message),
);
return Response.ok(
body: {
'id': message.id,
@@ -243,8 +312,14 @@ Future<Response> deleteMessageHandler(Request req) async {
);
}
final channelId = message.channelId;
messageBox.remove(messageId);
broadcastMessageDeleted(
channelId: channelId,
messageId: messageId,
);
return Response.ok(
body: {
'deleted': true,
@@ -252,3 +327,17 @@ Future<Response> deleteMessageHandler(Request req) async {
}.toBody,
);
}
Map<String, dynamic> _messageToMap(ChatMessage message) {
return {
'id': message.id,
'content': message.content,
'authorId': message.authorUserId,
'authorUsername': message.authorUsername,
'authorAvatarUrl': message.authorAvatarUrl,
'authorHomeServer': message.authorHomeServer,
'channelId': message.channelId,
'createdAt': message.createdAt.toIso8601String(),
'editedAt': message.editedAt?.toIso8601String(),
};
}