logo

Keen is a minimalist and opinionated real-time server written in Dart.

What is Keen ?

Each tool corresponds to a task, and too often we see hyper-scalable server architectures that end up being used by a few hundreds of simultaneous active users, maybe only for company small internal use cases. Cloud is cool, and distribution can be useful when performance is needed, but should we really gather all of this for simple use cases ?

Keen aims to fasten the development process of real-time servers, whether it is for prototyping purpose, or to create small-sized apps. It brings a simple exchange protocol and a simple authentification system based on one-time passwords sent by email.

Core principles

One server and multiple clients

Since Keen is designed around simplicity, you can only have a unique server. A set of clients can connect to it at any time, and a session will be created for each one of them.

A simple protocol : two classes and json

The server protocol is based on a websocket where a client sends Action events to a server, and the server sends Update events to the clients.

Action and Update should be defined as two dart object definitions that can be serialized to JSON.

The freezed package is a great solution for defining those two classes.

Implement your protocol

You first need to define action and update classes.

If we take a real-time chat example, we could have this :

/// Sent from client to server.
abstract class ChatAction {
  const ChatAction();

  Map<String, dynamic> toJson();

  factory ChatAction.fromJson(Map<String, dynamic> json) {
    if (json != null) {
      if (json['type'] == 'sendMessage') {
        return SendMessageAction.fromJson(json);
      }
    }
    return null;
  }
}

/// Send a message to other users in the chat.
class SendMessageAction extends ChatAction {
  final String content;
  const SendMessageAction(this.content);

   factory SendMessageAction.fromJson(Map<String, dynamic> json) =>
      SendMessageAction(json['content']);

  @override
  Map<String, dynamic> toJson() => {
        'type': 'sendMessage',
        'content': content,
      };
}
/// Sent from server to clients.
abstract class ChatUpdate {
  const ChatUpdate();
  
   Map<String, dynamic> toJson();

  factory ChatUpdate.fromJson(Map<String, dynamic> json) {
    if (json != null) {
      if (json['type'] == 'receivedMessage') {
        return ReceivedMessageUpdate.fromJson(json);
      }
    }
    return null;
  }
}

/// An other user sent a message.
class ReceivedMessageUpdate extends ChatUpdate {
  final String email;
  final String content;
  const ReceivedMessageUpdate({
    @required this.content,
    @required this.email,
  });

   factory ReceivedMessageUpdate.fromJson(Map<String, dynamic> json) =>
      ReceivedMessageUpdate(
        email: json['email'],
        content: json['content'],
      );

    @override
    Map<String, dynamic> toJson() => {
        'type': 'receivedMessage',
        'email': email,
        'content': content,
      };
}

Implement a server

To implement your api, you just have to implement the KeenAuthApiServer<Action,Update> class.

This class has an onAction callback when a user sends an action. Updates can be sent to sessions.

Here is an example for our previous chat example :

class ChatApi extends KeenAuthApiServer<ChatAction, ChatUpdate> {
  ChatApi()
      : super(
          actionDeserializer: (json) => ChatAction.fromJson(json),
          updateSerializer: (update) => update.toJson(),
        );

  @override
  FutureOr<ChatUpdate> onAction(User session, ChatAction action) {
    // We send a received message to all other connected users
    if (action is SendMessageAction) {
      for (var otherSession in sessions.where((x) => x.info.id != session.id)) {
        otherSession.update(
          ReceivedMessageUpdate(
            email: session.email,
            content: action.content,
          ),
        );
      }
    }
    return null; // You can send an update to the caller if you want to
  }
}

You can then start a KeenServer<Action,Update> with your API and the authentication setup.

Future<void> main(List<String> args) async {
    final server = KeenServer<ChatAction, ChatUpdate>(
    auth: KeenAuthServer(
      // This is a private key used to sign tokens
      signKey: 'my-signing-key-for-tokens',
      // FIXME only for testing, users are not persisted!
      // You can use PostgresUserStorage 
      userStorage: MemoryUserStorage(), 
      emailer: (request) async {
        print(
            "Sent email to '${request.email}' with password '${request.encryptedOneTimePassword}' !");
        // TODO Send an email with the encryptedOneTimePassword (app link for exemple)
        // You can use the provided SmtpKeenEmailer
      },
    ),
    // The previously defined API
    api: ChatApi(),
  );

  await server.run(
    address: InternetAddress.loopbackIPv4,
    port: 4040,
  );
}

Using the client

All interractions with the servers occurs trough a KeenClient<Action, Update> instance :

final client = KeenClient<ChatAction, ChatUpdate>(
    url: 'ws://localhost:4040',
    actionSerializer: (action) => action.toJson(),
    updateDeserializer: (json) => ChatUpdate.fromJson(json),
);

Authentication process

Since users must be authenticated to access the API, you first need to get a token from the server :

print('Requesting a password to $email...');
final request = await client.auth.sendOneTimePasswordByEmail(
  email: email,
  deviceName: 'Console',
);

print('Please type the password received by email:');
final oneTimePassword = stdin.readLineSync(encoding: utf8).trim();

print('Requesting a token...');
final token = await client.auth.getToken(
  sharedSecret: request,
  oneTimePassword: oneTimePassword,
);

API calls

Then to talk to the server, simply subscribe to updates and send actions with action.

final api = await client.connect(token);

// Listening for updates from server
api.updates.listen((update) {
    if (update is ReceivedMessageUpdate) {
      print('[${update.email}] ${update.content}');
    }
  });

  // Sending lines as actions to server
  stdin
      .transform(
        utf8.decoder,
      )
      .transform(
        LineSplitter(),
      )
      .listen(
    (line) async {
      print('[ME] $line');
      await api.action(
        SendMessageAction(
          line.trim(),
        ),
      );
    },
  );
}

Libraries

keen_client
keen_server