カテゴリー別アーカイブ: HTML5 API

Web Push 通知を実装してみた

Overview

これまで, ネイティブアプリでしか使えなかったプッシュ通知が, Web アプリケーションからも使えるように仕様策定が進められています. これによって, メールアドレスや外部サービスのアカウントを登録してもらわなくても, ユーザーに最新情報を伝えることが可能になります.

Web Push 通知 (Firefox)
Web Push 通知 (Firefox)

 

Web Push 通知は,

  • 画面上に通知を表示する機能
  • サーバーから通知を受信する機能

の 2 つに分解して考えることができます. そしてそれぞれの機能は,

  • Web Notifications API (画面上に通知を表示する機能)
  • Web Push API (サーバーから通知を受信する機能)

という 2 つの JavaScript の API によって実装することが可能です.

Web Notifications API

Web Notifications API でデスクトップ通知を表示するには, まずユーザーの許可を得る必要があります.

Notification.requestPermission().then((permission) => {
    switch (permission) {
      case 'granted':
        // 許可された場合
        break;
      case 'denied':
        // ブロックされた場合
        break;
      case 'default':
        // 無視された場合
        break;
      default:
        break;
    }
});

Notification.requestPermission メソッドを実行すると, ブラウザは以下のようなダイアログを表示してユーザーに許可を要求します.

Permit notifications
Permit notifications (Firefox)

許可状態はオリジンごとにブラウザに記憶されるので, 同一オリジンに存在するサイトであれば, 2 回目以降は許可は必要ありません.

1 度ブロックされるとダイアログが表示されないので, あらためて許可を得ることはできません. また, プログラム側からブロックを取り消すことも不可能です. ブロックの取り消しは, ユーザー操作によって, ブラウザの記憶を削除, または, 変更した場合のみ可能です.

Cancel block
Cancel block (Firefox)

いったん, 許可を得ることができれば通知の表示は簡単で, Notification インスタンスを生成するだけで表示できます.

const title        = '見出し';
const options  = {
    body: '本文',
    icon: 'アイコン画像のパス',
    data: {
      foo: '任意のデータ'
     }
};

const notification = new Notification(title, options);

第 1 引数のタイトルは必須です. 第 2 引数はオプションですが, よく指定するオプションを以下に示します.

Notification のオプション (使用頻度の高いオプション)
Property Type Description
body string 本文の文字列
icon string アイコン画像の URL, または, パス
tag string 通知を識別する文字列
data any 通知にもたせたい任意のデータ

tag は画面上に表示されるものではないので, 使い方がわかりにくいかもしれませんが, これは主に, すでに表示されている通知を置き換えるために使います. 通常, 1件以上の通知が表示されている状態でさらに通知を生成すると, 既存の通知とは別に新たな通知が表示されます. しかし, tag の値が既知の通知と一致する場合は, 新しい通知が別に表示されるのではなく, 当該通知の中身が新しいもので置き換えられます. tag オプションをうまく活用することで, 通知まみれになるのを防止することができます.

ユーザーがデスクトップ通知をクリックしたときに何らかの処理を実行するには, Notification インスタンスに対して, イベントリスナーを設定します.

notification.addEventListener('click', (event) => {
    console.dir(event);
}, false);

以上で, 「画面上に通知を表示する機能」は実装できました.

Web Push API

Service Worker を利用し, ブラウザでプッシュ通知を受けとるために用意された JavaScript の API です.

Web Push API を利用するには, プッシュ通知の送信元が正当なアプリケーションサーバーだと認証するために利用する公開鍵と李密鍵のペアを生成する必要があります.

Node.js の web-push モジュールを利用すると, Web Push API の利用に適したフォーマットで出力されるので今回はこれを使います.この方法で生成した鍵は + が – に, / が _ にそれぞれ置き換えられ, 末尾の = が削除された URL セーフな Base64 としてエンコードされています. 公開鍵は, クライアントサイド側で利用するので, 公開鍵を取得するための Web API をサーバーサイドに実装します. また, Base64 エンコードのままでは利用できないので, クライアントサイドでバイナリ形式に変換する必要があります.

$ npm init -y
$ npm install --save body-parser express web-push

server.js

'use strict';

const webpush = require('web-push');
const express = require('express');
const bodyParser = require('body-parser');
const app = express();

const contact   = 'mailto:rilakkuma.san.xjapan@gmail.com';
const vapidKeys = webpush.generateVAPIDKeys();

// アプリケーションの連絡先と, サーバーサイドの鍵ペアの情報を登録
webpush.setVapidDetails(contact, vapidKeys.publicKey, vapidKeys.privateKey);

// POST パラメータをパースする (のちほど実装)
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

app.use(express.static('public'));

const port = process.env.PORT || 3000;

app.listen(port, () => {
    console.log(`Listening on port ${port} ...`);
});

// 公開鍵をクライアントサイドに渡す
app.get('/api/webpush/get', (req, res) => {
    return res.json({
        publicKey: vapidKeys.publicKey
    });
});

public/app.js

'use strict';

// Base64 エンコードからバイナリ形式に変換する
function urlsafeBase64ToBinary(urlsafeBase64) {
    const base64 = urlsafeBase64.replace(/-/g, '+')
                                .replace(/_/g, '/');

    const raw    = window.atob(base64);
    const binary = new Uint8Array(raw.length);

    for (let i = 0, len = binary.length; i < len; i++) {
         binary[i] = raw.charCodeAt(i);
    }

    return binary;
}

// ...

const options = {
    method : 'GET',
    headers: new Headers({ 'Content-Type' : 'application/json' })
};

fetch('/api/webpush/get', options)
    .then((res) => res.json())
    .then((res) => {
        console.log(res.publicKey);  // Base64 エンコード
        console.log(urlsafeBase64ToBinary(res.publicKey));  // バイナリ形式
    })
    .catch((error) => {
        console.dir(error);
        console.log('Fetching public key failed.');
    });

Web Push API を利用して, プッシュ通知を受信するには, プッシュサービスに対してプッシュ通知を購読 (subscribe) する必要があります.

Service Worker の登録に成功したら, PushManager.subscribe メソッドを呼び出して, プッシュサービスに対してプッシュ通知の購読を要求します. このとき, プッシュサービスに公開鍵の情報を渡すために, 引数のオブジェクトの applicationServerKey プロパティにサーバーから取得したバイナリ形式の公開鍵を指定します.

購読要求の処理は非同期で実行されるので, メソッドの戻り値は Promise です. 購読要求が成功すると, コールバック関数の引数に PushSubscription オブジェクトが渡されます. この PushSubscription オブジェクトから, プッシュ通知の送信に必要な情報を取得できます.

そして, その情報をアプリケーションサーバーに送信 (POST) すれば購読は完了です.

server.js

'use strict';

const webpush = require('web-push');
const express = require('express');
const bodyParser = require('body-parser');
const app = express();

const contact   = 'mailto:rilakkuma.san.xjapan@gmail.com';
const vapidKeys = webpush.generateVAPIDKeys();

// アプリケーションの連絡先と, サーバーサイドの鍵ペアの情報を登録
webpush.setVapidDetails(contact, vapidKeys.publicKey, vapidKeys.privateKey);

// POST パラメータをパースする (のちほど実装)
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

app.use(express.static('public'));

const port = process.env.PORT || 3000;

app.listen(port, () => {
    console.log(`Listening on port ${port} ...`);
});

// 公開鍵をクライアントサイドに渡す
app.get('/api/webpush/get', (req, res) => {
    return res.json({
        publicKey: vapidKeys.publicKey
    });
});

// 購読のための POST 先
app.post('/api/webpush/subscribe', (req, res) => {
    // do something ...
});

public/app.js

'use strict';

// Base64 エンコードからバイナリ形式に変換する
function urlsafeBase64ToBinary(urlsafeBase64) {
    const base64 = urlsafeBase64.replace(/-/g, '+')
                                .replace(/_/g, '/');

    const raw    = window.atob(base64);
    const binary = new Uint8Array(raw.length);

    for (let i = 0, len = binary.length; i < len; i++) {
         binary[i] = raw.charCodeAt(i);
    }

    return binary;
}

// ArrayBuffer から Base64 エンコードに変換する
function arrayBufferToBase64(arrayBuffer) {
    return window.btoa(String.fromCharCode.apply(null, new Uint8Array(arrayBuffer))).replace(/\+/g, '-').replace(/\//g, '_');
}

if (navigator.serviceWorker) {
    // Service Worker 登録
    navigator.serviceWorker.register('./service-worker-web-push.js').then(() => {
        console.log('Registering Service Worker is successful.');
        return navigator.serviceWorker.ready;
    }).catch(() => {
        console.error('Registering Service Worker failed.');
    }).then((registration) => {
        const options = {
            method : 'GET',
            headers: new Headers({ 'Content-Type' : 'application/json' })
        };

        return fetch('/api/webpush/get', options)
                   .then((res) => res.json())
                   .then((res) => {
                       // プッシュサービスに対してプッシュ通知の購読を要求
                       return registration.pushManager.subscribe({
                           userVisibleOnly     : true,
                           applicationServerKey: urlsafeBase64ToBinary(res.publicKey)  // バイナリ形式の公開鍵を渡す
                       });
                   }).catch((error) => {
                       console.dir(error);
                       console.log('Fetching public key failed.');
                   });
    }).then((subscription) => {
        // POST の準備
        document.getElementById('hidden-endpoint').value = subscription.endpoint;
        document.getElementById('hidden-auth').value     = arrayBufferToBase64(subscription.getKey('auth'));    // PushSubscription#getKey の戻り値の型は ArrayBuffer なので, Base64 エンコード文字列に変換する
        document.getElementById('hidden-p256dh').value   = arrayBufferToBase64(subscription.getKey('p256dh'));  // PushSubscription#getKey の戻り値の型は ArrayBuffer なので, Base64 エンコード文字列に変換する
    }).catch((error) => {
        console.dir(error);
        console.error('Subscribing web push failed.');
    });
}

public/index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8" />
    <title>Web Push Example</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" />
    <link rel="stylesheet" href="./app.css" type="text/css" media="all" />
</head>
<body>
    <div class="WebPush">
        <form method="post" action="/api/webpush/subscribe">
            <dl>
                <dt><label for="text-title">Title</label></dt>
                <dd><input type="text" id="text-title" name="text-title" /></dd>
                <dt><label for="text-body">Body</label></dt>
                <dd><input type="text" id="text-body" name="text-body" /></dd>
                <dt><label for="url-icon">Icon</label></dt>
                <dd><input type="url" id="url-icon" name="url-icon" /></dd>
                <dt><label for="url-link">Link</label></dt>
                <dd><input type="url" id="url-link" name="url-link" /></dd>
            </dl>
            <ul>
                <li><input type="hidden" id="hidden-endpoint" name="hidden-endpoint" /></li>
                <li><input type="hidden" id="hidden-auth" name="hidden-auth" /></li>
                <li><input type="hidden" id="hidden-p256dh" name="hidden-p256dh" /></li>
            </ul>
            <button type="submit">Web Push</button>
        </form>
    </div>
    <script type="text/javascript" src="./app.js"></script>
</body>
</html>

以上で, プッシュ通知を受信するための下準備はできたので, あとはプッシュ通知を送受信する実装だけです.

購読時に, クライアントサイドから取得したエンドポイント URI に対して POST リクエストを送信します. プッシュサービスがリクエストを受信し, 署名の検証に成功すると該当するブラウザに対して通知が送信されます.

server.js

'use strict';

const webpush = require('web-push');
const express = require('express');
const bodyParser = require('body-parser');
const app = express();

const contact   = 'mailto:rilakkuma.san.xjapan@gmail.com';
const vapidKeys = webpush.generateVAPIDKeys();

// アプリケーションの連絡先と, サーバーサイドの鍵ペアの情報を登録
webpush.setVapidDetails(contact, vapidKeys.publicKey, vapidKeys.privateKey);

// POST パラメータをパースする
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: false }));

app.use(express.static('public'));

const port = process.env.PORT || 3000;

app.listen(port, () => {
    console.log(`Listening on port ${port} ...`);
});

// 公開鍵をクライアントサイドに渡す
app.get('/api/webpush/get', (req, res) => {
    return res.json({
        publicKey: vapidKeys.publicKey
    });
});

// 購読のための POST 先
app.post('/api/webpush/subscribe', (req, res) => {
    // プッシュ通知の送信先情報 (実際には, DB などから取得)
    const subscription = {
        endpoint: req.body['hidden-endpoint'],
        keys    : {
            auth  : req.body['hidden-auth'],
            p256dh: req.body['hidden-p256dh']
        }
    };

    // プッシュ通知で送信したい任意のデータ
    const payload = JSON.stringify({
        title: req.body['text-title'],
        body : req.body['text-body'],
        icon : req.body['url-icon'],
        url  : req.body['url-link']
    });

    // 購読時に, クライアントサイドから取得したエンドポイント URI に対して POST リクエストを送信
    webpush.sendNotification(subscription, payload).then((response) => {
        return res.json({
            statusCode: response.statusCode || -1,
            message   : response.message    || ''
        });
    }).catch((error) => {
        console.dir(error);
        return res.json({
            statusCode: error.statusCode || -1,
            message   : error.message    || '',
        });
    });
});

クライアントサイドでそれを受信したときに, 該当する Service Worker が起動していなければこのタイミングで起動し, push イベントが発生します. このイベントリスナーでデスクトップ通知の表示などの処理を実行します.

public/service-worker-web-push.js

'use strict';

self.addEventListener('install', (event) => {
    event.waitUntil(skipWaiting());
}, false);

self.addEventListener('activate', (event) => {
    event.waitUntil(self.clients.claim());
}, false);

self.addEventListener('push', (event) => {
    // デスクトップ通知の表示処理
}, false);

ネイティブアプリケーションのプッシュ通知とは異なり, Web Push によるプッシュ通知は, ブラウザが起動しているときでないと受信できません. ブラウザを起動していない間にアプリケーションサーバーからプッシュ通知が送信された場合は, その次にブラウザを起動したタイミングで受信されます.

最後に, 通知を受信したときの Service Worker の処理を実装します.  アプリケーションサーバーから送信された通知は, プッシュサービスを経由してブラウザに伝わり, Service Worker のイベントリスナーが呼び出されます.

アプリケーションサーバーで送信時に付与したペイロードは, pushイベントオブジェクトの data プロパティに格納されています. json メソッドで JSON 文字列をパースしたオブジェクトを取得できます (それ以外にも, text メソッドや arrayBuffer メソッド, blob メソッドなどもあります).

そして, デスクトップ通知の表示ですが, Service Worker 内では Notification クラスにアクセスできないので, self.registration (ServiceWorkerRegistration) の showNotification メソッドを利用します. このメソッドの引数は, Notification コンストラクタの引数とほぼ同じです.

また, デスクトップ通知をクリックしたときに実行するイベントリスナーの設定も異なっており, self (ServiceWorkerGlobalScope) に対して, notificationclick イベントのリスナーを設定します.

public/service-worker-web-push.js

'use strict';

self.addEventListener('install', (event) => {
    event.waitUntil(skipWaiting());
}, false);

self.addEventListener('activate', (event) => {
    event.waitUntil(self.clients.claim());
}, false);

self.addEventListener('push', (event) => {
    // デスクトップ通知の表示処理
    if (!event.data) {
        return;
    }

    const data  = event.data.json();  // ペイロードを JSON 形式でパース
    const title = data.title;
    const body  = data.body;
    const icon  = data.icon;
    const url   = data.url;

    event.waitUntil(
        self.registration.showNotification(title, { body, icon, data: { url } })
    );
}, false);

self.addEventListener('notificationclick', (event) => {
    const notification = event.notification;  // Notification インスタンスを取得
    const url          = notification.data.url;

    // 通知をクリックしたら, URL で指定されたページを新しいタブで開く
    event.waitUntil(self.clients.openWindow(url));
}, false);

WebAudio.tokyo #4

2017 年 3 月 28 日 (火) に WebAudio.tokyo #4 に参加してきました.

私は, 5 分の LT 枠で, 自作の Web Audio API ライブラリの概要を話してきました.

発表では, Chrome の Web MIDI API の実装をされた @toyoshim さんから, レコーディング機能の実装について質問されたり, 発表後には, Web Music Hackathonのボスである @g200kg さんとお話をできたりと非常に楽しい勉強会でした.

また, 自分が制作した 「Web Audio API の解説サイトを参考にさせてもらってます」
という方もいらっしゃってとてもうれしかったです.

Google の @agektmr さんに, X Sound をツイートしてもらえたのも感激でした.

最後に, 関連リンクを紹介しておきます.

XSound.js 1.13.0 リリース

XSound.js 1.13.0 をリリースしました.
今回のリリースで, Microsoft Edge にもおおよその機能は対応しました.

といっても, 大した対応はしていません.

Edge の ScriptProcessorNode のバグに対応しただけです.

ScriptProcessorNode インスタンスを生成するには, AudioContext インスタンスの createScriptProcessor を利用します. 例えば, 以下のような感じ

const context = new AudioContext();
const processor = context.createScriptProcessor(2048, 2, 2);

第1引数は, 必須で onaudioprocess イベントのバッファサイズを指定します.
第2引数, 第3引数はオプションで, それぞれ, 入力チャンネル数 / 出力チャンネル数を指定します.
省略した場合は, どちらもデフォルト値 2 が適用されると仕様では定義されています.

実は, Edge はこの仕様に反していて, 明示的に入力チャンネル数 / 出力チャンネル数を指定しないとダメなのです.

ここを省略していたので, 1.13.0 より前のバージョンにおいて Edge でエラーが出ていました.

これを, PR 送って修正したいのですが, Edge の Web Audio API のソースが公開されていないようなのです…
(誰かご存知でしたら教えていただけると助かります〜)

ちなみに, WebKit と Gecko の Web Audio API のソースは公開されているのですが…

Microsoft Edge でも X Sound を楽しんでください~

Web Music Hackathon #5

Web Music Hackathon #5に参加してきました.

「Twitterを楽器にする」というコンセプトで, MMLを書き込んで投稿すると音楽が再生されるChrome Extensionを作成しました.

過去にツイートされたMMLも演奏可能にし, ビジュアライゼーション機能や, WebSocketによるセッション機能もつけました.

GitHubリポジトリ : https://github.com/Korilakkuma/Music-Tweet

そして…なぜかわからないけど表彰されました〜

iframeを編集可能な領域にする

HTML

<iframe width="640" height="480"></iframe>

srcは指定する必要ありません. widthとheightを適当に指定しておきます.

JavaScript

var iframe = document.querySelector('iframe');

iframe.contentDocument.body.contentEditable = true;
iframe.contentDocument.designMode              = 'on';

以上で, iframe上のHTMLが編集可能になります.

あとは,

iframe.contentDocument.execCommand

を利用して, 文字を装飾したり, リンクを挿入したりしてください

JavaScript Fullscreen API

特定の要素をフルスクリーンにする場合

if (element.webkitRequestFullscreen) {
    element.webkitRequestFullscreen(Element.ALLOW_KEYBOARD_INPUT);
} else if (element.mozRequestFullScreen) {
    element.mozRequestFullScreen();
} else if (element.msRequestFullscreen) {
    element.msRequestFullscreen();  // IE 11 (IE 11未満は未対応)
} else if (element.requestFullscreen) {
    element.requestFullscreen();
} else {
    throw new Error('Cannot change to full screen.');
}

フルスクリーンを解除する場合

if (document.webkitCancelFullScreen) {
    document.webkitCancelFullScreen();
} else if (document.mozCancelFullScreen) {
    document.mozCancelFullScreen();
} else if (document.msExitFullscreen) {
    document.msExitFullscreen();
} else if (document.cancelFullScreen) {
    document.cancelFullScreen();
} else if (document.exitFullscreen) {
    document.exitFullscreen();
} else {
    throw new Error('Cannot exit from full screen.');
}

フルスクーン状態の要素の取得

var fullscreenElement = null;

if (document.webkitFullscreenElement) {
    fullscreenElement = document.webkitFullscreenElement;
} else if (document.mozFullScreenElement) {
    fullscreenElement = document.mozFullScreenElement;
} else if (document.msFullscreenElement) {
    fullscreenElement = document.msFullscreenElement;
} else if (document.fullscreenElement) {
    fullscreenElement = document.fullscreenElement;
}

フルスクリーンイベントの検知

document.addEventListener('webkitfullscreenchange', function(event) {}, false);
document.addEventListener('mozfullscreenchange', function(event) {}, false);
document.addEventListener('MSFullscreenChange', function(event) {}, false);
document.addEventListener('fullscreenchange', function(event) {}, false);

また, フルスクリーン状態におけるスタイルを設定することも可能です.

#element:-webkit-full-screen {
    /* ... */
}

#element:-moz-full-screen {
    /* ... */
}

#element:-ms-fullscreen {
    /* ... */
}

#element:fullscreen {
    /* ... */
}

SVGElementにclass属性を設定する

HTMLElementにclass属性を設定するときは, classNameプロパティやclassListプロパティにアクセスして設定します.

var div = document.createElement('div');
div.className = 'section-lv1';
div.classList.toggle('active');

SVGElement(やそれを継承したSVGの要素)に対して, その方法で設定しようとしても設定されません. では, どうすればいいかと言うと, setAttributeメソッドを利用します (逆に, HTMLElementでclass属性をsetAttributeメソッドで設定するのはタブーです).

var rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
rect.setAttribute('class', 'rect');

SVGで画像を動的に描画する

SVGで画像を動的に描画するには,

  • innerHTMLを利用する
  • createElementNS + appendChildを利用する

以上の2つの方法があります.

innerHTMLを利用する場合は, 特に解説は必要ないかと思いますので, createElementNS + appendChildを利用する方法を解説します.

var image = document.createElementNS('http://www.w3.org/2000/svg', 'image');

image.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', 'sample.png');

image.setAttributeNS(null, 'x',  x);
image.setAttributeNS(null, 'y',  y);
image.setAttributeNS(null, 'width',  64);  
image.setAttributeNS(null, 'height', 64);

document.querySelector('svg').appendChild(element);

ポイントは属性が名前空間付き属性であるということです. したがって, setAttributeではなく, setAttributeNSを利用する必要があります.

SVG 動的に要素を追加する

SVGにJavaScriptで動的に要素を追加する場合, HTMLElementのように, document.createElementメソッドで要素を作成し, DOMツリーに追加する方法ではうまくいきません

var text = document.createElement('text');

text.setAttribute('x', 10);
text.setAttribute('y', 10);
text.textContent = 'sample';

var svg = document.querySelector('svg');

svg.appendChild(text);

この問題は, 要素を作成するときに名前空間を指定できるdocument.createElementNSメソッドを利用することで解決できます.

var text = document.createElementNS('http://www.w3.org/2000/svg', 'text');

text.setAttribute('x', 10);
text.setAttribute('y', 10);
text.textContent = 'sample';

var svg = document.querySelector('svg');

svg.appendChild(text);

第1引数にSVGの名前空間を指定し, 第2引数にSVGのタグ名を指定するだけです.

参考 : document.createElementNS

ArtCanvas.js 2.0 のリリース

描画に必要な機能の実装がおおよそ完了したので, これをバージョン 2.0としてリリースします.

  • レイヤー
  • ペン, 図形, テキストの描画
  • Undo / Redo / レイヤーのクリア
  • カラー
  • ライン (線幅, ラインキャップ, ラインジョイン)
  • 変形
  • エキスポート

少々挙動がおかしい機能もありますが, 以上のような機能が実装されています (バグは今後のバージョンアップで修正していきます).