Node.js
Express
ServiceWorker
Node.jsDay 24

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

More than 1 year has 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 さんです。