Help us understand the problem. What is going on with this article?

ブラウザでNode.js(KoaJS)サーバを走らせる

More than 3 years have passed since last update.

:calendar_spiral: gloops Advent Calendar 24日目

:santa_tone2: Merry Christmas!! gloops Advent Calendar 24日目です
23日目は @t_shibaki さんの「webのサーバーエンジニアがunreal engineをさわってみた」でした
昔と違ってプログラムを書かずにゲームが作れちゃうんですね。。すごい


早速ですが、タイトルだけ見ると「何を言ってるんだ??」という感じだと思います
何がしたいかというと、ブラウザ(Service Worker)内で KoaJS 1 を実行しリクエストを処理させれば
オフラインアプリに近いことができるのでは?と試してみたところ実現できたので、共有します
(全然クリスマスっぽいネタじゃなくてすみません!)

概念図

概念図.png

初回のみ、ServiceWorker、ServiceWorker 登録用の HTML と JavaScript をサーバにリクエストします
2回目移行は ServiceWorker 内で動いている KoaJS サーバがルーティング処理などを行います

ビルド環境構成

  • Node.js(v6.9.1)
  • KoaJS(v1.2.4)
  • Webpack(v2.1.0-beta.27)

ServiceWorker の JavaScript を作成するために必要なものです
参考にさせて頂いたリポジトリは express + browserify という構成でしたが、今回は慣れている構成で試しました

KoaJS サーバの実装

ブラウザ内で実行させる前に、まずは普通にサーバを実装し、オンラインでどんな感じになるか確認しておきます

app.js
const app = require('koa')();
const route = require('koa-route');

function buildHtml(header, link1, linkText1, link2, linkText2) {
    return `
<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, minimum-scale=1, maximum-scale=1, user-scalable=no">
    <title>Koa Service</title>
    <link rel="stylesheet" href="./bootstrap.min.css">
</head>
<body>
    <nav class="navbar navbar-default" role="navigation">
        <div class="container-fluid">
            <div class="navbar-header">
                <h1 class="navbar-brand">${header}</h1>
            </div>
        </div>
    </nav>
    <div class="container-fluid">
        <a class="btn btn-info" role="button" href="${link1}">${linkText1}</a>
        <a class="btn btn-info" role="button" href="${link2}">${linkText2}</a>
    </div>
</body>
</html>
`;
}

app.use(route.get('./', function* () {
    this.body = buildHtml('Index Page', './about', 'To About', './help', 'To Help');
}));

app.use(route.get('./about', function* () {
    this.body = buildHtml('About Page', './', 'To Index', './help', 'To Help');
}));

app.use(route.get('./help', function* () {
    this.body = buildHtml('Help Page', './', 'To Index', './about', 'To About');
}));

app.listen(3000); // バンドルする時には必要ないので消します

今回は説明を簡単にするために、bootstrap で装飾された3つのページがあり
それぞれがリンクで繋がっている HTML を返すだけにしました

node app.js を実行し、http://localhost:3000 にアクセスしてみます
index、about、help というページがリンクで繋がっているのが確認できたと思います

これでサーバが用意できました
ここからはブラウザ内で実行させるための手順を説明していきます

いろいろな問題

ブラウザ内で実行させるために、いろいろな問題を解決する必要がありました

ブラウザでは Node.js を実行できない(コアモジュールを require できない)

Node.js のモジュールは様々なコアモジュールを require していますが
ブラウザではそれが不可能なため、Webpack でコアモジュールも含めて1つのファイルにバンドルします

webpack.config.js
module.exports = {
    entry: './src/server.js',
    output: {
        filename: './docs/sw.js'
    },
    target: 'web'
};

ポイントは target の設定です
'web' を設定することでコアモジュールもまとめて1つにバンドルしてくれます

この状態でビルドしてみると・・

Module not found: Error: Can't resolve 'fs' in 'koa-service\node_modules\destroy'
Module not found: Error: Can't resolve 'net' in 'koa-service\node_modules\koa\lib'

エラーでコケました・・orz
おそらく target: 'web' にしたため、ブラウザ向けの代替モジュールがないためエラーになったようです

今回は下記のように回避しました

webpack.config.js
module.exports = {
    entry: './src/server.js',
    output: {
        filename: './docs/webpack.sw.js'
    },
    target: 'web',
    node: {
        fs: 'empty',
        net: 'empty'
    }
};

empty を設定すると空のモジュールを用意してくれるようです
実際に書き出された JavaScript を見ると、こんな感じになっていました

/***/ },
/* 63 */
/***/ function(module, exports) {

/***/ },

この状態でビルドしてみると・・

ERROR in ./~/statuses/codes.json
Module parse failed: \koa-service\node_modules\statuses\codes.json Unexpected token (2:7)

今度は json の読み込みに失敗してるようなので loader の設定を追加してみます
(json-loader のインストールも必要です)

webpack.config.js
module.exports = {
    entry: './src/server.js',
    output: {
        filename: './docs/sw.js'
    },
    target: 'web',
    node: {
        fs: 'empty',
        net: 'empty'
    },
    module: {
        rules: [
            { test: /\.json$/, loader: 'json-loader' },
        ]
    }
};

これでビルドが通るようになりました!
(ウェブパッ筋があまり高くないので、他の方法でもイケるよ!というご指摘お待ちしておりますm(_ _)m)

実際に読み込ませてみると・・

書き出された ServiceWorker をブラウザに読み込ませてみると

Uncaught ReferenceError: window is not defined
Uncaught TypeError: global.XMLHttpRequest is not a constructor

このようなエラーが出てしまいます
ServiceWorker 内でのグローバル変数は window ではなく global に定義されてるため
存在しない window オブジェクトにアクセスしているのと、global.XMLHttpRequest が存在しないためエラーになったようです

patch.js
if (typeof window === 'undefined') {
    window = global;
}

if (typeof global.XMLHttpRequest === 'undefined') {
    global.XMLHttpRequest = function () {
        this.open = function () {};
    }
}

上記のようなパッチを用意して、しのいでみました
このパッチを含めてビルドすると、エラーが出ない状態になります

ServiceWorker 内での KoaJS の処理

ここまでで、ServiceWorker 内でサーバーを走らせる準備ができました
あとは、KoaJS に対してリクエストを投げることができればOKです

まずコードを見た方がイメージしやすいと思いますので、載せてみます
install イベント、activate イベント時は特殊なことはしてないので省略します

service-worker.js
self.addEventListener('fetch', (event) => {
    // 省略

    event.respondWith(new Promise((resolve) => {
        const parsedUrl = url.parse(event.request.url);
        const splitedPath = parsedUrl.pathname.split('/');

        const req = {
            url: `./${splitedPath[splitedPath.length - 1]}`, // Make relative url
            method: event.request.method,
            socket: {}
        };

        const res = {
            _headers: {},
            end(body) {
                const options = {
                    status: this.statusCode || 200,
                    headers: this._headers
                };
                resolve(new Response(body, options));
            },
            setHeader(name, value) {
                this._headers[name] = value;
            }
        };

        const handleRequest = app.callback();
        handleRequest(req, res);
    }));
});

重要なポイントはここです

const handleRequest = app.callback();
handleRequest(req, res);

下記のように、KoaJS では callback() で得られた requestListener を http.createServer に渡すことでリクエスト処理をしていることがわかります

application.js
app.listen = function(){
  debug('listen');
  var server = http.createServer(this.callback());
  return server.listen.apply(server, arguments);
};

同じように callback() で得られた requestListener を fetch 時に呼び出すことで
リクエスト処理させるということです

requestListner には http.IncomingMessage である req と http.ServerResponse である res を渡す必要がありますが
これらのインスタンスは http モジュール内でしか作成できないっぽい?ので自前で用意します

const req = {
    url: `./${splitedPath[splitedPath.length - 1]}`, // Make relative url
    method: event.request.method,
    socket: {}
};

req には url と HTTP Method を指定するだけ十分です
(socket は request モジュール内で参照されるため、とりあえず空のオブジェクトを用意しました)

const res = {
    _headers: {},
    end(body) {
        const options = {
            status: this.statusCode || 200,
            headers: this._headers
        };
        resolve(new Response(body, options));
    },
    setHeader(name, value) {
        this._headers[name] = value;
    }
};

res には endsetHeader を用意します
end はリクエスト処理が終わった時に KoaJS 内部で呼ばれます
引数で渡された body と option で作成したレスポンスで resolve することでリクエスト処理が終了します

これで、ServiceWorker 内で KoaJS がリクエストをさばけるようになりました

最終的なソースコードはこちらにあるので興味がある方はご覧ください

デモ

https://kanatapple.github.io/koa-service/
(実際に触れます)

動作確認済み環境:PC Chrome 55.0.2883.87 m (64-bit)

ServiceWorkerDemo.gif

ご覧頂くとわかると思いますが、全て ServiceWorker からリソースを取得しています
また、DeveloperTools の Offline にチェックを入れてもページ遷移が正常に行われています

まとめ

  • 簡単なものであれば、完全なオフラインアプリが作成できそう
  • 強引にコンパイルを通している部分があるので考慮が必要
  • Node.js 上でしか動かないモジュールは代替手段を用意する必要がある(ファイル保存の代わりに IndexedDB に入れるとか)

参考

https://github.com/bahmutov/express-service


最終日25日目は @j_kitayama_hoge000 さんです
クリスマスっぽいものを紹介してくれるそうなので期待しましょう!


  1. Node.js の Web Framework。express の方がよく知られているかも 

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした