Skip to content

Broadcasting & WebSocket support (Laravel Echo equivalent) #37

@anilcancakir

Description

@anilcancakir

Problem

Magic framework has no broadcasting/WebSocket abstraction. Apps must reimplement the entire Pusher-compatible WebSocket client from scratch — connection management, channel subscriptions, private channel authentication, event parsing, reconnection logic, deduplication, and heartbeat.

This is a core piece of the Laravel ecosystem that's missing on the Flutter side. Laravel has Broadcasting + Echo; magic should have its equivalent.

Current State (Kodizm app-level implementation)

We built a full Pusher-compatible client at the app level (~350 lines). Here's what it handles:

Connection Lifecycle

  • WebSocket connect to ws://{host}/app/{key} (Reverb/Pusher protocol)
  • Ping/pong heartbeat from server's activity_timeout
  • Exponential backoff reconnect (2^attempt, capped 30s)
  • Connection state tracking (connecting, connected, disconnected, reconnecting)
  • onReconnect stream for catch-up after reconnection

Channel Management

  • subscribe(channel) / unsubscribe(channel)
  • Private channel auth via HTTP POST to /broadcasting/auth (sends socket_id + channel_name, receives auth signature)
  • Presence channel support (not yet implemented but protocol supports it)
  • Subscription queuing during connection

Event Handling

  • Pusher envelope parsing: {"event":"App\\Events\\Foo","channel":"private-x","data":"{...}"}
  • Double-JSON decode (Reverb encodes payload as JSON string inside JSON)
  • Per-channel event listeners with Stream<WebSocketEvent>
  • Event deduplication via ring buffer (channel:event:data hash, max 100)

What we have to wire manually every time

// Service registration
Magic.app.singleton('websocket', () => WebSocketService());

// Boot — connect after auth
final ws = Magic.make<WebSocketService>('websocket');
await ws.connect(
  host: Config.get('websocket.host'),
  port: Config.get('websocket.port'),
  appKey: Config.get('websocket.app_key'),
  authEndpoint: Config.get('websocket.auth_endpoint'),
  authHeaders: {'Authorization': 'Bearer ${Auth.token()}'},
);

// Subscribe in state classes
final stream = ws.channel('private-conversation.$id');
stream.listen((event) => _handleEvent(event));

Proposed Architecture

1. Config (config/broadcasting.dart)

Mirror Laravel's broadcasting config:

Map<String, dynamic> get broadcastingConfig => {
  'broadcasting': {
    'default': 'reverb',
    'connections': {
      'reverb': {
        'driver': 'reverb',
        'host': env('REVERB_HOST', 'localhost'),
        'port': int.tryParse(env('REVERB_PORT', '8080')) ?? 8080,
        'scheme': env('REVERB_SCHEME', 'ws'),
        'app_key': env('REVERB_APP_KEY', ''),
        'auth_endpoint': '/broadcasting/auth',
        'reconnect': true,
        'max_reconnect_attempts': null,  // infinite
        'activity_timeout': 30,
      },
      'pusher': {
        'driver': 'pusher',
        'key': env('PUSHER_APP_KEY', ''),
        'cluster': env('PUSHER_APP_CLUSTER', 'mt1'),
        'encrypted': true,
      },
      'null': {
        'driver': 'null',
      },
    },
  },
};

2. Facades

// Echo facade — primary API (mirrors Laravel Echo)
Echo.channel('team.5');                        // public channel
Echo.private('conversation.uuid');             // private channel  
Echo.join('session.uuid');                     // presence channel
Echo.listen('conversation.uuid', '.message', (event) => ...);
Echo.leave('conversation.uuid');
Echo.disconnect();

// Low-level access
Echo.connection;                                // BroadcastConnection instance
Echo.socketId;                                  // for excluding self from broadcasts

3. Contracts

/// Connection driver contract
abstract class BroadcastDriver {
  Future<void> connect();
  Future<void> disconnect();
  String? get socketId;
  bool get isConnected;
  Stream<BroadcastConnectionState> get connectionState;
  Stream<void> get onReconnect;
  BroadcastChannel channel(String name);
  BroadcastChannel private(String name);
  BroadcastPresenceChannel join(String name);
  void leave(String name);
}

/// Channel contract
abstract class BroadcastChannel {
  String get name;
  Stream<BroadcastEvent> get events;
  BroadcastChannel listen(String event, void Function(BroadcastEvent) callback);
  void stopListening(String event);
}

/// Presence channel extends with member tracking
abstract class BroadcastPresenceChannel extends BroadcastChannel {
  List<Map<String, dynamic>> get members;
  Stream<Map<String, dynamic>> get onJoin;
  Stream<Map<String, dynamic>> get onLeave;
}

/// Parsed event
class BroadcastEvent {
  final String event;
  final String channel;
  final Map<String, dynamic> data;
  final DateTime receivedAt;
}

4. Drivers

Driver Use Case
ReverbDriver Laravel Reverb (Pusher-compatible WebSocket)
PusherDriver Pusher Channels (cloud)
NullDriver Local dev / testing (no-op)

5. BroadcastManager

Same pattern as LogManager — resolves driver from config, caches connection:

class BroadcastManager {
  static final Map<String, BroadcastDriver Function(Map<String, dynamic>)> _customDrivers = {};
  
  /// Register a custom driver factory (same as LogManager.extend from #33).
  static void extend(String name, BroadcastDriver Function(Map<String, dynamic>) factory) {
    _customDrivers[name] = factory;
  }
  
  BroadcastDriver connection([String? name]) { ... }
}

6. Auth Integration

Private/presence channel auth should automatically use Magic's Auth.token():

// Internal — BroadcastManager handles this
Future<Map<String, String>> _authenticate(String channel, String socketId) async {
  final response = await Http.post(authEndpoint, data: {
    'socket_id': socketId,
    'channel_name': channel,
  });
  return response.data;
}

7. Service Provider Lifecycle

// register() — bind BroadcastManager singleton
Magic.app.singleton('broadcasting', () => BroadcastManager());

// boot() — auto-connect if auth'd and driver configured
if (Auth.check() && Config.get('broadcasting.default') != 'null') {
  await Echo.connect();
}

8. Built-in Resilience (from our production learnings)

These should be framework-level, not app-level:

  • Exponential backoff reconnect with configurable cap
  • Event deduplication (Reverb sometimes double-delivers)
  • Subscription queue during reconnection (auto-resubscribe on reconnect)
  • Heartbeat/ping-pong from server's activity_timeout
  • Connection state stream for UI indicators
  • onReconnect stream for app-level catch-up logic

Prior Art

Framework Flutter Equivalent
Laravel Broadcasting This proposal
Laravel Echo (JS) Echo facade
Pusher Channels Flutter pusher_channels_flutter (heavy, native deps)
Laravel Reverb Server-side, no client changes needed

Related Issues

All four issues (#31#34) together complete the "Laravel parity" story for magic framework — routing observers, HTTP plugins, logging drivers, and now real-time broadcasting.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions