Friday, June 30, 2006

More on Ajax/Comet and Server-side vs Client-side Polling

I did some more searching and reading, and my previous entry here was not really "Comet". Comet is synonymous with slow load or "reverse AJAX", and uses a hidden frame to keep a connection literally open to the server. The browser window (at least in FF 1.5) shows the page as continuously loading.

Since I could not originally figure out how to keep the connection open - this is, AFAIK, impossible using the XMLHttpRequest object - I artificially replicated a listening function. Basically like regular AJAX-style polling, but with the onus of the polling placed on the in-memory user array on the server side. The technique avoids connections to the server by the client at regular intervals, and does not seem to add much work to the web server to poll the queue for each user at 100ms intervals. With high traffic however you could potentially get a lot more connections, and I'm not completely sure how or when the framework will release the thread if the connection times out without some more policing on the server-side polling algorithm.

It is pretty responsive in the testing I have done so far, though I have limited resources to really scale the thing up. It is nice to have a more-or-less instant response from the server and not have to poll from the client. I also like the degree of control that the XMLHttpRequest offers. I'll have to play around with the slow load a bit more and see how it compares. Slow load would certainly seem to be more efficient in terms of resources, both on the client and on the server.

Wednesday, June 28, 2006

Ajax/Comet chat application

The Idea

Until recently, there was no way to emulate a full-featured network application through HTTP. Along came AJAX, and it is now the next hottest buzzword in the realm of web development. Suddenly everyone is pushing to use AJAX - including for things that don't really need it, which I guess is always the case with the hottest buzzword of the moment. There are a lot of additional considerations for interoperability with non-JS browsers, security concerns, and other things that probably haven't even been thought of yet.

I've worked a little with the XMLHttpRequest object that makes the Asynchronous aspect of AJAX work. I put some javascript callback code into my wiki project, and generally liked the results. You can definitely add a lot of functionality to an existing web application, and it's cool to see new things come out (like Google Maps) that wouldn't be possible at all without it.

I kept thinking that something more, surely, could be done with this, not just in terms of web applications but, more generally, how the asyncrhonous request was used at all. One day walking home, it struck me - what if you instantiated a connection to the web server and just sort of...left it open? Could you get a real socket-type connection out of a web page with some javascript and server-side trickery? I thought about it for a few minutes and was convinced that, somehow, it was surely possible. I got home and did a bit of googling and...

Damn, someone else thought of it first. Google Chat, meebo.com, and a handful of others appear to be doing this very thing, giving something equivalent to a persistent connection to a connectionless protocol. The term for this is "Comet", though as of this writing you won't find many sites that refer to Comet in regards to web development.

A Simple Comet chat implementation in Javascript/C#/ASP.NET

A couple hours later, I had a working chat application that used the idea. I have no idea what others have done to achieve the same goal. I ended up using two XMLHttpRequest objects. To get the persistent connection, one object was designated the listener. The listener does a POST to a webpage. The server does not return a reply, however, until there is a message for the user. As soon as the server sends a response, I just launched the post request again, and held any additional messages in a hashtable on the server so nothing gets dropped when the response is being sent and the connection re-established. The other object is used to handle client-originated responses.

I'm sure there are a host of issues with this. I know that it does not currently handle timeouts or other error responses from the server; I really built in no error handling, but it works as a proof of concept. Firefox 1.0.4 on my fiancee's computer also didn't like it very much, and I still have not figured out what the problem is there. The list goes on, but without further ado, here's what I did. If I can work out some of the kinks I may start a project on Sourceforge with what comes out of the base code.

I would really like to apply this to something a bit more interesting, like an html interface for mud servers.

For this code to work, enter your name then click the Start button to start the listener. The code loses a lot of (sometimes important) formatting when I pasted it into the blog entry; email me at <calberty AT gmail DOT com> for the source files. I will also try to post a follow up with links soon.

Listing 1 - AjaxCore.js

var xhrUser = InstantiateRequestObject();
var xhrListener = InstantiateRequestObject();

//StartListening();

function InstantiateRequestObject()
{
var xhr = null;
try
{
xhr = new ActiveXObject("Msxml2.xmlHttp");
}
catch (e)
{
try
{
xhr = new ActiveXObject("Microsoft.xmlHttp");
}
catch (ex)
{
xhr = false;
}
}

if (!xhr && typeof(XMLHttpRequest)!= 'undefined')
{
xhr = new XMLHttpRequest();
}

return xhr;
}

function PostUserRequest(requestString)
{
//Just a regular user request
try{
xhrUser.open("POST", "chat.aspx", true);
xhrUser.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
xhrUser.send("Thread=User&" + requestString);

} catch(e) {
alert("UserRequest: " + e);
}
}

function PostListenerRequest(requestString)
{
//The listener request...
try{
xhrListener.open("POST", "chat.aspx", true);
xhrListener.setRequestHeader('Content-Type','application/x-www-form-urlencoded');
xhrListener.onreadystatechange = OnListenerRequestStateChange;
xhrListener.send("Thread=Listener&" + requestString);
} catch(e) {
//alert(e);
}

}

function StartListening()
{
var txtName;
txtName = document.getElementById("txtName");
PostListenerRequest("Action=StartSession&Name=" + txtName.value);
}

function OnUserRequestStateChange()
{
if (
(xhrUser.readyState == 4) && (xhrUser.status == 200) ) {
var txtConversation = document.getElementById("txtConversation");
txtConversation.value += xhrUser.responseText + "\n";
}
}

function OnListenerRequestStateChange()
{
if ( (xhrListener.readyState == 4) && (xhrListener.status == 200) )
{
var txtConversation = document.getElementById("txtConversation");
txtConversation.value += xhrListener.responseText;

//continue listening
PostListenerRequest("Action=Listen");
}
}

function SendText(message)
{
//alert('sending request - ' + message);
PostUserRequest("Message=" + escape(message));
x = null;
}

Listing 2 - chat.aspx

<%@ Page Language="C#" AutoEventWireup="true" CodeFile="chat.aspx.cs" Inherits="chat" %>

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">

<html xmlns="http://www.w3.org/1999/xhtml" >
<head runat="server">
<title>Untitled Page</title>

<script type="text/javascript" src="AjaxCore.js"></script>
</head>
<body>
<form id="form1" runat="server">
<div>
<textarea id="txtConversation" cols=30 rows=20></textarea>
<br />
Type your message here:
<br />
<textarea id="txtText" cols=30 rows=5 ></textarea>
<input type=button value="Send" id="btnSend" onclick="Javascript:SendText(txtText.value);" />
<br />
Type your name nere:
<input type=text id="txtName" />
<br />
<input type=button value="Start Listening" id=btnListen onclick="Javascript:StartListening();" />
</div>
</form>
</body>
</html>

Listing 3 - chat.aspx.cs

using System;
using System.Data;
using System.Configuration;
using System.Collections;
using System.Threading;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;

public partial class chat : System.Web.UI.Page
{
ChatUser currentUser = null;

protected void Page_Load(object sender, EventArgs e)
{
if (Request.HttpMethod == "POST")
{
//Create a new user for this session if needed
if (ChatUserController.Users[Session.SessionID] == null)
{
currentUser = new ChatUser();
currentUser.Name = (Request["Name"] != null) ? Request["Name"].ToString() : "anonymous";
ChatUserController.Users[Session.SessionID] = currentUser;
}
else //get the already existing user
{
currentUser = (ChatUser)(ChatUserController.Users[Session.SessionID]);
}

//handle user input
if (Request["Thread"] != null)
{
switch (Request["Thread"].ToString())
{
case "User":
HandleUserRequest();
break;
case "Listener":
HandleListenerRequest();
break;
}
}
}
}

private void HandleUserRequest()
{
ChatUser user = null;
//add the message to each user's queue
foreach (DictionaryEntry d in ChatUserController.Users)
{
user = (ChatUser)(d.Value);
lock (user)
{
user.MessageQueue += currentUser.Name + ": " + Request["Message"].ToString() + "\n";
user.HasMessages = true;
}
}
//Response.Write("ack");
Response.End();
}

private void HandleListenerRequest()
{
bool queueFlushed = false;
while (!queueFlushed)
{
//block until there's a message to process
if (currentUser.HasMessages)
{
lock (currentUser)
{
Response.Write(currentUser.MessageQueue);
Response.Flush();
currentUser.MessageQueue = "";
currentUser.HasMessages = false;
}
queueFlushed = true;
}
else //poll the user's message queue at 100 ms intervals
{
Thread.Sleep(100);
}
}

//if the queue had messages, end the request. A new one should be launched immediately by the client listener
Response.End();

}
}

Listing 4 - chatcode.cs

using System;
using System.Data;
using System.Collections;
using System.Configuration;
using System.Web;
using System.Web.Security;
using System.Web.UI;
using System.Web.UI.WebControls;
using System.Web.UI.WebControls.WebParts;
using System.Web.UI.HtmlControls;

public static class ChatUserController
{
public static Hashtable Users = new Hashtable();
}

public class ChatUser
{
public string MessageQueue;
public string Name;
public bool HasMessages;

public ChatUser()
{
HasMessages = false;
MessageQueue = "";
Name = "";
}
}