keen 0.1.0-alpha.1 keen: ^0.1.0-alpha.1 copied to clipboard
Keen is a minimalist and opinionated real-time server written in Dart.
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(),
),
);
},
);
}