スキップしてメイン コンテンツに移動

サーバプッシュの現在 - AJAXの辺縁

プッシュテクノロジと呼ばれる、Web通信をデータ放送に見立てた一連のWeb技術が1990年代後半に注目され、Webブラウザによる実装も試みられたことがあった。通常のHTTP接続はWebブラウザがリクエストをサーバに送りつけるとレスポンスを1つしか返さないのに対し、Content-type: multipart/x-mixed-replaceを利用したHTTP上でのサーバプッシュでは、クライアントのリクエスト無しで第二第三のメッセージが連続してサーバから送られてくるように見える。これを利用するとユーザの介入無く動的なページの表示が可能だった。ところがこの方法はNetscapeブラウザしか対応しておらず、Internet Explorerの台頭と共に完全に死滅することになった。

OSの最大シェアを握るMicrosoftの影響力はやはり強く、何年もアップデートされないIEがWebの進化を停滞させているという批判の一方で、画面遷移無しでWebページの内容を更新する手法としてのAJAXの基盤であるXMLHTTPRequestは、Microsoftが生み出したデファクトスタンダードとしてすんなりと世間に受け入れられた(ただしGoogleがその成果を非Microsoft化してしまった)。しかし、XMLHTTPRequestはクライアントがリクエストを一々発行するクライアントプル技術であり、サーバプッシュの真の代用にはなり得ない。

そもそもHTMLやHTTPというWebの標準がここまで貧しい設計でなければ特定ブラウザで動作するしないといった些細な事柄について無意味な議論を重ねる必要は生じなかったはずだ。HTMLベース技術の大半がプラットフォーム間の互換性の維持に労力を費やしている状況はある観点から見れば極めて寒々しい。例えばblogのデファクトスタンダードであるトラックバックという仕組みは、技術的なメリットではなくそれが標準として流布しているという政治的状況によって生かされているのである。その現状を一旦忘れ、例えば全てのWebページが別個のソフトウェアアプリケーションだったらどうだろうか。HTMLがチューリング完全なプログラミング言語で、自己が利用できるサーバまたはクライアント上の資源を理解しつつ全てのWebページが位置透過的・自律的にサービスを提供していたら?

マイクロソフトのActiveX(現在は.NET)やSunのJavaは、ネットワーク上に分散配置されたソフトウェアコンポーネント群からなる世界の端緒を開くことを期待された技術だった。ところが様々な政治的要因によりそれらの技術がWebを席巻することは最早あり得ない状況となっている。ブラウザの備えるソフトウェアスタックのサポートはECMAScriptとW3C DOM/CSSのサポートに留まり、WebページそのものはXMLに見られるようにますますデータ化の傾向を強めるかに見える。その傾向と一線を画するのがActiveXコンテナに収まってIE上で動作するAdobe Flashである。2001年にリリースされたWindows XPをインストールして最初に表示されるプロダクトツアーはMacromedia Flashで駆動されていた。Flashオブジェクトを作成するためのActionScriptは現在3.0までバージョンアップし、Java並のプログラマビリティを備える。実装のパフォーマンスもJavaアプレットと比較して高く、Webアプリケーションのユーザビリティを阻害するストレスがほとんどない。絶対的パフォーマンスを除けば、ActiveXコンポーネントに劣るのはデコンパイルが容易な点くらいだろう。Flex 2 SDKは無料化され、誰でも無料でFlashアプリケーションを開発できる。先日のMozilla FoundationへのActionScript VM寄贈も記憶に新しい。その上サーバサイドActionScriptがAdobe製品以外にも拡がることにでもなれば、FlashがWebの覇者となる未来も決して遠くないだろう。あるいは現在既にそうなっているかもしれない。

しかしFlashとてWebの基幹技術のHTMLと融合したわけではなく、両者の間の溝は厳然として存在する。Webブラウザのプラグインとして実装されているFlashその他の技術は、HTMLベースの技術と異なり、WebブラウザのUI上では異物感を感じさせることが少なくない。ブラウザ自身の提供するUIウィジェットの中でプラグインの表示領域のみが浮く、表示フォントの違い、右クリック時のコンテクストメニューの違い、Javaアプレットの場合は起動自体の遅さなど、差異については枚挙にいとまがない。そもそもコンテンツホルダーの要求でテクストのコピーが禁止になっているなど、風通 しの悪さも感じさせられることがあるかもしれない。これも過渡期の現象で遠い将来にはFlashが全てのUIを覆い尽くすことになるのだろうか。Flashがプロプライエタリな技術に留まる限りこの展望は全く荒唐無稽といえる。AJAXの定義をJavaScriptによるWebブラウザのUI操作全般に拡張する動きに見られるように、ECMAScriptのスタックがツールも含めて十分に成長してくれば、WebのUIにダイナミックで柔軟なプログラマビリティを与える道具としてのFlashは存在意義を失ってしまう。そして、歴史をひもとけばFlashのプログラミング言語ActionScript自体がECMAScriptから派生している(ただし2.0でクラスライブラリ階層や強い型付けが導入されたため現在のActionScriptはプロトタイプベースではない)。つまり、現時点では、Flashを擁するAdobe社が、Microsoftとその他の対立に乗じていわば「漁夫の利」を得た状況となっているわけだ。そして、この不安定な均衡に満足しないAdobeは既に先手を打ち、将来にわたりFlashの支配を保証するために、オープンなTamarin Projectに打って出た。

この現状を認識した上で冒頭のサーバプッシュの話に戻ると、かつてのサーバプッシュに現在最も近い技術はWeb上のストリーミング映像サイトで目にすることが出来る。ただし、これらは実際はサーバプッシュではなくクライアントプルでプロトコル上実装されていることもあるのでストリーミン グ即サーバプッシュを意味するわけではない。ここでもプラットフォーム中立性やコンテンツホルダの要請により、QuicktimeやWindows Media PlayerオブジェクトのWebページへの埋め込みではなく、FlashによるFlash Videoフォーマットのストリーミングが一般的になりつつある。HTTPではない(あるいはHTTPトンネリングされた)プロトコルを喋るストリーミン グサーバとWebブラウザの間をFlashが取り持っている。Webブラウザがそれ自身でリッチなアニメーションや動画を処理する標準方式が存在しない以上、先に述べたUI上でのFlashの異物感もやむを得ないと言えるし、このストリーミングは基本的にFlash内で完結している。しかし、イメージ/バイナリベースではない、テクストベースのサーバプッシュについてはどうか。

DICEのWebサーバ 部分にIRCチャンネルのWebインターフェイス機能を画面遷移無しで持たせようと考えたとき、真っ先に頭をよぎったのはNetscapeブラウザが持っていたサーバプッシュ機能だった。もちろん、Internet Explorerでは対応していないこの方式は使用することはできない。DICEは1つのサーバでIRCインターフェイスとWebインターフェイスの両方を別々のポートから同時に提供するのでJavaアプレットやFlashのセキュリティサンドボックスの制限をクリアできるとはいえ、それらプラグインでIRCクライアントのUIを作った場合のUIの異物感、不統一感は避けたい。逆に、HTML寄りの方法としてAJAXを使う場合、新しいメッセージがチャンネルに存在していないか調べるためにサーバに対し一定時間毎にXMLHTTPRequestで接続を張ってテストするというポーリングを行わなければならない。これはスマートではないし、サーバへの負荷が高い。AJAXの最悪の使用法だ。

あるいは、サーバプッシュを真似る古典的な手法として、HTTPの接続を切らずに隠しIFRAMEやXMLHTTPRequestによる接続先にサーバからJavaScriptの文を一つ一つ送らせ、フレームやXMLHttpRequestのプロパティといったローカル受信バッファへのポーリングを一定時間毎に行うことにより、サーバ自体へのポーリングを回避しつつ動的なページの更新をエミュレートするというものがある。AJAXに飽き足らないユーザの中にはこの手法をCometあるいはHTTPストリーミングなどと呼んでAJAXのパターンに含めようという動きもあり、例えばPushletはそれを系統的にまとめ上げたツールキットである。しかしこの方法はいわゆるハックの部類に属し、ブラウザの読み込み表示が表示されたままになるという問題の他、クライアント側のタイムアウト時間、バッファリングや同時接続数制限といった仕様に依存する。実装上も、CGIなどのWebアプリケーションとして長時間スレッドをsleepさせるようなものはパフォーマンスの観点から論外である上に、DICE自体に実装するにも煩雑すぎる。

IRCチャンネルにWebインターフェイスを設ける際の注意点として、上記のようなクライアントやサーバ側の仕様上の問題に加え、IRCチャンネルのセキュリティの問題がある。従来はDICEのWebインターフェイスからIRCチャンネルへ発言を投稿するには、サーバがJavaScriptとスタイルシートで文字列(機械的な読み取りが困難なよ うにノイズを加えてある)を描き、投稿者にそれを一々読ませて投稿時に記入させ、HTTPのPOSTで投稿させていた。故意の連続投稿やスパムを未然に防止するためである。しかしWeb掲示板ならともかくリアルタイム性が売りのIRCにこの形式はそぐわない。HTTPストリーミングと組み合わせた場合には輪をかけて非効率である。当然実装も複雑になる。IRC側ではプロクシサーバのチェックまで行っているにも関わらず、Web側のユーザの参加資格には何の制約もないのも問題だ。反面、IRCチャンネル内でWebのユーザをアクセス禁止にすることは容易ではない。

そこで、Flashをソケット通信のバックエンドとしてのみ用いてブラウザをIRCサービスへ直接接続させ、UIはすべてJavaScriptによるDOM操作で構築することを考えた。ページに不可視のFlashオブジェクトを埋め込み、FlashはソケットでIRCサービスのポートへ接続し、通常のIRCクライアント同様に振る舞う。ただしFlashは出来る限りネットワークプロクシの役割に留め、IRCクライアントとしてのロジックはJavaScript側で実装する。この方法ならばプラットフォーム間の差異を最小に留めつつIRCチャンネルのWebインターフェイス を提供でき、セキュリティ上・実装上のオーバーヘッドも低い。テクストベースのWebインターフェイスにFlashプラグインを接ぎ木することによるUI上 の拒絶反応も当然無い。サーバにIRCクライアントとして接続する以前に取得する必要のある動的な情報(例えばIRCのポート)のみAJAXで取得すればよい。UIはスクリプト言語のJavaScriptで制御されているのでFlashと異なりコンパイル無しでサーバ管理者が自由に編集でき、アプリケーションの配布には都合がよいという利点もある。

早速コードを見てみよう。以下、DICEに 添付しているIRCクライアントWebアプリケーションのAdobe Flash 9向けFlashオブジェクトProxyIRCのActionScript 3.0ソースコードProxyIRC.asを解説していく。最初IRCProxyという名称で作成していたが何故かエラーが発生したのでProxyIRC という名称に変更した。ビルドには、最近無償配布が開始されたコマンドライン開発ツールAdobe Flex SDKを使用する。 コンパイル時のコマンドラインオプションは mxmlc -default-size 1 1 -default-frame-rate=30 -default-background-color=0xFFFFFF ProxyIRC.as とでもすればよいだろう。


package
{
import flash.display.*;
import flash.external.*;
import flash.net.*;
import flash.events.*;
import flash.utils.*;

// class name may conflict with the JavaScript engine for IE
// when ExternalInterface is in use, be careful to test different names
public class ProxyIRC extends Sprite
{
private var socket:Socket;
private var receivedMessageBuffer:String;
private var isJIS:Boolean;

public function ProxyIRC()
{
socket = new Socket();
configureListeners(socket);
isJIS = false;

receivedMessageBuffer = new String();

// It seems the JS engine for IE has a serious problem with the functionName argument.
// Don't use an existing JS function name.
ExternalInterface.addCallback("testLoopback", loopbackToJavaScript);
ExternalInterface.addCallback("connectIRC", connect);
ExternalInterface.addCallback("sendIRC", send);
ExternalInterface.addCallback("setJIS", setCharacterEncoding);
ExternalInterface.addCallback("quitIRC", disconnect);
}


ActionScript 3.0は前述のようにJavaScript風からJava風に進化したような言語なので、Javaを知っているならばほとんど言語仕様を見なくてもライブラリリファレンスから必要な道具を拾うだけで直ちにプログラミングを開始できるだろう。型が変数名の後に付くとか、XMLをソースコード内に直接記入できるとかという程度の違いしかない。このクラスProxyIRCはFlashのディスプレイリストの最も単純な基本クラスSpriteを継承し、3つのプロパティを保持している。socketは文字通り接続対象に接続するためのTCP/IPソケットであり、receivedMessageBufferは送られてきたデータを解析するために溜めておくバッファである。isJISは、日本語で行われるIRCのエンコーディングは通常JISのため、内部の文字列を全てutf-8で扱うJavaScriptとやりとりするときに日本語向けの相互変換を行うか否かのユーザ設定を保持するためのフラグである。コンストラクタ内での一連のExternalInterface.addCallbackの呼び出しは、JavaScriptの関数名とFlashのメソッドとの関連付けを登録し、JavaScriptからFlashの関数名を呼び出せるようにするための準備である。


public function loopbackToJavaScript():void
{
ExternalInterface.call("loopbackFromFlash");
}

public function setCharacterEncoding(jis:Boolean):void
{
isJIS = jis;
}

public function connect(host:String, port:uint):void
{
if (socket.connected)
{
socket.writeUTFBytes("QUIT\n");
socket.flush();
socket.close();
}

socket.connect(host, port);
}

public function disconnect():void
{
if (!socket.connected)
return;

socket.writeUTFBytes("QUIT\n");
socket.flush();
socket.close();
}


loopbackToJavaScriptはFlashが正常に動作しているかJavaScript側から調べるためのメソッドである。JavaScript側から呼び出されると逆にJavaScript側のloopbackFromFlash関数を実行する。connectは サーバへの接続を実行するための関数だが、IRCクライアントUIの実装上、接続中は接続開始のボタンが切断ボタンになっているので、接続中に呼び出されると接続を直ちに切断するようにしてある。この部分はJavaScriptのUIのことを考慮せず分離すべきだったかも知れない。disconnectメソッドは単純にサーバから切断するためのメソッドである。ちなみにIRCはテクストベースのプロトコルで一つ一つのメッセージは'\n'で区切られる。QUITは最も単純なメッセージである。プロトコルの詳細はRFC 2812を参照して頂きたい。


private function configureListeners(dispatcher:IEventDispatcher):void
{
dispatcher.addEventListener(Event.CLOSE, closeHandler);
dispatcher.addEventListener(Event.CONNECT, connectHandler);
dispatcher.addEventListener(ProgressEvent.SOCKET_DATA, dataHandler);
dispatcher.addEventListener(IOErrorEvent.IO_ERROR, ioErrorHandler);
dispatcher.addEventListener(ProgressEvent.PROGRESS, progressHandler);
dispatcher.addEventListener(SecurityErrorEvent.SECURITY_ERROR, securityErrorHandler);
}

public function send(utf8string:String):void
{
if (isJIS)
{
var b:ByteArray = new ByteArray();
b.writeMultiByte(utf8string + "\n", "iso-2022-jp");
socket.writeBytes(b, b.bytesAvailable);
}
else
socket.writeUTFBytes(utf8string + "\n");
socket.flush();
}

private function closeHandler(event:Event):void
{
ExternalInterface.call("onClose");
}

private function connectHandler(event:Event):void
{
ExternalInterface.call("onConnect");
}


configureListenersメソッドは、引数で指定されたオブジェクトに起こったイベントを監視するためのイベントリスナをまとめて登録するための初期化メソッドで、既に見たようにProxyIRCのコンストラクタで呼ばれる。引数はソケットオブジェクトなので、ソケットが切断される、ソケットがデータを受信する等のイベントに対してそれぞれの処理メソッドの参照を関連付けている。sendはサーバに対して文字列を送信するためのメソッドで、JavaScript側からutf-8で送られてきた文字列を、JISエンコーディングが有効になっている場合はByteArrayクラスのwriteMultibyteメソッドを利用してJIS(iso-2022-jp)エンコーディングに変換してから送信する。それが必要ない場合はそのままutf-8文字列として送信している。closeHandlerconnectHandlerはJavaScript側にソケットの切断・接続を通知するための処理メソッドである。


private function dataHandler(event:ProgressEvent):void
{
var buffer:String = new String();
if (isJIS)
buffer = socket.readMultiByte(event.bytesLoaded, "iso-2022-jp");
else
buffer = socket.readUTFBytes(event.bytesLoaded);

var c:String = new String();
for (var i:uint = 0; i < buffer.length; ++i)
{
c = buffer.charAt(i);
if (c == "\n" || c == "\r")
{
if (receivedMessageBuffer.length != 0)
{
if (receivedMessageBuffer.length >= 4
&amp;&amp; receivedMessageBuffer.substring(0, 4).toUpperCase().localeCompare("PING") == 0)
send("PONG");
else
ExternalInterface.call("output", receivedMessageBuffer);
}
receivedMessageBuffer = "";
}
else
receivedMessageBuffer += c;
}
}

private function progressHandler(event:ProgressEvent):void
{
// trace("progressHandler loaded:" + event.bytesLoaded + " total: " + event.bytesTotal);
}

private function ioErrorHandler(event:IOErrorEvent):void
{
ExternalInterface.call("reportError", "I/O Error: " + event);
}

private function securityErrorHandler(event:SecurityErrorEvent):void
{
ExternalInterface.call("reportError", "Security Error: " + event);
}
}
}


dataHandlerメソッドはやや複雑だが、サーバからデータを受信したときの処理を記述したメソッド である。JISエンコーディングが有効なときはJISとしてソケットからバッファへ読み込み、そうでなければutf-8文字列として読み込む。ループ処理 はバッファから個々のIRCメッセージを切り出すためのもので、メッセージ区切りの改行文字を見つけると一つメッセージを受け取ったと判断してJavaScript側の処理関数outputを呼び出している。ただし例外として、PINGというIRCメッセージを受け取ると、JavaScript側に通知せずPONGと いうメッセージを送り返す。これはIRCの接続維持メカニズムで、一定時間活動がないと回線が接続されているかどうかのポーリングが行われるというもので ある。JavaScript側へ渡すことも出来るが、無駄なオーバーヘッドとなるのでここではFlash側で適切に処理している。ioErrorHandlerはI/Oエラー時の処理メソッド、securityErrorHandlerはFlashのセキュリティ違反が起こったときに呼ばれるメソッドである。Javaアプレットと同じく、Flashのセキュリティサンドボックスはソケットの接続をアプレットがホストされているサーバと同じドメインのサーバに制限しているので、それに違反するとソケットにセキュリティエラーが起こる。ただ し、Flashの場合は、接続先外部サーバがルートにXMLのクロスドメインポリシーファイルを持っていればこの制限は回避できるため、外部サーバに接続するよう指示された場合Flashはまずクロスドメインポリシーファイルを探索し、見つからないとセキュリティエラーを発生させる。

ソケット通信と日本語JISエンコーディング変換を行うバックエンドのFlashコードは以上である。今度は、JavaScriptで構築されたユーザインターフェイスを見ていこう。基本的な動作イメージは、よくあるGUIのIRCクライアントのように、チャンネルの内容を表示するバッファとチャンネルのメンバーを表示するコラムがあり、さらに発言を入力したりするための操作パネルが下部にある。さらにチャンネルやその他のタブ画面の切り替えを行 うために、Windowsのタスクバーのようにタブを列挙したバーが操作パネルの下部に付属している。最初から存在する消去できない特殊なタブとして、ヘ ルプファイル表示タブ(help)、生の通信ログを書き出すためのタブ(raw)、IRCサーバーが発したメッセージを表示するためのタブ(status)、チャンネルリスト表示用タブ(channels)がある。これらの要素を、JavaScriptによるDOM操作で、Internet Explorer 6またはFirefox 2以上を対象としたIRCクライアントWebアプリケーションとして駆動する。

IRCクライアント本体のコードを見る前に、AJAXやDOM関係の簡易ユーティリティメソッドをまとめたJavaScriptファイルdice.jsをまず提示しておく。IRCクライアントの方ではここに登場するメソッドを使用している。



var isMozilla = navigator.userAgent.indexOf('Gecko') != -1;
var isIE = window.ActiveXObject;

function createHttpRequest()
{
if (isIE)
{
try
{ // CLSID_XMLHTTP
// v 3.0
return new ActiveXObject("Msxml2.XMLHTTP");
}
catch (e)
{
try
{// v 2.x
return new ActiveXObject("Microsoft.XMLHTTP");
}
catch (e2)
{
return null;
}
}
}
else if (window.XMLHttpRequest) // non-IE
{
var hr = new XMLHttpRequest();
if (isMozilla)
hr.overrideMimeType('text/xml');
return hr;
}
else
{
return null;
}
}

function getInnerHTML(id)
{
// if (isIE)
// {
return document.getElementById(id).innerHTML;
// }

/* For old Mozilla compatibility
var html = "";
node = document.getElementById(id);
for (var i = 0; i < node.childNodes.length; ++i)
html += getOuterHTML(node.childNodes.item(i));

return html;*/
}

/* For old Mozilla compatibility
function getOuterHTML(node)
{
var s = "";

switch (node.nodeType)
{
case 1: // ELEMENT_NODE
s += "<" + node.nodeName;
for (var i = 0; i < node.attributes.length; ++i)
{
if (node.attributes.item(i).nodeValue != null)
{
s += " "
s += node.attributes.item(i).nodeName;
s += "=\"";
s += node.attributes.item(i).nodeValue;
s += "\"";
}
}

if (node.childNodes.length == 0
&amp;&amp; (node.nodeName == "IMG"
|| node.nodeName == "HR"
|| node.nodeName == "BR"
|| node.nodeName == "INPUT"
))
s += ">";
else
{
s += ">";
s += getInnerHTML(node);
s += "<" + node.nodeName + ">"
}
break;
case 3: //TEXT_NODE
s += node.nodeValue;
break;
case 4: // CDATA_SECTION_NODE
s += "<![CDATA[" + node.nodeValue + "]]>";
break;
case 5: // ENTITY_REFERENCE_NODE
s += "&amp;" + node.nodeName + ";"
break;
case 8: // COMMENT_NODE
s += "<!--" + node.nodeValue + "-->"
break;
}

return s;
}
*/

function replaceElement(id, html)
{
// if (isIE)
// {
document.getElementById(id).innerHTML = html;
// }
/* For old Mozilla compatibility
else
{
var e = document.createElement('span');
e.setAttribute('id', 'temp');

var range = document.createRange();
range.selectNodeContents(document.body);
range.collapse(true);
e.appendChild(range.createContextualFragment(html));

var outer = document.getElementById(id);
while (outer.firstChild)
outer.removeChild(outer.firstChild);
outer.appendChild(e);
}*/
}

function appendElement(id, html)
{
// if (isIE)
// {
document.getElementById(id).innerHTML += html;
// }
/* For old Mozilla compatibility
else
{
var e = document.createElement('span');
e.setAttribute('id', 'temp');
var range = document.createRange();
range.selectNodeContents(document.body);
range.collapse(true);
e.appendChild(range.createContextualFragment(html));
document.getElementById(id).appendChild(e);
}*/
}

function sendHTTP(data, method, fileName, callback, async)
{
var hr = createHttpRequest();
hr.open(method, fileName, async);
hr.setRequestHeader("If-Modified-Since", "Thu, 01 Jun 1970 00:00:00 GMT");
hr.onreadystatechange = function()
{
if (hr.readyState == 4)
{
callback(hr);
}
}

hr.send(data)
}

// replace an element in an HTML document with a text node in an AJAX XML response
// that has the same tag name as the ID of the aforementioned HTML element
function replaceElementByAjaxTagName(hr, tagname)
{
var nodelist = hr.responseXML.getElementsByTagName(tagname);
if (nodelist != null)
{
var x = nodelist.length;
for (var i = 0; i < x; ++i)
{
var n = nodelist.item(i);
var nn = n.firstChild;
// NODE_TEXT == 3 || NODE_CDATA_SECTION == 4
while (nn != null &amp;&amp; (nn.nodeType == 3 || nn.nodeType == 4))
{
replaceElement(tagname, nn.nodeValue);
nn = nn.nextSibling;
}
}
}
}

function getAjaxTextNodeByTagName(hr, tagname)
{
var nodelist = hr.responseXML.getElementsByTagName(tagname);
if (nodelist == null)
return null;

return nodelist.item(0).firstChild.nodeValue;

//return nodelist.item(0).textContent; // IE fails
}

function unixtime2localdate(t)
{
var d = new Date;
d.setTime(t * 1000);

return d.toLocaleString();

//var h = String(d.getHours());
//if (h.length == 1)
// h = "0" + h;

//var m = String(d.getMinutes());
//if (m.length == 1)
// m = "0" + h;

//var s = String(d.getSeconds());
//if (s.length == 1)
// s = "0" + h;

//return String(d.getYear() % 1900 + 1900) + "/"
// + String(d.getMonth() + 1) + "/"
// + d.getDate() + " "
// + h + ":"
// + m + ":"
// + s;
}


御覧のように、かなりの部分がコメント化されて消されているのは、MozillaがinnerHTMLをサポートしていなかった時代から使っていた互換性維持のためのコードを省略したことによる。innerHTML自体はあくまで非標準だが、今回はあえて古いブラウザは切り捨てておそらく効率的であろうinnerHTMLを使用するという道を選んだ。

ここから、JavaScriptによるIRCクライアントのコードを含んだHTMLファイルirc_client.htmlを見ていく。


<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN" "http://www.w3.org/TR/html4/loose.dtd">
<html>
<head>
<title>Web IRC Client for KLassphere DICE</title>

<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta HTTP-EQUIV="PRAGMA" CONTENT="NO-CACHE">
<meta HTTP-EQUIV="Expires" CONTENT="Mon, 04 Dec 1999 21:29:02 GMT">

<style type="text/css"><!--
td, form, input, button, #tabs
{
font-size: 12px;
font-family: Verdana, Geneva, Arial, sans-serif;
}
-->
</style>
</head>
<script language="JavaScript" src="dice.js"></script>
<script language="JavaScript">
<!--
/*

Flash/Javascript IRC Client for KLassphere DICE - (c) RyuK 2006 - 2007 All Rights Reserved

klassphere[at.mark]gmail.com
http://zzz.zggg.com/
http://aiueo.da.ru/

This file is not redistributable.

*/

var flashIsEnabled = false;
var isConnected = false;

var mapChannelBuffer = new Object();
var mapSpecialBuffer = new Object();
var mapPrivateMessageBuffer = new Object();

var helpBuffer = new ChannelBuffer;
var rawBuffer = new ChannelBuffer("(This is the buffer where raw server outputs are dumped in)<br>");
var statusBuffer = new ChannelBuffer("(This is the buffer where current status is logged)<br>");

var channelListHeader = ("Channel List<br><br>Push the button to load the list from the server after a connection is established.<br>Click a channel name in the list to join in.<br>"
+ "<button onClick=\"loadChannelList();\">Refresh the channel list</button><br><br>");
var channelListBuffer = new ChannelBuffer(channelListHeader);

var myNick = "";
var currentTab = "";
var serverName = "";


まず、グローバル変数の定義から行う。mapChannelBufferは汎用のObjectとして生成されるが、実際には連想配列として使用され、キーはチャンネル名(IRCチャンネル名は全て'#'という接頭辞が付く - 従ってJavaScriptオブジェクトのプロパティ名としては不適当なので、チャンネル名に対応したオブジェクトのアクセスは.#testではなく ["#test"]のように角括弧で行う)、キーに対応するエントリはIRCチャンネルの表示ウィンドウを表すオブジェクトとする予定である。同様に、mapSpecialBufferは先に述べた特殊なタブの表示ウィンドウを保持する。mapPrivateMessageBufferは、他のIRCユーザと1対1のプライベートメッセージを交わすための表示画面を保持する、ユーザ名をキーとした連想配列となる。helpBufferrawBufferstatusBufferchannelListBufferはそれぞれの特殊タブの実体で、後で定義されるChannelBufferというクラスのオブジェクトを個々に保持する。ChannelBufferは文字通りチャンネルの中身を保持するためのオブジェクトで、ここでは一々オブジェクトを特殊化せずにそのまま他の種類のタブにも流用している。myNickはIRCサーバ上での自分のニックネーム、currentTabは現在選択され表示されているタブの名称、serverNameは接続先サーバ名である。

さて、ここで説明の便宜上、irc_client.htmlの最下部に置いてあるHTMLデザインの部分を先に出しておく。デザインの概要は、先に基本動作イメージとして述べたとおりである。冒頭にFlashのProxyIRCが配置され、1ピクセルの大きさなのでユーザからは見えない。(尚、フラッシュのファイル名についている"?1"はWebブラウザによるFlashオブジェクトのキャッシュを抑制するためのもので、 irc_client.htmlの冒頭のヘッダにもキャッシュを抑制するためのmetaタグが置いてある。開発中に頻繁にFlashを更新するためにブラ ウザのキャッシュを抑制すべく置いたが、実際に機能する場合としない場合があるようだ。)チャンネル表示ウィンドウとして主に利用される、IDがcurrentBufferの 要素内に、helpタブの内容は既に記述されており、アプリケーションは開始時にこの要素のHTMLを読み込んでヘルプファイルとする。また、チャンネル表示ウィンドウの右上にはXボタンがあり、これを押すとそのタブが消去されるのはWindowsなどのGUIの慣例に拠っている。例えばチャンネルのタブの場合、そのチャンネルから離脱するという効果が得られる。


<body onLoad="setup();" onkeydown="onKeyDown(event);">

<OBJECT classid="clsid:D27CDB6E-AE6D-11cf-96B8-444553540000" id="ProxyIRC" width="1" height="1"
codebase="http://download.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=9,0,0,0">
<PARAM name="movie" value="ProxyIRC.swf?1">
<PARAM name="quality" value="high">
<PARAM name="bgcolor" value="#ffffff">
<PARAM name="allowScriptAccess" value="sameDomain">
<param name="src" value="ProxyIRC.swf?1">
<EMBED src="ProxyIRC.swf?1" quality="high" bgcolor="#ffffff" width="1" height="1" name="ProxyIRC" align="middle
" play="true" loop="false" quality="high" allowScriptAccess="sameDomain" type="application/x-shockwave-flash" pluginspage="http://www.macromedia.com/go/getflashplayer">
</OBJECT>

<table width="100%" height="85%" border="1" cellpadding="0">
<tr>
<td width="100%" height="1%" colspan="2" bgcolor="#0066FF">
<table width="100%" border="0" cellspacing="0">
<tr>
<td bgcolor="#0066FF"><font color="#ffffff"><b><span id="buffer_title"></span></b></font></td>
<td width="1%"><button onClick="onXButtonClick();">X</button></td>
</tr>
</table>
</td>
</tr>
<tr>
<td width="85%" height="460" style="vertical-align: top;"><p><span id="currentBuffer"><b>Welcome to the Web IRC Client for the KLassphere DICE</b><br>
<br><font size=-1>
Usage:<br>
<br>
* This client requires <b>Internet Explorer 6</b> or <b>Firefox 1.5</b> or higher, and <b>Adobe Flash 9</b> plug-in to run. Also <b>JavaScript</b>
must be enabled in the web browser options.<br><br>
* There are tabs at the bottom of this page, "<b>help</b>", "<b>raw</b>", "<b>status</b>", and "<b>channels</b>". Click them to open. The "help"
tab is this page and has the user manual. "raw" is the buffer where raw server outputs are dumped in. "status" is where current status is logged,
such as negotiation with an IRC server. "channels" is for the channel list for the IRC server.<br><br>
* Enter your nick name for IRC in the box on the right of "<b>Nick Name</b>". Then press the "<b>Connect</b>" button to connect to the IRC server.
The boxes on the right are for IRC server name and port.<br><br>
* After the connection is established, you are transferred to the "status" tab and can see server messages. Then you can retrieve it the channel
list and join channels from the "channels" tab. Alternatively, or when you want to create a new channel, you can specify the channel name directly
in the box on the right of the "Join Channels" button and join in it by pressing the button. A channel name has to start with "#".<br><br>
* When you've joined a channel, a new tab is added in the tab bar at the bottom of this page. You can switch channels by clicking tabs.<br><br>
* To speak in a channel, write down your message in the "<b>Message</b>" box and press the Enter key or push the "Send" button.<br><br>
* To exchange private messages with a channel member, click a user name in the channel member list and type in a message.<br><br>
* The "Unicode (UTF-8)" / "Japanese Multibyte (JIS)" radio buttons are used to change the message encoding. Set it to "Unicode (UTF-8)"
if your channel's encoding is Unicode.<br><br>
* When you check the "Raw Message" checkbox this client sends the text entered as is to the IRC server. This command is useful to enter
an IRC command which is not supported by this client. Alternatively you can send a message with "/quote " prefix to gain the same effect.<br><br>
* If you want to erase the content of the current tab, push the "Clear This Buffer" button.<br><br>
* To part from a channel, click the "X" button on the upper right of the tab.<br><br>
* If you are done with chat, push the "Disconnect" button to quit.<br><br>
</font>
</span></p></td>
<td width="15%" style="vertical-align: bottom;"> <p><span id="members"></span></p></td>
</tr>
</table>
<form onSubmit="return false;"><input type="button" onclick="connect();" id="buttonConnect" value="(initializing)">
<input type="text" id="IRCHost" value=""> : <input type="text" id="IRCPort" value="" size="5"> Nick Name <input type="text" id="nick" value="" size="16">
<input type="button" onclick="joinChannel();" id="buttonJoin" value="Join Channel">
<input type="text" id="channelName" size="32" onFocus="this.value = '';" value="(enter a channel name here)"><br>
Message <input type="text" id="message" size="80"><input type="button" onclick="sendMessage();" value="Send"><br>
<input type="radio" name="charset" value="utf-8" onclick="setCharset(false)"> Unicode (UTF-8)
<input type="radio" name="charset" value="JIS" onclick="setCharset(true)" checked> Japanese Multibyte (JIS) <input type="checkbox" id="raw"> Raw Message
<input type="button" onClick="onClearThisBuffer();" id="buttonClearBuffer" value="Clear this buffer"></form>
<span id="tabs"></span>
</body></html>


再びJavaScriptコードへ戻ろう。初期化関連の関数がまず登場する。


function callProxyIRC()
{
// IE6 doesn't need this method, but FF3 does
if (isIE)
{
return window["ProxyIRC"];
}
else
{
return document["ProxyIRC"];
}
}

function setup()
{
mapSpecialBuffer["help"] = helpBuffer;
helpBuffer.buffer = getInnerHTML("currentBuffer");

mapSpecialBuffer["raw"] = rawBuffer;
mapSpecialBuffer["status"] = statusBuffer;
mapSpecialBuffer["channels"] = channelListBuffer;

var date = new Date;
document.getElementById("nick").value = ("guest" + date.getTime().toString().slice(-5));

renderTabs();
switchTab("help");

sendHTTP( '' , 'GET', './irc_address', fillServerAddress, true);

// For Firefox 2 and lower, the initialization of Flash is so slow
// that the setup function must be delayed.
setTimeout('setupFlash()', 1000);
}

function setupFlash()
{
callProxyIRC().testLoopback();

if (!flashIsEnabled)
setTimeout('setupFlash()', 2000);
}

function loopbackFromFlash()
{
flashIsEnabled = true;
document.getElementById("buttonConnect").value = "Connect";
}

function fillServerAddress(hr)
{
document.getElementById("IRCHost").value = getAjaxTextNodeByTagName(hr, "IRCHost");
document.getElementById("IRCPort").value = getAjaxTextNodeByTagName(hr, "IRCPort");
}


callProxyIRCはFlashのProxyIRCオブジェクトをJavaScriptからの呼び出し対象として取得する関数で、ブラウザ間の扱いの違いを吸収するためのラッパーである。isIEはdice.js内で定義されているフラグで、Internet Explorerに対してtrueとなる。IEの場合は全てのドキュメントオブジェクトはwindowに格納されており、Firefoxの場合はdocumentになる。setupはこのアプリケーションの初期化関数で、HTMLからonLoadで呼び出され、まずそれぞれのタブ群をまとめる連想配列に各々の表示ウィンドウのオブジェ クトを入れる。それから、ユーザのニックネーム欄には"guest"で始まるランダムな文字列が設定される。タブ群の準備が出来たところでrenderTabsを呼び出し、タブを列挙したバーの描画を行い、helpタブへタブを切り替える。最後に、Webサーバの/irc_addressというファイルを取得するようAJAXで要求を送っている。DICEのWebサービスは/irc_addressでIRCサービスのアドレスとポートをXMLファイルとして返すので、fillServerAddress関 数はこの情報を得てフォームの接続先ホスト・ポート欄に記入する。setup関数の最後には、Flash内のtestLoopback関数を呼び出すこと により、Flashがこのブラウザで有効になっているか調べている。Firefox 3やIE6/7ではsetTimeoutで実行を遅らせる必要はないが、Firefox 2だとFlashの初期化がbodyタグでのonloadのタイミングより若干遅れるため、2秒の実行待ち時間を入れている。

続いて、ChannelBufferクラスの定義を行う。



// ChannelMember class
function ChannelMember(px, n)
{
var obj =
{
prefix : px,
nick : n
};
return obj;
}

// ChannelBuffer class
function ChannelBuffer(buf)
{
if (buf == null)
buf = "";

var obj =
{
buffer : buf,
members : new Array(),

addMember : function(m) // m: ChannelMember object
{
this.members.push(m);
},

// returns the removed channel member object if it's successful
removeMember : function(n) // n: nick
{ // don't use splice for compatibility
var ret = false;
var c = new Array();
for (var i in this.members)
{
if (ret)
c.push(this.members[i]);
else if (this.members[i].nick != n)
c.push(this.members[i]);
else
ret = this.members[i];
}

if (ret)
this.members = c;

return ret;
},

sortMembers : function()
{
this.members.sort(this.compareMember);
},

compareMember : function(a, b)
{
if (a.prefix == "@" &amp;&amp; b.prefix != "@")
return -1;
if (a.prefix == "+" &amp;&amp; b.prefix == "")
return -1;
if (a.prefix == "" &amp;&amp; b.prefix != "")
return 1;

if (b.prefix == "@" &amp;&amp; a.prefix != "@")
return 1;
if (b.prefix == "+" &amp;&amp; a.prefix == "")
return 1;
if (b.prefix == "" &amp;&amp; a.prefix != "")
return -1;

//return (a.nick - b.nick); // this doesn't work in IE6

if (b.nick > a.nick)
return -1;
else if (b.nick == a.nick)
return 0;
else
return 1;
},

setMemberMode : function(nick, mode)
{
for (var i in this.members)
{
if (this.members[i].nick == nick)
{
if (mode.charAt(0) == "+")
{
switch (mode.charAt(1))
{
case "o":
this.members[i].prefix = "@";
break;
case "v":
this.members[i].prefix = "+";
break;
}
}
else
this.members[i].prefix = "";

return;
}
}
},

getMemberPrefix : function(nick)
{
for (var i in this.members)
{
if (this.members[i].nick == nick)
{
return this.members[i].prefix;
}
}

return "";
},

// these rendering functions force update when the parameter is omitted.
renderMembers : function(bufferName)
{
var ret = (this.members.length == 0 ? "" : "[members]<br>");

for (var i in this.members)
{
ret += "<a style=\"text-decoration: none;\" href=\"#\" onclick=\"openPrivateMessage('";
ret += (this.members[i].nick + "@" + serverName);
ret += "')\">";
ret += (this.members[i].nick == myNick
? ("<font color=\"red\">" + this.members[i].prefix + this.members[i].nick + "</font>")
: (this.members[i].prefix + this.members[i].nick));
ret += "</a>";
ret += "<br>";
}

if (!bufferName || currentTab == bufferName)
{
replaceElement("members", ret);
}
},

renderBuffer : function(bufferName)
{
if (!bufferName || currentTab == bufferName)
{
replaceElement("currentBuffer", this.buffer);
}
},

render : function(bufferName)
{
if (!bufferName || currentTab == bufferName)
{
replaceElement("currentBuffer", this.buffer);
this.renderMembers();
}
},

renderTab : function(i)
{
var t = "<span style=\"border: 1px solid gray; padding: 0px 2px 4px 4px; margin: 3px 3px 3px 3px\"><a style=\"text-decoration: none;\" href=\"#\" onclick=\"switchTab('";
t += i;
t += "')\">";
t += i;
t += "</a></span>";
return t;
}
};
return obj;
}


JavaScriptはプロトタイプベースの言語でクラスというものが存在せず、いつでもオブジェクトのプロパティやメソッド、また継 承に使用されるプロトタイプメソッドを付けたり外したりできるので、あるプロパティのセットを持つオブジェクトを返すという、クラスのコンストラクタ風のメソッドを定義することによってオブジェクト指向を模することになる。ここではJSONデータフォーマットの元となったオブジェクト記法でクラスのプロパ ティやメソッドオブジェクトを列挙している。ChannelMemberクラスはチャンネル参加メンバーリストの個々のメンバーを表すオブジェクトで、prefixnickの2つのプロパティを持つ。IRCのチャンネル参加者は階級毎に異なる接頭辞が付くので、その接頭辞とニックネームの双方を別々に保持するための2つのプロパティである。ChannelBufferクラスは先に出てきたチャンネル表示ウインドウのデータを保持するためのクラスである。bufferはチャンネルのログなどが表示されるメインの領域のデータを保持するためのバッファで、membersChannelMemberの配列を保持するチャンネル参加者リストである。チャンネルメンバー追加、メンバーリストのソートなどの一連のメンバーリスト操作メソッドの後に、メンバー リスト、メイン領域、タブ列挙バーの中の個々のタブの描画を担当するレンダリングメソッドが続く。これらレンダリングメソッドは、対象のタブが現在表示さ れているときのみdice.jsのreplaceElement関数を使用してDOMを操作し表示領域を更新する。


function getCurrentTimeString()
{
var ret = "";
var d = new Date();
var tmp = d.getHours();
if (tmp < 10)
tmp = "0" + tmp;
ret += tmp;
ret += ":";
tmp = d.getMinutes();
if (tmp < 10)
tmp = "0" + tmp;
ret += tmp;
ret += ":";
tmp = d.getSeconds();
if (tmp < 10)
tmp = "0" + tmp;
ret += tmp;
return ret;
}

function refreshCurrentBuffer(bufferName, buffer)
{
if (currentTab == bufferName)
replaceElement("currentBuffer", buffer);
}

function passOutputStatusBuffer(s, raw)
{
if (raw)
{
statusBuffer.buffer += ("[" + getCurrentTimeString() + "] " + s);
refreshCurrentBuffer("status", statusBuffer.buffer);
return;
}

var matched = s.match(/\S+\s+:*(.*)/);
if (matched == null)
{
mapSpecialBuffer["status"].buffer += ("[" + getCurrentTimeString() + "] " + s);
return;
}

statusBuffer.buffer += ("[" + getCurrentTimeString() + "] " + matched[1] + "<br>");
refreshCurrentBuffer("status", statusBuffer.buffer);
}

function passOutputRawBuffer(s)
{
rawBuffer.buffer += ("[" + getCurrentTimeString() + "] " + s + "<br>");
refreshCurrentBuffer("raw", rawBuffer.buffer);
}

function escapeHTML(s)
{
return s.split("<").join("&amp;lt;").split(">").join("&amp;gt;");
}


これらはユーティリティ関数群である。getCurrentTimeStringはタイムスタンプ用文字列を生成し、refreshCurrentBufferはタブが現在表示されているときのみ画面を更新する。passOutputStatusBufferはstatusタブへ、passOutputRawBufferはrawタブへそれぞれ文字列を出力する。escapeHTMLはIRCサーバから送られてくる文字列がブラウザによってHTMLとして解釈されないように全てサニタイズするためのフィルタ関数である。


// this function is called from Flash
function output(s)
{
s = escapeHTML(s);

// matched[1] : prefix, matched[2] : command, matched[3] : parameter
var matched = s.match(/^:(\S+)\s(\S+)\s*(.*)/);
if (matched == null)
return;

if (serverName == "")
serverName = matched[1];

passOutputRawBuffer("<font color=red>" + matched[1]
+ "</font> <font color=blue>" + matched[2]
+ "</font> <font color=green>" + matched[3] + "</font>");

switch (matched[2])
{
case "JOIN":
onJOIN(matched[1], matched[3]);
break;
case "KICK":
onKICK(matched[1], matched[3]);
break;
case "MODE":
onMODE(matched[1], matched[3]);
break;
case "NICK":
onNICK(matched[1], matched[3]);
break;
case "NOTICE":
onNOTICE(matched[1], matched[3]);
break;
case "PART":
onPART(matched[1], matched[3]);
break;
case "PRIVMSG":
onPRIVMSG(matched[1], matched[3]);
break;
case "QUIT":
onQUIT(matched[1], matched[3]);
break;
case "332": // RPL_TOPIC
onTOPIC(matched[3]);
break;
case "353": // RPL_NAMREPLY
onNAMREPLY(matched[3]);
break;
case "001": // RPL_WELCOME
onWELCOME(matched[1], matched[3]);
break;
case "002": // RPL_YOURHOST
case "003": // RPL_CREATED
case "004": // RPL_MYINFO
case "005": // RPL_BOUNCE / RPL_ISUPPORT

case "251": // RPL_LUSERCLIENT
case "254": // RPL_LUSERCHANNELS
case "255": // RPL_LUSERME

case "375": // RPL_MOTDSTART
case "372": // RPL_MOTD
case "376": // RPL_MOTDSTART
passOutputStatusBuffer(matched[3]);
break;
default:

break;
}
}

function reportError(s)
{
statusBuffer.buffer += ("[" + getCurrentTimeString() + "] " + s + "<br>");
refreshCurrentBuffer("status", statusBuffer.buffer);
}


outputは、先にProxyIRCの解説で登場した、FlashがIRCサーバーからデータを受け取ったときに呼ばれる関数である。ここで正規表現を用いてIRCメッセージを解析し、得られたコマンドに基づいてそれぞれのコマンド処理関数へ処理を分岐させる。reportErrorはエラーをstatusタブに書き出す関数である。


function renderTabs()
{
var t = "";

for (var i in mapSpecialBuffer)
{
t += mapSpecialBuffer[i].renderTab(i);
}

for (var i in mapChannelBuffer)
{
t += mapChannelBuffer[i].renderTab(i);
}

for (var i in mapPrivateMessageBuffer)
{
t += mapPrivateMessageBuffer[i].renderTab(i);
}

replaceElement("tabs", t);
}

function setCurrentBuffer(name)
{
currentTab = name;
replaceElement("buffer_title", name);
}

function switchTab(name)
{
for (var i in mapSpecialBuffer)
{
if (name == i)
{
setCurrentBuffer(name);
mapSpecialBuffer[name].render();
return;
}
}

for (var i in mapChannelBuffer)
{
if (name == i)
{
setCurrentBuffer(name);
mapChannelBuffer[name].render();
return;
}
}

for (var i in mapPrivateMessageBuffer)
{
if (name == i)
{
setCurrentBuffer(name);
mapPrivateMessageBuffer[name].render();
return;
}
}

alert("Invalid tab");
}

function setCharset(jis)
{
callProxyIRC().setJIS(jis);
}


renderTabsは特殊タブ、チャンネルタブ、プライベートメッセージタブの順でタブ列挙バーの描画を行う。setCurrentBufferは現在のタブを設定するとともに表示ウィンドウのタイトルバーに表示されている文字列を現在のタブの名称に更新する。switchTabはユーザがタブを押したときに呼ばれる関数で、目的のタブを探して表示する。setCharsetはFlashが日本語JISエンコーディングを文字列に適用するか否かを設定する。


function connect()
{
if (!flashIsEnabled)
{
callProxyIRC().testLoopback();

if (!flashIsEnabled)
{
alert("This client requires Adobe Flash Player 9 and a JavaScript-enabled web browser!");
return;
}
}

document.getElementById("buttonConnect").blur();

if (isConnected)
{
if (!confirm("Do you really want to disconnect?"))
return;

callProxyIRC().quitIRC();
onClose();
return;
}

var n = document.getElementsByName("charset");
for (var i = 0; i < n.length; ++i)
{
if (n.item(i).checked)
callProxyIRC().setJIS(n.item(i).value == "JIS" ? true : false);
}

var h = document.getElementById("IRCHost").value;
if (!h || h == "")
{
alert("Invalid host name");
return;
}

var p = document.getElementById("IRCPort").value;
if (!p || p == "")
{
alert("Invalid port");
return;
}

callProxyIRC().connectIRC(h, parseInt(p, 10));
}

function onConnect()
{
isConnected = true;
document.getElementById("buttonConnect").value = "Disconnect";

switchTab("status");

myNick = document.getElementById("nick").value;

send("USER " + myNick + " " + myNick + " " + myNick + ":" + myNick);
send("NICK " + myNick);
}

function onClose()
{
isConnected = false;
document.getElementById("buttonConnect").value = "Connect";

mapChannelBuffer = new Object();
renderTabs();

switchTab("status");
passOutputStatusBuffer("<br>* Disconnected<br><br>", true);

serverName = "";
myNick = "";
}


connectはユーザが接続ボタンを押したときに呼ばれる関数で、接続中に呼ばれると接続を切断するはたらきもある。フォームのホスト欄、ポート欄からそれぞれデータを取得し、Flash側のメソッドを呼び出してIRCサーバへの接続を開始する。onConnectonCloseはそれぞれサーバに接続したとき、切断されたときにFlash側から呼び出される関数で、前者はニックネームを設定するIRCコマンドをサーバへ送信し、後者はチャンネルリストをクリアしてタブ列挙バーを再描画し、画面に切断の表示を出して終了処理を行う。


function joinChannel()
{
document.getElementById("buttonJoin").blur();

if (!isConnected)
{
alert("Not connected");
return;
}

if (document.getElementById("channelName").value == "")
{
alert("No channel specified");
return;
}

if (document.getElementById("channelName").value.charAt(0) != '#')
{
alert("Invalid channel name - it must begin with '#'");
return;
}

send("JOIN " + document.getElementById("channelName").value);
}

function send(s)
{
passOutputRawBuffer("(send) " + s);

callProxyIRC().sendIRC(s);
}

function sendMessage()
{
var m = document.getElementById("message").value;
if (m == "")
return;

if (document.getElementById("raw").checked)
{
document.getElementById("message").value = "";
send(m);
return;
}

var matched = m.match(/\/quote\s+(.*)/i);
if (matched)
{
document.getElementById("message").value = "";
send(matched[1]);
return;
}

for (var i in mapSpecialBuffer)
{
if (currentTab == i)
{
send(m);
return;
}
}

for (var i in mapChannelBuffer)
{
if (currentTab == i)
{
var c = mapChannelBuffer[currentTab];
if (!c)
return;

c.buffer += "[";
c.buffer += getCurrentTimeString();
c.buffer += "] <";
c.buffer += myNick;
c.buffer += "> ";
c.buffer += m;
c.buffer += "<br>";

c.renderBuffer(currentTab);

document.getElementById("message").value = "";
send("PRIVMSG " + currentTab + " :" + m);
return;
}
}

for (var i in mapPrivateMessageBuffer)
{
if (currentTab == i)
{
var pm = mapPrivateMessageBuffer[currentTab];
if (!pm)
return;

matched = currentTab.match(/([^@]+)@/);
if (!matched)
return;

pm.buffer += ("[" + getCurrentTimeString() + "] ");
pm.buffer += "<";
pm.buffer += myNick;
pm.buffer += "> ";
pm.buffer += m;
pm.buffer += "<br>";

pm.renderBuffer(currentTab);

document.getElementById("message").value = "";
send("PRIVMSG " + matched[1] + " :" + m);
return;
}
}
}


joinChannelはユーザが操作パネルから参加対象チャンネルを明示的に選んでチャンネルに参加することを選んだ場合に呼ばれる関数である。sendは裸の文字列をサーバへ送信する関数で、sendMessageは ユーザが操作パネルのメッセージ欄に文字列を記入しEnterキーを押すか送信ボタンを押したときに呼ばれる関数である。IRCサーバはユーザが送信して きたメッセージをそのユーザ自身へエコーしないので、クライアントが自分で自分の発したメッセージを画面に書き出してやる必要がある。


function onJOIN(prefix, param)
{
var matched = prefix.match(/([^!]+)!(\S+)/);
if (matched == null)
return;

if (matched[1] == myNick)
{
var c = new ChannelBuffer("");

c.buffer += ("[" + getCurrentTimeString() + "] ");
c.buffer += "<font color=\"green\">* Now talking in ";
c.buffer += param;
c.buffer += "</font><br>";

mapChannelBuffer[param] = c;

renderTabs();
switchTab(param);
send("MODE " + param);
//send("WHO " + param);
}
else
{
var c = mapChannelBuffer[param];
if (!c)
return;

c.buffer += ("[" + getCurrentTimeString() + "] ");
c.buffer += "<font color=\"green\">* Joins: ";
c.buffer += matched[1];
c.buffer += " (";
c.buffer += matched[2];
c.buffer += ")</font><br>";

c.addMember(ChannelMember("", matched[1]));
c.sortMembers();

c.render(param);
}
}

function onPART(prefix, param)
{
var matched = prefix.match(/([^!]+)!(\S+)/);
if (matched == null)
return;

var c = mapChannelBuffer[param];
if (!c)
return;

c.buffer += ("[" + getCurrentTimeString() + "] ");
c.buffer += "<font color=\"green\">* Parts: ";
c.buffer += matched[1];
c.buffer += " (";
c.buffer += matched[2];
c.buffer += ")</font><br>";

c.removeMember(matched[1]);
c.sortMembers();

c.render(param);
}

function onKICK(prefix, param)
{
var matched = prefix.match(/([^!]+)!(\S+)/);
if (matched == null)
return;

var nick = matched[1];

matched = param.match(/(\S+)\s+(\S+)\s+:(.*)/);
if (matched == null || matched[1] == null || matched[2] == null)
return;

if (matched[2] == myNick)
{
delete mapChannelBuffer[matched[1]];

var s = c.buffer += ("[" + getCurrentTimeString() + "] <font color=\"green\">* You were kicked from ");
s += matched[1];
s += " by ";
s += nick;

if (matched[3])
{
s += " (";
s += matched[3];
s += ")";
}

s += "</font><br>";

passOutputStatusBuffer(s, true);

renderTabs();
switchTab("status");

return;
}

var c = mapChannelBuffer[matched[1]];
if (!c)
return;

c.buffer += ("[" + getCurrentTimeString() + "] ");
c.buffer += "<font color=\"green\">* ";
c.buffer += matched[2];
c.buffer += " was kicked by ";
c.buffer += nick;

if (matched[3])
{
c.buffer += " (";
c.buffer += matched[3];
c.buffer += ")";
}

c.buffer += "</font><br>";

c.removeMember(matched[2]);
c.sortMembers();

c.render(param);
}

function onQUIT(prefix, param)
{
var matched = prefix.match(/([^!]+)!/);
if (matched == null)
return;

var nick = matched[1];
for (var i in mapChannelBuffer)
{
var c = mapChannelBuffer[i];
if (c.removeMember(nick))
{
c.buffer += ("[" + getCurrentTimeString() + "] ");
c.buffer += "<font color=\"blue\">*** Quits: ";
c.buffer += matched[1];
c.buffer += " (";
c.buffer += ((param.length != 0 &amp;&amp; param.charAt(0) == ":") ? param.substr(1): param);
c.buffer += ")</font><br>";

c.sortMembers();
c.render(i);
}
}
}


これらはユーザの入退出に関連するメッセージを受け取ったときの処理関数である。onJOINはユーザがチャンネルに参加してきたときに送られるメッセージで、自分がチャンネルに参加成功したときも送られてくるためそれぞれについて処理する必要がある。onPARTはユーザがチャンネルから退出したというメッセージ、onKICKはチャンネルから強制的に出されたというメッセージを処理する。onQUITはあるユーザがIRCサーバ自体から退出したというメッセージで、onPARTと異なりそのユーザが参加していた全てのチャンネルについて影響を考慮してやる必要がある。


function onMODE(prefix, param)
{
var matched = prefix.match(/([^!]+)/);
if (matched == null)
return;

var subject = matched[1];

matched = param.match(/(\S+)\s+(\S+)\s+(\S*)/);
if (matched == null)
return;

var c = mapChannelBuffer[matched[1]];
if (!c)
return;

// channel mode
if (!matched[3])
{
c.buffer += ("[" + getCurrentTimeString() + "] ");
c.buffer += "<font color=\"green\">* ";
c.buffer += subject;
c.buffer += " sets mode: ";
c.buffer += matched[2];
c.buffer += "</font><br>";

c.renderBuffer(matched[1]);
return;
}

// user mode

c.buffer += ("[" + getCurrentTimeString() + "] ");
c.buffer += "<font color=\"green\">* ";
c.buffer += subject;
c.buffer += " sets mode: ";
c.buffer += matched[2];
c.buffer += " ";
c.buffer += matched[3];
c.buffer += "</font><br>";

c.setMemberMode(matched[3], matched[2]);
c.sortMembers();

c.render(matched[1]);
}

function onNICK(prefix, param)
{
var matched = prefix.match(/([^!]+)!/);
if (matched == null)
return;

var subject = matched[1];

matched = param.match(/:(\S+)/);
if (matched == null)
return;

var newNick = matched[1];

var m = ("[" + getCurrentTimeString() + "] <font color=\"green\">* ");
m += subject;
m += " is now known as ";
m += newNick;
m += "</font><br>";

if (subject == myNick)
{
myNick = newNick;
document.getElementById("nick").value = myNick;
}

for (var i in mapChannelBuffer)
{
var c = mapChannelBuffer[i];
var removed = c.removeMember(subject);
if (removed)
{
c.buffer += m;
c.addMember(ChannelMember(removed.prefix, newNick));
c.sortMembers();
c.render(i);
}
}
}


onMODEはチャンネルまたはユーザのモードが変化したとき、onNICKはユーザがニックネームを変更したときに送られるメッセージを処理する。ユーザのニックネーム変更はonQUIT同様に全ての関係チャンネルで処理しなければならない。


function onNOTICE(prefix, param)
{
var matched = prefix.match(/([^!]+)/);
if (matched == null)
return;

var nick = matched[1];

matched = param.match(/(\S+)\s+:(.*)/);
if (matched == null)
{
passOutputStatusBuffer("-" + serverName + "- " + param.substr(1) + "<br>", true);
return;
}

var c = mapChannelBuffer[matched[1]];
if (!c)
{
delete mapChannelBuffer[matched[1]];
passOutputStatusBuffer("-" + nick + "- " + matched[2] + "<br>", true);
return;
}

c.buffer += ("[" + getCurrentTimeString() + "] ");
c.buffer += "-> (";
c.buffer += nick;
c.buffer += ") ";
c.buffer += matched[2];
c.buffer += "<br>";

c.renderBuffer(matched[1]);
}

function onPRIVMSG(prefix, param)
{
var matched = prefix.match(/([^!]+)/);
if (matched == null)
return;

var nick = matched[1];

matched = param.match(/(\S+)\s+:(.*)/);
if (matched == null)
return;

var c = mapChannelBuffer[matched[1]];
if (!c)
{
if (matched[1] == myNick)
{ // private message
var pm = mapPrivateMessageBuffer[nick + "@" + serverName];
if (pm)
{
pm.buffer += ("[" + getCurrentTimeString() + "] ");
pm.buffer += "<";
pm.buffer += nick;
pm.buffer += "> ";
pm.buffer += matched[2];
pm.buffer += "<br>";

pm.renderBuffer(nick + "@" + serverName);
}
else
{
var pm = new ChannelBuffer("");
pm.buffer += ("[" + getCurrentTimeString() + "] ");
pm.buffer += "<";
pm.buffer += nick;
pm.buffer += "> ";
pm.buffer += matched[2];
pm.buffer += "<br>";

mapPrivateMessageBuffer[nick + "@" + serverName] = pm;

renderTabs();
}
}
return;
}

c.buffer += "[";
c.buffer += getCurrentTimeString();
c.buffer += "] <";
c.buffer += c.getMemberPrefix(nick);
c.buffer += nick;
c.buffer += "> ";
c.buffer += matched[2];
c.buffer += "<br>";

c.renderBuffer(matched[1]);
}

function openPrivateMessage(target)
{
if (serverName == "")
{
alert("Not connected");
return;
}

var matched = target.match(/([^@]+)/);
if (!matched)
return;

var pm = mapPrivateMessageBuffer[matched[1] + "@" + serverName];
if (!pm)
{
var pm = new ChannelBuffer("");
mapPrivateMessageBuffer[matched[1] + "@" + serverName] = pm;

renderTabs();
}

switchTab(matched[1] + "@" + serverName);
}


IRCのチャンネル内で発言するにはPRIVMSGによるかNOTICEによるかのどちらかであり、onNOTICEはそのうちNOTICEメッセージを、onPRIVMSGは PRIVMSGメッセージを扱う。PRIVMSGメッセージは引数がユーザの時には1対1のプライベートメッセージのためのメッセージとしても使用され る。このプライベートメッセージを受け取ると、対象ユーザと会話するためのタブが存在しない場合は新たに1対1会話用のタブを生成してmapPrivateMessageBufferへ追加する。openPrivateMessageはユーザがチャンネル参加者リストの中の1対1で会話したいメンバーをクリックしたときに新しくタブを生成する関数である。


function onTOPIC(param)
{
var matched = param.match(/\S+\s+(\S+)\s+:(.*)/);
if (matched == null)
return;

var c = mapChannelBuffer[matched[1]];
if (!c)
return;

c.buffer += "[Topic] ";
c.buffer += matched[2];
c.buffer += "<br><br>";

c.render(matched[1]);
}

function onNAMREPLY(param)
{
var matched = param.match(/=\s+(\S+)\s+:(.+)/);
if (matched == null)
return;

var c = mapChannelBuffer[matched[1]];
if (!c)
return;

var a = matched[2].split(" ");
for (var i in a)
{
switch (a[i].charAt(0))
{
case "@":
c.addMember(ChannelMember("@", a[i].substr(1)));
break;
case "+":
c.addMember(ChannelMember("+", a[i].substr(1)));
break;
default:
c.addMember(ChannelMember("", a[i]));
break;
}
}

c.sortMembers();
c.renderMembers(matched[1]);
}

function onWELCOME(prefix, param)
{
// it's not necessarily true that welcome message is the first one the server sents in
if (serverName != prefix)
serverName = prefix;

var matched = param.match(/(\S+)\s+:*/);
if (matched != null)
{
myNick = matched[1];
document.getElementById("nick").value = myNick;
}

passOutputStatusBuffer(param);
}


onTOPICはチャンネルトピックを通知するメッセージを、onNAMEREPLYはチャンネル参加者を列挙するメッセージを処理する。双方ともチャンネル参加時に送られてくるメッセージである。それに対し、onWELCOMEは申請したニックネームがサーバに登録されサーバに受容されたときに送られてくるもので、ユーザのニックネームを含んでおり、申請したニックネームを重複などの理由によりサーバが勝手に変更した場合にも対処できるようにしてある。


function onXButtonClick()
{
for (var i in mapSpecialBuffer)
{
if (i == currentTab)
{
alert("This tab can't be closed");
return;
}
}

for (var i in mapChannelBuffer)
{
if (i == currentTab)
{
send("PART " + currentTab);

delete mapChannelBuffer[currentTab];

renderTabs();
switchTab("status");
return;
}
}

for (var i in mapPrivateMessageBuffer)
{
if (i == currentTab)
{
delete mapPrivateMessageBuffer[currentTab];

renderTabs();
switchTab("status");
return;
}
}

alert("Invalid buffer");
}

function onClearThisBuffer()
{
document.getElementById("buttonClearBuffer").blur();

if (!confirm("Do you really want to clear the content of the current buffer?"))
return;

for (var i in mapSpecialBuffer)
{
if (currentTab == i)
{
mapSpecialBuffer[currentTab].buffer = "";
mapSpecialBuffer[currentTab].renderBuffer(currentTab);
return;
}
}

for (var i in mapChannelBuffer)
{
if (currentTab == i)
{
mapChannelBuffer[currentTab].buffer = "";
mapChannelBuffer[currentTab].renderBuffer(currentTab);
return;
}
}

for (var i in mapPrivateMessageBuffer)
{
if (currentTab == i)
{
mapPrivateMessageBuffer[currentTab].buffer = "";
mapPrivateMessageBuffer[currentTab].renderBuffer(currentTab);
return;
}
}

alert("Invalid buffer");
}


onXButtonClickは、上述のように、タブ右上のXボタンを押すとそのタブを閉じることが出来るというもので、同時にそのタブが表象していたチャンネルを離脱するようになっている。onClearThisBufferは、操作パネルのタブの内容をクリアするボタンを押したときの動作を記述している。


function loadChannelList()
{
if (!isConnected)
{
alert("Not connected");
return;
}

channelListBuffer.buffer += "Now loading...";
refreshCurrentBuffer("channels", channelListBuffer.buffer);

sendHTTP('' , 'GET', './channels', showChannels, true);
}

function showChannels(hr)
{
var nodelist = hr.responseXML.getElementsByTagName("Channel");
if (!nodelist)
{
channelListBuffer.buffer = channelListHeader;
channelListBuffer.buffer += "Currently there are no channels on the server.";
refreshCurrentBuffer("channels", channelListBuffer.buffer);

return;
}

var output = nodelist.length.toString();
output += (nodelist.length == 1 ? " channel has been retrieved.<br><br>" : " channels have been retrieved.<br><br>");

for (var i = 0; i < nodelist.length; ++i)
{
var n = nodelist.item(i);
if (n == null)
continue;

var users = n.getAttribute("Users");

var nn = n.firstChild;
var name = "";
var topic = "";

while (nn != null)
{
if (nn.nodeName == "Name")
{
// nn.textContent == Mozilla only
var a = nn.firstChild;
// NODE_TEXT == 3 || NODE_CDATA_SECTION == 4
while (a != null &amp;&amp; (a.nodeType == 3 || a.nodeType == 4))
{
name = a.nodeValue;
a = a.nextSibling;
}
}
else if (nn.nodeName == "Topic")
{
var a = nn.firstChild;
// NODE_TEXT == 3 || NODE_CDATA_SECTION == 4
while (a != null &amp;&amp; (a.nodeType == 3 || a.nodeType == 4))
{
topic = a.nodeValue;
a = a.nextSibling;
}
}

nn = nn.nextSibling;
}

output += (i + 1).toString();
output += ". ";
output += "<a style=\"text-decoration: none;\" href=\"#\" onclick=\"joinChannelFromList('#";
output += name;
output += "')\">#";
output += name;
output += "</a> ";
output += topic;
output += " (";
output += users;
output += " users)<br><br>";
}

channelListBuffer.buffer = channelListHeader;
channelListBuffer.buffer += output;

refreshCurrentBuffer("channels", channelListBuffer.buffer);
}

function joinChannelFromList(channelName)
{
for (var i in mapChannelBuffer)
{
if (i == channelName)
{
switchTab(channelName);
return;
}
}

send("JOIN " + channelName);
}


loadChannelListは、AJAXでサーバからIRCチャンネルリストを得る関数である。DICEは、/channelsパスにアクセスするとIRCチャンネルリストをXMLで返す。IRC側にもチャンネルリストを得るコマンドは勿論存在するが、ここでは元々Webチャンネルブラウザのために実装したインターフェイスを再利用している。そのコールバック関数がshowChannelsで、AJAXリクエスト完了時に受け取ったXMLを処理する。joinChannelFromListは、チャンネルリスト上のチャンネル名をクリックしたときに呼ばれる関数で、目的のチャンネルへユーザを参加させる。


function onKeyDown(e)
{
if (e.keyCode == 13 &amp;&amp; isConnected) // Enter
{
sendMessage();
}
}


これがJavaScriptコードの最後の部分で、先に挙げたHTMLのデザイン部分が後に続く。onKeyDownはキー入力を監視し、Enterキーが押されるとメッセージ欄に記入されているメッセージを送信する。このバージョンではIEだとEnterキー押下時に音が鳴るが、formタグの使用を止めれば回避できる。このIRCクライアントWebアプリケーションはDICEのパッケージに同梱されているので、デザインも一新されている最新版での実際の動作はDICEをインストールして確かめてみて欲しい(本記事で掲載しているバージョンのActionScriptコードに存在する日本語JISコードの取り扱いに関するバグはそちらでは修正済みである)。

AJAXやWeb 2.0と形容されるものを超えた先にあるのは何か? WebブラウザがHTTPではないプロトコルのサーバに自由に接続することが当たり前のようになるとき、WebブラウザはWebを超えたプラットフォームとなり、Webそのものの刷新が起こるのではないか。そのときWebブラウザはWebブラウザではない他の存在に、WebはWebではない他の何かになっているのかもしれない。

(2008-04-11追記) 現時点で、Adobe Flash Player 9.0.124.0でのFlashのセキュリティ仕様変更により、本稿のクライアントを動作させるにはサーバ側の対応が必要となる。DICEではバージョン0.9.1.1で対応完了している。

コメント