feat: First initialization of laravel_echo_client.dart
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
library laravel_echo_client;
|
||||
|
||||
export 'src/api/socket_channel.dart';
|
||||
export 'src/api/socket_client.dart';
|
||||
export 'src/config/socket_config.dart';
|
||||
export 'src/socket_event.dart';
|
||||
export 'src/client/laravel_echo_socket_client.dart';
|
||||
@@ -0,0 +1,23 @@
|
||||
import 'package:flutter_laravel_echo_client/src/core/types.dart';
|
||||
|
||||
abstract class SocketChannel {
|
||||
String get name;
|
||||
|
||||
SocketChannel listen(String event, SocketEventHandler handler);
|
||||
|
||||
SocketChannel stopListening(String event, {SocketEventHandler? handler});
|
||||
|
||||
Future<void> unsubscribe();
|
||||
}
|
||||
|
||||
abstract class SocketPrivateChannel extends SocketChannel {
|
||||
void whisper(String event, Map<String, dynamic> data);
|
||||
}
|
||||
|
||||
abstract class SocketPresenceChannel extends SocketChannel {
|
||||
SocketPresenceChannel here(void Function(List<Map<String, dynamic>>) handler);
|
||||
|
||||
SocketPresenceChannel joining(void Function(Map<String, dynamic>) handler);
|
||||
|
||||
SocketPresenceChannel leaving(void Function(Map<String, dynamic>) handler);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter_laravel_echo_client/laravel_echo_client.dart';
|
||||
|
||||
abstract class SocketClient {
|
||||
Future<void> connect();
|
||||
|
||||
Future<void> disconnect({bool force = false});
|
||||
|
||||
bool get isConnected;
|
||||
|
||||
void updateAuthToken(String token);
|
||||
|
||||
SocketChannel channel(String name);
|
||||
|
||||
SocketPrivateChannel private(String name);
|
||||
|
||||
SocketPresenceChannel presence(String name);
|
||||
|
||||
Stream<void> get onConnected;
|
||||
|
||||
Stream<void> get onDisconnected;
|
||||
|
||||
Stream<Object> get onError;
|
||||
|
||||
Stream<int> get onReconnectAttempt;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter_laravel_echo_client/src/api/socket_channel.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/core/auth.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/core/payloads.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/core/types.dart';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
|
||||
abstract class BaseChannel implements SocketChannel {
|
||||
BaseChannel(this.name, this.socket, this._authProvider);
|
||||
|
||||
@override
|
||||
final String name;
|
||||
|
||||
/// Публичный сокет (раньше было _socket — private для другого файла).
|
||||
io.Socket? socket;
|
||||
|
||||
/// Провайдер заголовков для auth в subscribe/unsubscribe.
|
||||
final AuthHeadersProvider? _authProvider;
|
||||
|
||||
final Map<String, List<SocketEventHandler>> _handlers = {};
|
||||
final Map<String, void Function(dynamic)> _aggregators = {};
|
||||
final Map<String, String> _boundEventName = {};
|
||||
|
||||
void bind(io.Socket newSocket) {
|
||||
socket = newSocket;
|
||||
// перевесить агрегаторы после реконнекта
|
||||
for (final entry in _aggregators.entries) {
|
||||
final user = entry.key;
|
||||
final bound = _boundEventName[user] ?? normalizeEvent(user);
|
||||
socket?.on(bound, entry.value);
|
||||
}
|
||||
}
|
||||
|
||||
void subscribe() => socket?.emit('subscribe', subscribePayload(name, _authProvider));
|
||||
|
||||
void resubscribe() => subscribe();
|
||||
|
||||
@override
|
||||
SocketChannel listen(String event, SocketEventHandler handler) {
|
||||
final userEvent = event;
|
||||
final socketEvent = normalizeEvent(userEvent);
|
||||
|
||||
_handlers.putIfAbsent(userEvent, () => []).add(handler);
|
||||
|
||||
_aggregators.putIfAbsent(userEvent, () {
|
||||
void aggregator(dynamic payload) {
|
||||
Map<String, dynamic>? data;
|
||||
|
||||
if (payload is Map) {
|
||||
data = Map<String, dynamic>.from(payload);
|
||||
} else if (payload is List && payload.isNotEmpty) {
|
||||
final last = payload.last;
|
||||
if (last is Map) data = Map<String, dynamic>.from(last);
|
||||
}
|
||||
|
||||
if (data != null) {
|
||||
final hs = _handlers[userEvent];
|
||||
if (hs != null) {
|
||||
for (final h in List.of(hs)) {
|
||||
h(userEvent, data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
socket?.on(socketEvent, aggregator);
|
||||
_boundEventName[userEvent] = socketEvent;
|
||||
return aggregator;
|
||||
});
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
@override
|
||||
SocketChannel stopListening(String event, {SocketEventHandler? handler}) {
|
||||
final list = _handlers[event];
|
||||
if (list == null) return this;
|
||||
|
||||
if (handler == null) {
|
||||
list.clear();
|
||||
} else {
|
||||
list.remove(handler);
|
||||
}
|
||||
|
||||
if (list.isEmpty) {
|
||||
final aggregator = _aggregators.remove(event);
|
||||
final bound = _boundEventName.remove(event) ?? normalizeEvent(event);
|
||||
if (aggregator != null) socket?.off(bound, aggregator);
|
||||
_handlers.remove(event);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> unsubscribe() async {
|
||||
socket?.emit('unsubscribe', unsubscribePayload(name, _authProvider));
|
||||
|
||||
for (final entry in _aggregators.entries) {
|
||||
final user = entry.key;
|
||||
final bound = _boundEventName[user] ?? normalizeEvent(user);
|
||||
socket?.off(bound, entry.value);
|
||||
}
|
||||
_aggregators.clear();
|
||||
_handlers.clear();
|
||||
_boundEventName.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter_laravel_echo_client/src/api/socket_channel.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/channels/private_channel.dart';
|
||||
|
||||
class PresenceChannel extends PrivateChannel implements SocketPresenceChannel {
|
||||
PresenceChannel(super.name, super.socket, super.p);
|
||||
|
||||
@override
|
||||
SocketPresenceChannel here(void Function(List<Map<String, dynamic>>) cb) {
|
||||
listen('presence:subscribed', (_, data) {
|
||||
final users = (data['users'] as List?)?.cast<Map>() ?? const [];
|
||||
cb(users.map((e) => Map<String, dynamic>.from(e)).toList());
|
||||
});
|
||||
return this;
|
||||
}
|
||||
|
||||
@override
|
||||
SocketPresenceChannel joining(void Function(Map<String, dynamic>) cb) {
|
||||
listen('presence:joining', (_, data) => cb(Map<String, dynamic>.from(data)));
|
||||
return this;
|
||||
}
|
||||
|
||||
@override
|
||||
SocketPresenceChannel leaving(void Function(Map<String, dynamic>) cb) {
|
||||
listen('presence:leaving', (_, data) => cb(Map<String, dynamic>.from(data)));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import 'package:flutter_laravel_echo_client/src/api/socket_channel.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/channels/base_channel.dart';
|
||||
|
||||
class PrivateChannel extends BaseChannel implements SocketPrivateChannel {
|
||||
PrivateChannel(super.name, super.socket, super.p);
|
||||
|
||||
@override
|
||||
void whisper(String event, Map<String, dynamic> data) {
|
||||
socket?.emit('client event', {'channel': name, 'event': 'client-$event', 'data': data});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import 'package:flutter_laravel_echo_client/src/channels/base_channel.dart';
|
||||
|
||||
class PublicChannel extends BaseChannel {
|
||||
PublicChannel(super.name, super.socket, super.p);
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_laravel_echo_client/src/api/socket_channel.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/channels/base_channel.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/channels/presence_channel.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/channels/private_channel.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/channels/public_channel.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/config/socket_config.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/core/auth.dart';
|
||||
import 'package:flutter_laravel_echo_client/src/core/channel_registry.dart';
|
||||
import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
|
||||
enum _ChannelType { public, private, presence }
|
||||
|
||||
class LaravelEchoSocketClient {
|
||||
LaravelEchoSocketClient(SocketConfig config) : _config = config.copyWith() {
|
||||
_auth = _config.authHeadersProvider ?? bearerProvider(_config.token); // дефолтный Bearer
|
||||
}
|
||||
|
||||
SocketConfig _config;
|
||||
late AuthHeadersProvider _auth;
|
||||
io.Socket? _socket;
|
||||
|
||||
final _onConnected = StreamController<void>.broadcast();
|
||||
final _onDisconnected = StreamController<void>.broadcast();
|
||||
final _onError = StreamController<Object>.broadcast();
|
||||
final _onReconnectAttempt = StreamController<int>.broadcast();
|
||||
|
||||
final _channels = ChannelRegistry<BaseChannel>();
|
||||
|
||||
bool get isConnected => _socket?.connected ?? false;
|
||||
|
||||
Stream<void> get onConnected => _onConnected.stream;
|
||||
|
||||
Stream<void> get onDisconnected => _onDisconnected.stream;
|
||||
|
||||
Stream<Object> get onError => _onError.stream;
|
||||
|
||||
Stream<int> get onReconnectAttempt => _onReconnectAttempt.stream;
|
||||
|
||||
Future<void> connect() async {
|
||||
if (_socket?.connected ?? false) return;
|
||||
|
||||
final opts =
|
||||
io.OptionBuilder()
|
||||
.setTransports(_config.transports)
|
||||
.setPath(_config.path)
|
||||
.setQuery(_config.query ?? {})
|
||||
.setReconnectionDelay(_config.reconnectionDelay.inMilliseconds)
|
||||
.setReconnectionDelayMax(_config.reconnectionDelayMax.inMilliseconds)
|
||||
.setTimeout(_config.ackTimeout.inMilliseconds)
|
||||
.build();
|
||||
|
||||
_socket = io.io(_config.host.toString(), opts);
|
||||
|
||||
_socket!
|
||||
..onConnect((_) {
|
||||
_onConnected.add(null);
|
||||
for (final ch in _channels.values) {
|
||||
ch
|
||||
..bind(_socket!)
|
||||
..resubscribe();
|
||||
}
|
||||
})
|
||||
..onDisconnect((_) => _onDisconnected.add(null))
|
||||
..onConnectError((e) => _onError.add(e))
|
||||
..onError((e) => _onError.add(e))
|
||||
..onReconnectAttempt((n) => _onReconnectAttempt.add(n is int ? n : 0));
|
||||
}
|
||||
|
||||
Future<void> disconnect({bool force = false}) async {
|
||||
for (final ch in _channels.values) {
|
||||
await ch.unsubscribe();
|
||||
}
|
||||
_channels.clear();
|
||||
|
||||
final s = _socket;
|
||||
_socket = null;
|
||||
if (s != null) {
|
||||
s
|
||||
..off('connect')
|
||||
..off('disconnect')
|
||||
..off('connect_error')
|
||||
..off('error')
|
||||
..off('reconnect_attempt')
|
||||
..disconnect();
|
||||
if (force) s.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// Мягкое обновление токена: меняем провайдер и переподключаемся.
|
||||
void updateAuthToken(String token) {
|
||||
_config = _config.copyWith(token: token);
|
||||
_auth = _config.authHeadersProvider ?? bearerProvider(token);
|
||||
|
||||
final s = _socket;
|
||||
if (s != null) {
|
||||
s.io
|
||||
..disconnect()
|
||||
..connect();
|
||||
}
|
||||
}
|
||||
|
||||
SocketChannel channel(String name) => _getOrCreate(name, _ChannelType.public);
|
||||
|
||||
SocketPrivateChannel private(String name) => _getOrCreate(name, _ChannelType.private) as SocketPrivateChannel;
|
||||
|
||||
SocketPresenceChannel presence(String name) => _getOrCreate(name, _ChannelType.presence) as SocketPresenceChannel;
|
||||
|
||||
void leave(String name) {
|
||||
for (final t in _ChannelType.values) {
|
||||
leaveChannel(_fullName(name, t));
|
||||
}
|
||||
}
|
||||
|
||||
void leaveChannel(String fullName) {
|
||||
final ch = _channels.remove(fullName);
|
||||
ch?.unsubscribe();
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
BaseChannel _getOrCreate(String name, _ChannelType type) {
|
||||
final full = _fullName(name, type);
|
||||
|
||||
return _channels.putIfAbsent(full, () {
|
||||
final ch = switch (type) {
|
||||
_ChannelType.public => PublicChannel(full, _socket, _auth),
|
||||
_ChannelType.private => PrivateChannel(full, _socket, _auth),
|
||||
_ChannelType.presence => PresenceChannel(full, _socket, _auth),
|
||||
}..subscribe();
|
||||
return ch;
|
||||
});
|
||||
}
|
||||
|
||||
String _fullName(String name, _ChannelType type) => switch (type) {
|
||||
_ChannelType.public => name,
|
||||
_ChannelType.private => 'private-$name',
|
||||
_ChannelType.presence => 'presence-$name',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter_laravel_echo_client/src/core/auth.dart';
|
||||
|
||||
class SocketConfig {
|
||||
const SocketConfig({
|
||||
required this.host,
|
||||
this.token,
|
||||
this.authHeadersProvider,
|
||||
this.query,
|
||||
this.transports = const ['websocket'],
|
||||
this.ackTimeout = const Duration(seconds: 20),
|
||||
this.reconnectionDelay = const Duration(seconds: 1),
|
||||
this.reconnectionDelayMax = const Duration(seconds: 5),
|
||||
this.reconnectionAttempts = 0,
|
||||
this.path = '/socket.io',
|
||||
});
|
||||
|
||||
final Uri host;
|
||||
final String? token;
|
||||
final AuthHeadersProvider? authHeadersProvider;
|
||||
|
||||
final Map<String, dynamic>? query;
|
||||
final List<String> transports;
|
||||
final Duration ackTimeout;
|
||||
final Duration reconnectionDelay;
|
||||
final Duration reconnectionDelayMax;
|
||||
final int reconnectionAttempts;
|
||||
final String path;
|
||||
|
||||
SocketConfig copyWith({String? token}) => SocketConfig(
|
||||
host: host,
|
||||
token: token ?? this.token,
|
||||
authHeadersProvider: authHeadersProvider ?? bearerProvider(token ?? this.token),
|
||||
query: query,
|
||||
transports: transports,
|
||||
ackTimeout: ackTimeout,
|
||||
reconnectionDelay: reconnectionDelay,
|
||||
reconnectionDelayMax: reconnectionDelayMax,
|
||||
reconnectionAttempts: reconnectionAttempts,
|
||||
path: path,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
typedef AuthHeadersProvider = Map<String, String> Function();
|
||||
|
||||
AuthHeadersProvider bearerProvider(String? token) {
|
||||
return () {
|
||||
if (token == null || token.isEmpty) return <String, String>{};
|
||||
return {'Authorization': 'Bearer $token'};
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import 'package:flutter_laravel_echo_client/laravel_echo_client.dart';
|
||||
|
||||
class ChannelRegistry<C extends SocketChannel> {
|
||||
final _map = <String, C>{};
|
||||
|
||||
C? get(String fullName) => _map[fullName];
|
||||
|
||||
C putIfAbsent(String fullName, C Function() builder) {
|
||||
return _map.putIfAbsent(fullName, builder);
|
||||
}
|
||||
|
||||
C? remove(String fullName) => _map.remove(fullName);
|
||||
|
||||
Iterable<C> get values => _map.values;
|
||||
|
||||
void clear() => _map.clear();
|
||||
|
||||
bool contains(String fullName) => _map.containsKey(fullName);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import 'package:flutter_laravel_echo_client/src/core/auth.dart';
|
||||
|
||||
Map<String, dynamic> subscribePayload(String channel, AuthHeadersProvider? provider) {
|
||||
final payload = <String, dynamic>{'channel': channel};
|
||||
final headers = provider?.call();
|
||||
if (headers != null && headers.isNotEmpty) {
|
||||
payload['auth'] = {'headers': headers};
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
Map<String, dynamic> unsubscribePayload(String channel, AuthHeadersProvider? provider) {
|
||||
return subscribePayload(channel, provider);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
typedef SocketEventHandler = void Function(String event, Map<String, dynamic> data);
|
||||
|
||||
/// ".chat.foo" -> "chat.foo"; "chat.foo" -> "chat.foo"
|
||||
String normalizeEvent(String userEvent) => userEvent.startsWith('.') ? userEvent.substring(1) : userEvent;
|
||||
|
||||
/// "chat.foo" -> ".chat.foo"
|
||||
String userEventName(String socketEvent) => '.$socketEvent';
|
||||
@@ -0,0 +1,9 @@
|
||||
class SocketEvent {
|
||||
SocketEvent({required this.channel, required this.event, required this.data, DateTime? receivedAt})
|
||||
: receivedAt = receivedAt ?? DateTime.now();
|
||||
|
||||
final String channel;
|
||||
final String event;
|
||||
final Map<String, dynamic> data;
|
||||
final DateTime receivedAt;
|
||||
}
|
||||
Reference in New Issue
Block a user