1枚psgiファイルでchatのようなもの

前々から同時接続中のクライアントにサーバから通知というのがものすごーくやりたかったのだけどこのたびTwiggyTatumakiのegとぶつかり稽古を重ねてようやくできた。
多分最近だとこういうのやるのにWebSocketとかでやるのかもしれないけどそれだとブラウザが対応してなかったりするとアレなのとTwiggyのegと全く同じになってしまう気がするのでjquery.ev.js使ってlong-poll(というのかしら)でやってますです。

use strict;
use warnings;
use Encode;
use JSON;
use Data::Section::Simple;
use Text::Xslate;
use Plack::Request;
use Plack::Builder;

#$ENV{TWIGGY_DEBUG} = 1;

my $vpath = Data::Section::Simple->new()->get_data_section();
my $tx    = Text::Xslate->new(path => [$vpath]);

my $timer;
my %clients;

my $app = sub {
    my $env = shift;
    my $req = Plack::Request->new($env);

    if ($req->path eq '/') {
        my $html = $tx->render('index.tx');
        return [
            200,
            ['Content-Type' => 'text/html', 'Content-Length' => length $html ],
            [ $html ]
        ];
    }
    elsif ($req->path eq '/post') {
        my $message = decode_utf8( $req->param('message') );
        my $name    = decode_utf8( $req->param('name') );
        my $current_time = time();

        # 全てのクライアントにレスポンスを返す
        for my $cv (values %clients) {
            undef $timer;
            $cv->send([
                200, 
                ['Content-Type' => 'text/plain'], 
                [ encode_json [ 
                    { 
                        type => 'message', 
                        message => $message, 
                        name => "${name}[$env->{REMOTE_ADDR}]", 
                        current_time => $current_time,
                    }, 
                ] ] 
            ]);
        }
        return [ 200, ['Content-Type' => 'text/plain'], ['success'] ];
    }
    elsif ($req->path eq '/poll') {
        my $client_id = $req->param('_');

        my $fh = $env->{'psgix.io'}
            or return [500, ['Content-Type' => 'text/plain'], ['something wrong']];

        my $cv = AnyEvent->condvar;
        $clients{ $client_id } = $cv;

        return sub {
            my $responder = shift;

            # callbackの中でrecvしないと×
            $cv->cb(sub {
                # $cv->send()するとここに戻ってくる。$resには$->send()に渡した引数が返ってくる。
                my $res = $cv->recv; 
                my $w = $responder->($res);
            });

            # イベント待ちタイマー
            $timer = AnyEvent->timer(
                after    => 30, 
                interval => 0, 
                cb       => sub {
                    undef $timer;
                    delete $clients{ $client_id };
                    $cv->send( [200, ['Content-Type' => 'text/plain'], [ encode_json +[] ] ] );
                }
            );
        };
    }
    else { 
        return [
            404, 
            ['Content-Type' => 'text/plain'],
            ['not found']
        ];
    }
};

builder {
    mount '/chat' => builder {
        enable "Static", path => sub { s!^/static/!! }, root => './htdocs/static';
        $app;
    };
};
__DATA__
@@ index.tx
<html>
<head>
<title>chat</title>
<script src="http://code.jquery.com/jquery-1.4.4.min.js" type="text/javascript"></script>
<script src="/chat/static/js/jquery.ev.js" type="text/javascript"></script>
<script type="text/javascript">
function get_duration(millisecond) {
    
    // milli => sec
    var second  = parseInt( millisecond / 1000 );
    var r_milli = millisecond % 1000;

    // sec => min
    var minute = parseInt( second / 60 );
    var r_sec  = second % 60;

    // min => hour
    var hour  = parseInt( minute / 60 );
    var r_min = minute % 60;

    // hour => day
    var day = parseInt( hour / 24 );
    var r_hour = hour % 24;

    if (day) {
        return day + 'days ago'; 
    }
    else if (hour) {
        return hour + 'hours and ' + r_min + 'minutes ago';
    }
    else if (minute){
        return minute + ' minutes and ' + r_sec + ' seconds ago';
    }
    else { 
        return second + ' seconds ago';
    }
}
function replace_duration(current_time) {
    $(".duration").each(function() {
        var post_time = $(this).attr('title');
        if (current_time === post_time) {
            return true;
        }
        var time = current_time - post_time;
        $(this).text( get_duration(time * 1000) );
    });
}

$(document).ready(function() {
    $("#message").keyup(function(e) {
        if (e.keyCode === 13 && $(this).val()) {
            $.ajax({
                url : '/chat/post',
                data: { message : $(this).val(), name : $("#name").val() || 'unknown' },
                success: function(data) {}
            });
        }
    });

    $.ev.loop('/chat/poll', {
        message : function(m) {
            var date = new Date();
            var duration = get_duration(m.current_time * 1000 - date.getTime());
            $("#messages").prepend(
                $('<tr/>').append( $('<td/>').addClass('name').append(m.name)    )
                          .append( $('<td/>').addClass('message').append(m.message) )
                          .append( $('<td/>').addClass('duration').append(duration).attr('title', m.current_time) )
            );
            replace_duration(m.current_time);
        }
    });
});
</script>
<style type="text/css">
h1 {
    font-size: 50px;
}

hr {
    border-top: 1px solid gray;
}
table {
    white-space:nowrap;
    table-layout: fixed;
    width: 100%;
}
#messages {
    font-size: small;
}
.name {
    width: 15%;
    font-weight: bold;
}

.message {
    width:70%;
    text-align:left;
    float:left;
}
.duration {
    text-align:right;
    font-size:xx-small;
    color: gray;
    vertical-align: bottom;
}
</style>
</head>
<body>
<h1>chat modoki</h1>
<lable>name:</label>
<input type="text" name="name" id="name" size="15" />
<lable>message:</lable>
<input type="text" name="message" id="message" size="70" />
<hr />
<table id="messages"></table>
</body>
</html>

Twiggy使って非同期なアプリを書くときに自分なりに考えた要点

AE使いたいアクションの場合はコードリファレンスを返してやる。

レスポンスとしてコードリファレンスを返すとTwiggyがそのコードリファレンスの第一引数にコードリファレンスを渡してくれる。そしてこの渡されたコードリファレンスにPSGIレスポンスを渡して実行してやるとクライアントにレスポンスが返る感じになる。

$cv->send()に渡した値が$cv->recv()の返り値となる

なのでAEのコールバックで$cv->send()するときにクライアントに返したいPSGIレスポンスを渡してやる。そうすると$cv->recv()に戻ってくるので後はその返り値を受け取ってTwiggyから渡されたコードリファレンスに渡してやればいい。

あとjquery.ev.jsの使い方についてちょっと

パッと見で使い方がわからなかったので。{type : 'message'}というJSONを返してやると$.ev.loop()の第二引数に渡したハンドラオブジェクトのmessageに登録した関数が実行されるという仕組みのよう。

こんなちょっとしたことでもなんだかんだで1ヶ月ぐらい格闘してたわーーー