An excerpt from Dart: Up and Running

Chapter 5. Walkthrough: Dart Chat

This chapter points out some of the useful and fun features of Dart that we used to build Dart Chat, a client-server app. If you’d like step-by-step instructions on building Dart Chat, you might be interested in our code lab.

Figure 5.1, “Multiple chat clients can use the chat server to talk” shows the chat client executing in a Dartium window. Each copy of the chat client can send messages to the chat server, which forwards those messages to the other chat clients.

How to Run Dart Chat

The easiest way to run the Dart Chat client and server apps is to open them in Dart Editor.

  1. Download the Dart Chat source code from GitHub.

  2. In Dart Editor, use File > Open Folder..., to open the finished directory of the Dart Chat source code.

  3. Select chat-server.dart, and then click the Run button . A view named chat-server appears in Dart Editor, displaying debugging output for the server.

  4. Select client/chat-client.dart, and then click the Run button . Dartium launches, if necessary, and displays a Dart Chat tab.

  5. To create another copy of the chat client, go to the Dart Chat tab in Dartium. Right-click the tab, and choose Duplicate.

Figure 5.1. Multiple chat clients can use the chat server to talk

Multiple chat clients can use the chat server to talk

How Dart Chat Works

The chat server and client are simple. The chat server is an HTTP server that provides a WebSocket. The chat client uses that WebSocket for a bi-directional communication channel with the server. The client sends chat messages to the server over the WebSocket, and the server relays those messages to all other connected clients.

As Figure 5.2, “Chat clients connect to a web socket created by the chat server” shows, the server starts things off by listening for requests to ws://127.0.0.1:1337/ws. Chat clients then connect to that URL.

Figure 5.2. Chat clients connect to a web socket created by the chat server

Chat clients connect to a web socket created by the chat server

The real communication between client and server happens when the user enters a message. As Figure 5.3, “A chat client uses the server to send a message to other chat clients” shows, the chat client sends a JSON-encoded version of the message to the server. The server then forwards the message to every client except the one that sent it.

Figure 5.3. A chat client uses the server to send a message to other chat clients

A chat client uses the server to send a message to other chat clients

The chat server implements an HTTP server to listen for WebSocket requests. The HTTP server can also serve static files from the client directory—for example, http://127.0.0.1:1337/chat-log.txt shows the file that’s at client/chat-log.txt.

The client code is split between HTML (page structure), CSS (page look), and JavaScript (logic and behavior). That’s typical of web clients.

The twist is that this client’s JavaScript code is produced from Dart code, thanks to the dart2js compiler. Any modern browser can run this JavaScript code. Dartium (and any other browsers that support Dart) can run either the JavaScript code or the original Dart code.

The Client’s HTML Code

The main elements in the client UI are two text fields (with the IDs “chat-username” and “chat-message”) and a status area (ID: “chat-display”):

lang-html
<!-- In client/index.html: -->
<textarea id="chat-display" rows="10" disabled></textarea>
...
<input id="chat-username" name="chat-username" type="text">
...
<input id="chat-message" name="chat-message" type="text" disabled 
    value="enter message...">

Near the bottom of client/index.html, a couple of <script> tags tell the browser to execute the client’s Dart or JavaScript code:

lang-html
<script type="application/dart" src="chat-client.dart"></script>
<script src="packages/browser/dart.js"></script>

The first line works in browsers that have an embedded Dart VM and so can execute Dart code; currently, only Dartium qualifies. The second line is important for every other browser. It executes dart.js, which is a standard script that converts all Dart <script> tags to use foo.dart.js instead of foo.dart, with the assumption that foo.dart.js is a JavaScript version of foo.dart. For non-Dartium browsers, dart.js changes the first <script> tag to the following:

lang-html
<!-- Inserted by dart.js for non-Dartium browsers -->
<script src="chat-client.dart.js"></script>

The script contents run when the browser has loaded the HTML and constructed its DOM (document object model).

You can get dart.js with the browser package from pub. See the section called “dart2js: The Dart-to-JavaScript Compiler” for more information about compiling Dart code into its JavaScript equivalent.

The Client’s Dart Code

Dart code (client/chat-client.dart) provides the client’s logic, using the DOM to interact with UI elements. For example, the client’s Dart code uses the DOM to find the text area where the client displays messages.

Finding DOM Elements

The client app uses dart:html’s top-level query() method to get the client’s UI elements from the DOM:

lang-dart
// In client/chat-client.dart:
import 'dart:html';
//...
TextAreaElement chatElem = query('#chat-display');
InputElement usernameElem = query('#chat-username');
InputElement messageElem = query('#chat-message');

The query() method uses a selector string that identifies an element in the DOM. See the section called “Finding elements” for more about selectors.

Wrapping DOM Elements

Rather than always dealing with DOM APIs, the chat client wraps the elements in Dart objects:

lang-dart
new ChatWindow(chatElem);
usernameInput = new UsernameInput(usernameElem);
messageInput = new MessageInput(messageElem);

ChatWindow, UsernameInput, and MessageInput are custom classes that extend another custom class called View. These Views effectively separate the DOM manipulation from the application logic.

Because Dart has real classes and inheritance, it’s simple to express the relationship that ChatWindow is-a View. Here’s the complete code for UsernameInput:

lang-dart
class UsernameInput extends View<InputElement> {
  UsernameInput(InputElement elem) : super(elem);

  bind() { // Called by the View constructor.
    elem.onChange.listen((e) => _onUsernameChange());
  }

  _onUsernameChange() {
    if (!elem.value.isEmpty) {
      messageInput.enable();
    } else {
      messageInput.disable();
    }
  }

  String get username => elem.value;
}

To get the string that’s in the chat-username field, the client app uses the username getter of a UsernameInput object. For example:

lang-dart
chatWindow.displayMessage(message, usernameInput.username);

Notice how the code uses generics (View<InputElement>) to specify what kind of element the View class can encapsulate. In the preceding example, the UsernameInput wraps an InputElement. Expressing this gives tools information that they can use to identify bugs or improve code completion.

Wrapping elements is a technique you can use as you develop a simple app that might evolve into a larger app. As the app grows, you might change it to use Web UI, a library for declarative widgets and dynamic, data-driven views.

Updating DOM Elements

The bind() method sets up the event handlers, which bind events from the DOM to logic in the Dart objects. For example, in UsernameInput, the _onUsernameChange() method is called any time the text in the input element changes.

To display messages in the chat window, the ChatWindow class adds the message to the text node of the text area:

lang-dart
class ChatWindow extends View<TextAreaElement> {
  ChatWindow(TextAreaElement elem) : super(elem);
  
  displayMessage(String msg, String from) {
    _display('$from: $msg\n');
  }
  
  displayNotice(String notice) {
    _display('[system]: $notice\n');
  }
  
  _display(String str) {
    elem.addText(str);
  }
}

In both examples, the View objects expose an application-specific API—for example, displayMessage() or _onUsernameChange()—and encapsulate the manipulation of DOM elements.

Encoding and Decoding Messages

The dart:json library encodes and decodes JSON-formatted messages. JSON is an easy way to provide string message data to WebSockets. Using JSON also gives a bit of structure to the messages and leaves the door open to creating more detailed messages in the future.

The stringify() method converts a Dart object to a JSON-encoded string, and the parse() method converts a JSON string back into a Dart object. Here’s the JSON-related code from the chat client:

lang-dart
import 'dart:json' as json;
var encoded = json.stringify({'f': from, 'm': message});
Map message = json.parse(encodedMessage);

See the section called “dart:json - Encoding and Decoding Objects” for more information.

Communicating with WebSockets

The custom class ChatConnection takes care of the chat client’s WebSocket communication. First it connects to the WebSocket by calling the WebSocket constructor with the argument 'ws://127.0.0.1:1337/ws'. Then ChatConnection adds event handlers for open, close, error, and message events. For example, here’s the code that responds to message events:

lang-dart
webSocket.onMessage.listen((MessageEvent e) {
  print('received message ${e.data}');
  _receivedEncodedMessage(e.data);
});

The _receivedEncodedMessage() method just parses the JSON data and displays it in the status area:

lang-dart
_receivedEncodedMessage(String encodedMessage) {
  Map message = json.parse(encodedMessage);
  if (message['f'] != null) {
    chatWindow.displayMessage(message['m'], message['f']);
  }
}

To send a message on the WebSocket connection, _sendEncodedMessage() ensures the WebSocket connection is ready and then sends the JSON-encoded message:

lang-dart
// In the ChatConnection class:
send(String from, String message) {
  var encoded = json.stringify({'f': from, 'm': message});
  _sendEncodedMessage(encoded);
}

_sendEncodedMessage(String encodedMessage) {
  if (webSocket != null && webSocket.readyState == WebSocket.OPEN) {
    webSocket.send(encodedMessage);
  } else {
    print('WebSocket not connected, message $encodedMessage not sent');
  }
}

In the event of a connection problem, the client code attempts to reconnect to the WebSocket server. The following code takes advantage of Dart’s nested functions, nesting the scheduleReconnect() function inside of _init(). Dart’s lexical scoping ensures that scheduleReconnect() can see variables from _init().

lang-dart
_init([int retrySeconds = 2]) {
  bool encounteredError = false;
  chatWindow.displayNotice('Connecting to Web socket');
  webSocket = new WebSocket(url);
  
  scheduleReconnect() {
    chatWindow.displayNotice('socket closed, retrying in $retrySeconds seconds');
    if (!encounteredError) {
      new Timer(new Duration(seconds:retrySeconds),
          () => _init(retrySeconds*2));
    }
    encounteredError = true;
  } 
  //...
  webSocket.onClose.listen((e) => scheduleReconnect());
  webSocket.onError.listen((e) => scheduleReconnect());

The reconnect logic uses new Timer() to schedule a retry using an exponential backoff algorithm.

The Server’s Code

The chat-server.dart file contains most of the code used in the chat server. It is responsible for serving static files and managing WebSocket connections. The chat server also logs the chat messages to a file.

Serving Static Files

The chat server uses dart:io’s HttpServer to implement a web server. The default request handler is configured to serve static files from a specific directory on the file system.

lang-dart
runServer(String basePath, int port) {
  ChatHandler chatHandler = new ChatHandler(basePath);
  StaticFileHandler fileHandler = new StaticFileHandler(basePath);
  
  HttpServer.bind('127.0.0.1', port)
    .then((HttpServer server) {
      print('listening for connections on $port');
      
      var sc = new StreamController();
      sc.stream.transform(new WebSocketTransformer()).listen(chatHandler.onConnection);

      server.listen((HttpRequest request) {
        if (request.uri.path == '/ws') {
          sc.add(request);
        } else {
          fileHandler.onRequest(request);
        }
      });
    },
    onError: (error) => print("Error starting HTTP server: $error"));
}

The StaticFileHandler first gets the file contents using File and Stream. It then sends those contents using the HttpResponse object from the request.

Because I/O can cause delays, due to variable network or disk bandwidth conditions, the chat server uses asynchronous I/O to handle HTTP requests while still being responsive to other requests. Each I/O request returns a Future, allowing the server to continue executing without waiting for the I/O to complete.

For example, in the following snippet the exists() method returns a Future. When the Future completes (with a value of true if the file exists, or false if it doesn’t), the function specified to then() executes.

lang-dart
// Respond to HTTP requests for static files.
onRequest(HttpRequest request) {
  final String path =
      request.uri.path == '/' ? '/index.html' : request.uri.path;
  final File file = new File('${basePath}${path}');
  file.exists().then((bool found) {
    if (found) {
      file.fullPath().then((String fullPath) {
        if (!fullPath.startsWith(basePath)) {
          _send404(request.response);
        } else {
          file.openRead().pipe(request.response)
            .catchError((e) => print(e));
        }
      });
    } else {
      _send404(request.response);
    }
  });
}

See the section called “dart:async - Asynchronous Programming” for more information about using Future, and the section called “Files and Directories” for details on file and directory I/O.

Managing WebSocket Connections

In addition to serving static files, the chat server manages WebSocket connections, routing chat messages between clients. The dart:io WebSocketTransformer class accepts HTTP connections, converts them into WebSocket connections, and then passes them to ChatHandler.

lang-dart
runServer(String basePath, int port) {
  //...
  var sc = new StreamController();
  sc.stream.transform(new WebSocketTransformer())
      .listen(chatHandler.onConnection);
}

ChatHandler is a custom class that takes care of all WebSocket communication for the chat server. Here is its implementation:

lang-dart
class ChatHandler {
  Set<WebSocketConnection> connections;
  //...
  onConnection(WebSocket conn) {
    void onMessage(message) {
      print('new ws msg: $message');
      webSocketConnections.forEach((connection) {
        if (conn != connection) {
          print('queued msg to be sent');
          queue(() => connection.send(message));
        }
      });
      time('send to isolate', () => log.log(message));
    }
    
    print('new ws conn');
    webSocketConnections.add(conn);
    conn.listen(onMessage,
      onDone: () => webSocketConnections.remove(conn),
      onError: (e) => webSocketConnections.remove(conn)
    );
  }
}

When a client connects, the server adds the client’s WebSocket connection to a collection. When the client disconnects (either through an error or on purpose), the server removes that client’s connection from the collection. When a new message arrives, the server sends the message to all connected clients except the original source.

Logging Messages to a File

The chat server logs data to a file, client/chat-log.txt, using a custom library implemented in file-logger.dart. This library uses an isolate to handle file I/O without tying up the root isolate. Here’s the code that creates and starts this isolate:

lang-dart
SendPort _loggingPort = spawnFunction(startLogging);

The value returned by dart:isolate’s spawnFunction() is a SendPort object. Because isolates share no data, messages sent to ports are the only way for the root isolate to communicate with the spawned isolate.

The argument to spawnFunction() points to the startLogging() function, which implements the logging isolate. The logic for the logging isolate is simple: the first message specifies the log file location, and subsequent messages provide data to write to the log file.

lang-dart
startLogging() {
  print('started logger');
  File logFile;
  IOSink out;
  port.receive((msg, replyTo) {
    if (logFile == null) {
      print('Opening file $msg');
      logFile = new File(msg);
      out = logFile.openWrite(mode: FileMode.APPEND);
    } else {
      time('write to file', () {
        out.write('${new DateTime.now()} : $msg\n');
      });
    }
  });
}

In the preceding code, the port property used by startLogging() refers to a ReceivePort provided by dart:isolate. The port is how this isolate gets data from the root isolate. If this isolate needed to send messages back to the root isolate, it could use the replyTo argument (a SendPort) to do so.

Recall that in the root isolate, the _loggingPort variable holds a SendPort that the root isolate uses to send messages to the logging isolate. Every time the chat server calls the log() method, the root isolate sends the log data:

lang-dart
void log(String message) {
  _loggingPort.send(message);
}

See the section called “dart:isolate - Concurrency with Isolates” for more information about using isolates.

What Next?

You’ve seen how the Dart Chat sample uses both server-side and client-side Dart code to implement a web app. Here are some other samples you might want to look at:

  • Solar, which simulates the solar system with animations in a canvas, using requestAnimationFrame().

  • Spirodraw, a fun, interactive tool to build colorful works of art.

Finally, please visit our website and join the discussion. We look forward to hearing from you and answering your questions!