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

ServiceWorker内にExpressサーバーを立てる

More than 3 years have passed since last update.

:christmas_tree: この記事は Node.js Advent Calendar 2016 24日目の記事です。 :christmas_tree:

今回は、ServiceWorker内の動作をExpressフレームワークを用いて記述できるようにするまでを解説します。

はじめに

Node学園祭で「Browser is the new server」というセッションの際に発表されていたライブラリ、express-service(bahmutov氏作成)が非常に興味深かったため、その処理内容を追いました。

Browser is the new server
スライド / 反応まとめ

今回のデモコードは、以下から参照できます。
https://github.com/narirou/test-express-service-worker
https://test-express-service-worker.herokuapp.com/

ServiceWorkerの仕組み

まずは、ServiceWorkerの仕組みについて簡単におさらいします。

ServiceWorkerはブラウザがWebページとは別にバックグラウンドで実行可能な動作を提供します。
ブラウザからは特定のイベントを監視でき、それらに介入することが可能になります。

詳しくは以下を参照ください。
Service Worker の紹介  |  Web  |  Google Developers
Service Workerの利用について - Web API インターフェイス | MDN
Service Workerの基本とそれを使ってできること - Qiita
などが参考になります。


ServiceWorkerのライフサイクルから見ていきましょう。ライフサイクルはページ側とは異なります。
ライフサイクル図

以上を踏まえて、記述する処理は以下になります。

  • まずはブラウザにて、ServiceWorkerをinstallします。
main.js
navigator.serviceWorker.register('/service-worker.js');
  • 上記が成功すると、ServiceWorker内のスクリプトでinstallイベントが発行されます。
service-worker.js
self.addEventListener('install', (event) => {
    // インストール処理
});
  • 次にスクリプトの登録を行います。 成功すると記述したServiceWorkerがidleとなり、利用可能な状態になります。Activateの処理がインストール処理と分離しているのは、バージョンアップした場合の整合性を任意にコントロールできるようにするためです。
service-worker.js
self.addEventListener('activate', (event) => {
    // 登録処理
    // 以下は即座に登録を実行する
    event.waitUntil(self.clients.claim());
});
  • 今回は通信イベントを変更するため、重要となるがfetchイベントです。
service-worker.js
self.addEventListener('fetch', (event) => {
    // ページからのリクエストを処理
});

fetchイベントを用いて処理を挟むことで、サーバーへの通信に対して任意の処理を割り込ませることが可能となります。
この割り込みはスコープ内のすべての通信において有効であり、イベント処理内で返されたレスポンスは、あたかもサーバからのレスポンスであるかのようにページ側で処理されます。

動作としては、サーバー間のプロキシサーバーのようなものと見立てることが出来ます。

こうした特性を利用すれば、通信環境が無い環境でもサーバーが存在するかのように動作するアプリケーションはもちろんのこと、キャッシュやプッシュ通知など様々な分野で応用できます。

Expressのリクエスト・レスポンスに変換するには

ExpressのRoutingでは、request(NodeClientRequet)と、response(NodeHttpResponse)の2つを引数に取ります。
これらを用いて処理を行い、最終的にresponse.endが呼び出されて終了します。

app.get((request, response) => {
    response.render(...);
})

一方で、ServiceWorkerのfetchイベント内でリクエストからレスポンスを返すまでは、以下のように処理します。 (実際は以下のStreamをcloneして具体的に処理します)

self.addEventListener('fetch', (event) => {
    const request = event.response; // リクエスト
    event.respondWith(new Response('new response')); // resondWithでレスポンスを返す
});

これらの構造は、大変似ているのがお分かりになるでしょうか。
つまり、以下の変換ができればfetch内でのリクエスト・レスポンスをExpressのreq, resオブジェクトにでき、Expressとの接続が出来そうです。

[in] -> fetchで取得するリクエスト -> 変換! -> Expressのrequest -> [Express開始]

[Express終了] -> Expressのresponse -> 変換! -> fetch内のResponseオブジェクト -> [out]

変換処理

実際の変換コードは以下の通りです。
まず、fetchイベントで取得できるリクエストを、Expressのrequestオブジェクトに変換します。

// fetchイベントから、Expressに渡すリクエストをつくる
self.addEventListener('fetch', function (event) {
    const parsedUrl = url.parse(event.request.url)

    var req = {
        url: parsedUrl.href,
        method: event.request.method,
        body: body,
        headers: {
            'content-type': event.request.headers.get('content-type')
        },
        unpipe: function () {},
        connection: {
                remoteAddress: '::1'
        }
    }
    // 同様にExpressに渡すレスポンスをつくる
    // ...
}

次に、expressのリクエストが終了するres.endをServiceWorkerのレスポンスを返す処理に変更します。

espress-service/src/service.js
// Expressのresオブジェクトから、fetchイベント内で返すResponseオブジェクトへの変換
// ステータスコードと、リクエストヘッダを取得して、渡している
function endWithFinish (chunk, encoding) {
  const responseOptions = {
        status: res.statusCode || 200,
        headers: {
            'Content-Length': res.get('Content-Length'),
            'Content-Type': res.get('Content-Type')
        }
    }
    if (res.get('Location')) {
        responseOptions.headers.Location = res.get('Location')
    }
    if (res.get('X-Powered-By')) {
        responseOptions.headers['X-Powered-By'] = res.get('X-Powered-By')
    }
    // ServiceWorkerはPromiseベースのためresolveにてレスポンスを返す
    resolve(new Response(chunk, responseOptions))
}

// そしてres.endを豪快に置き換えます 男前です
res.end = endWithFinish

最後にExpressアプリケーションにrequest, responseオブジェクトを渡します。

espress-service/src/service.js
app(req, res)

上述した簡単な変換処理と、Expressをクライアント側でも動作可能にするための少しの処理を付け加えることで、ページ側で発生した通信処理をServiceWorker内で処理+擬似的に再現したExpressサーバーで処理して、そのレスポンスをfetch内のPromiseで返すことが出来ているようです。

どうなるか

これらを利用すれば、Expressアプリケーションは例えば下記のように書くことが出来るでしょう。

  • Expressサーバー
application.js
const express = require('express');
const indexPage = require('../pages/index');
const aboutPage = require('../pages/about');
const testPage = require('../pages/test');

const app = express();

// 静的ファイルはサーバーのアクセスに限定する
// express.staticが動作しないため
if (global.process) {
    app.use('/', express.static(`${__dirname}/../public`));
}

app.get('/', (req, res) => { res.send(indexPage); });
app.get('/about', (req, res) => { res.send(aboutPage); });
app.get('/test', (req, res) => { res.send(testPage); });

module.exports = app;
  • 実サーバー起動
run.js
const app = require('./application');
const http = require('http');

const server = http.createServer(app);

server.listen(process.env.PORT || 3000);
  • ServiceWorker
service-worker.js
const app = require('../server/application'); // サーバーと同じExpressのコードを読み込める
const expressService = require('express-service');

const cacheName = 'server-v1';
const cacheUrls = ['/'];

expressService(app, cacheUrls, cacheName);

どうでしょう、今回Expressで作成したapplication.jsは通常のサーバーでも動作するコードであり、ServiceWorker内で動作するコードでもあります。

ここまでの共通化を図ることは少ないかもしれませんが、こうした考え方を用いてServiceWorkerを設計していくことは、サーバーとクライアント間のコード共有を容易にします。実現することが出来れば、よりネイティブアプリとの距離が縮まることでしょう。

このアプリケーションは以下で動作を見ることが出来ます。Wifiを切っても動作しますね。
https://test-express-service-worker.herokuapp.com/

実際に利用してサーバー間との同期を行うには、PouchDB等を用いるなど少し工夫が必要です。


express-serviceはExpressの簡易的なラッパーであり、完全な共通化を目指すのは簡単なことではない気がしてはいますが、大変面白い試みであったため今回ご紹介させていただきました。

備考

同日のAdventCalendarでKoaJSをServiceWorkerで動かしている方が…!
http://qiita.com/y_fujieda/items/b1b572ed816a6092cde8

明日の記事は、 @takeswim さんです。

yahoo-japan-corp
Yahoo! JAPAN を運営しています。
https://www.yahoo.co.jp
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
ユーザーは見つかりませんでした