node.jsでhttp sessionを共有するsocket.ioのテストを書く

node.js + socket.io はリアルタイムwebアプリを作るのにとても良い組み合わせだと思っています。
しかし、基本的にsocket.ioのconnectionはhttpのsessionと関連が無いので、例えばそのconnectionがログインしているユーザのものかその他のユーザのものか区別がつかない。以前はSessionWebSocketを使ってそれを判別するようにしていたのだけど、残念なことに残念なのでコレはもう使いたくない。。
そこでclientから最初にcookieを送信してもらって、そこからclientのsessionを識別する、という方法がある、と以前@さんに教えていただきました。 *1

session共有方法

クライアント側では下記のように接続時にcookieを送信するようにしておき、

    var socket = new io.Socket();
    socket.on('connect', function() {
        socket.send({ cookie: document.cookie });
    });
    socket.connect();

サーバ側ではsession storeをsocket.ioと共有できるようにしておく。例えばexpressを使っている場合は

    var express = require('express'),
        app = express.createServer(),
        config = { cookie_secret: "****" };

    var store = new (require('connect').session.MemoryStore)();
    app.use(express.cookieParser());
    app.use(express.session({
        store: store,
        secret: config.cookie_secret,
        cookie: { httpOnly: false }
    }));

のようなかんじで設定。デフォルトでhttpOnlyはtrueになっているけどこれをfalseにしておかないとclientでcookieが取得出来ないので。。
この例ではデフォルトのMemoryStoreを使っているけど、connect-redisやconnect-mongodbのように別のものに置き換えて使うことも可能*2
socket.ioサーバでは、このstoreを使ってcookieからsession情報を引けるようにする。

module.exports = function(http, store) {
    var socket = require('socket.io').listen(http, { log: false });

    socket.on('connection', function(client) {
        client.on('message', function(msg) {
            var cookie = msg.cookie;
            if (cookie) {
                // 送られてきたcookieからsession keyを取得し
                var parseCookie = require('connect').utils.parseCookie,
                    sid = parseCookie(cookie)['connect.sid'];
                // storeからsession情報を引いて
                store.get(sid, function(err, session) {
                    // とりあえずここではclientにsessionデータを送ってやる
                    client.send({ data: session.data });
                });
            }
        });
    });

    return socket;
};

こうしておくことで、httpサーバもsocket.ioサーバも同じsession storeを見るので、session情報を共有することができるようになる。

テストを書く

で、これもできればテストを書きたい。と思ってそもそもsocket.ioのテストってどうするんだろうとsocket.ioパッケージについているtestを調べてみたら、node-websocket-clientを使ってclientからの接続を作ってtestを書いているようだった。それを参考に、上記のhttp sessionを共有するsocket.ioサーバのテストを書いてみた。

require('../test_helper');

var port = 15873;
var path = require('path');
var Cookie = require('connect').session.Cookie;
var utils = (function() {
    var socketio_dir = path.dirname(require.resolve('socket.io')),
        utils_path = path.join(socketio_dir, 'lib', 'socket.io', 'utils');
    return require(utils_path);
})();

function client() {
    var dir = path.dirname(require.resolve('socket.io')),
        websocket_path = path.join(
            dir, 'support', 'node-websocket-client', 'lib', 'websocket'
        ),
        WebSocket = require(websocket_path).WebSocket,
        url = 'ws://localhost:' + port + '/socket.io/websocket';
    return new WebSocket(url);
}

var http = require('http').createServer(),
    store = new (require('connect').session.MemoryStore)(),
    server;

QUnit.module('socket.io & session', {
    setup: function() {
        http.listen(port);
        server = require('../lib/socket.io')(http, store);
    },
    teardown: function() {
        http.close();
    }
});

QUnit.test('connect, message, disconnect', function() {
    var cookie = new Cookie();

    QUnit.stop();

    store.set('hoge', { data: 'fuga', cookie: cookie }, function() {
        assert.ok(true, 'session set');
        QUnit.start();
    });

    QUnit.stop();

    var c = client(http);
    c.onmessage = function(ev) {
        if (! c._first) {
            // 1回目はsessionIdが送られてくる?
            c._first = true;
            return;
        }

        var rawmsg = utils.decode(ev.data)[0],
            frame = rawmsg.substr(0, 3),
            msg;
        switch (frame) {
        case '~h~':
            return c.send(utils.encode(rawmsg)); // echo
        case '~j~':
            msg = JSON.parse(rawmsg.substr(3));
            break;
        }

        // dataを受け取ったらcloseする
        assert.deepEqual(msg, { data: 'fuga' }, 'message');
        c.close();
    };
    c.onopen = function() {
        assert.ok(true, 'connect');
        c.send(utils.encode({
            cookie: cookie.serialize('connect.sid', 'hoge')
        }));
    };

    server.on('clientDisconnect', function() {
        assert.ok(true, 'disconnect');
        QUnit.start();
    });
});

QUnit.start();

qunit-tapを使ってnode.jsのテストをproveで行う - すぎゃーんメモで用意したtest_helperスクリプトを使ったQUnit+qunit-tapのテストになっています。使用portは空いてるものを見つけるようにした方が良いと思うけどここでは決め打ちで。
setup時にhttpサーバを適当に上げて、MemoryStoreと一緒にモジュール化したsocket.ioサーバに渡して立ち上げる。最初にsessionデータをcookieと一緒に保存し、websocket-clientでクライアント側からの接続を作って非同期でconnect -> cookie送信 -> message受信 -> 取得したsessionの情報を確認 -> disconnectまでの流れ。disconnectまで終わったらテスト終了。

$ prove --ext=.js --exec=node -v t/socket.io.js
t/socket.io.js ..
# module: socket.io & session
# test: connect, message, disconnect
ok 1 - session set
ok 2 - connect
ok 3 - message
ok 4 - disconnect
1..4
ok
All tests successful.
Files=1, Tests=4,  1 wallclock secs ( 0.02 usr  0.00 sys +  0.14 cusr  0.02 csys =  0.18 CPU)
Result: PASS

websocket-clientはnpmパッケージあるけどsocket.ioにも同梱されているのでそこから持ってくることができる。ブラウザ用に書くクライアント側のsocket.ioとちょっと違うのでインタフェースを合わせた下記のようなラッパーを使用した方がスッキリすると思う。
https://github.com/masahiroh/Socket.io-node-client


何にせよ、このように

  • socket.ioサーバをhttpサーバ&session storeを渡して動かすようにするモジュールとして切り出す
  • test時にはsetupで立ち上げてteadownで落とす
  • cookieも作ってMemoryStoreにsessionを入れておき、testの中で検証できるようにする
  • websocket-clientでクライアント側からの接続をエミュレート

というようにやればtestは書けるはず。非同期のやりとりはゴチャゴチャするかもしれないけど…

*1:websocketなどの場合は明示的にclientから送られなくてもclient.request.headers.cookieで取得できるようだけど、jsonp-pollingなどの場合はclient.requestが空になってしまうようなので接続時に明示的にdocument.cookieを送ってもらった方が確実そう

*2:むしろproductionではそうすべき、か