Deno で Web Push を試す

少し前に、Deno で Web Push が動くかと試みたら、動作せず。Issueを出していました。

npm:web-push not working

しばらく経ち、最近修正がされて、動作確認が取れました。

改めて、動作確認がてら導入方法を記しておきます。

参考

導入前準備

web-push を使うにあたり、鍵情報の発行が必要です。

ローカルで以下のコマンドを実行して、鍵情報を発行します。

1
2
$ deno -E npm:web-push generate-vapid-keys --json
{"publicKey":"hogehogehogehogehogehogehogehogehogehogehogehogehogehogehogehogehogehoge","privateKey":"hogehogehogehogehogehogehogehogehogehogehogehoge"}

この内容は、.env ファイルに記述します。

実装

実装の中で、用意すべき事項がいくつかあるので、分割して紹介します。

サーバー側実装

まず、エントリポイントにもなる main.ts です。
こちらがサーバー本体にもなります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// main.ts
import "@std/dotenv/load";
import { type Route, route, serveDir } from "@std/http";
import { getClientMainTs } from "./getClientMainTs.ts";
import { publicVapidKey, sendNotification } from "./webPushServer.ts";
import { registrySubscribe, subscribeKeys } from "./kvStorage.ts";

const routes: Route[] = [
{
method: "POST",
pattern: new URLPattern({ pathname: "/subscribe" }),
handler: async (request: Request) => {
const subscription = await request.json();

if (!(await registrySubscribe(subscription))) {
console.error("Failed to subscribe", subscription);
return new Response("Failed to subscribe", { status: 500 });
}

return new Response("Subscribed");
},
},
{
method: "GET",
pattern: new URLPattern({ pathname: "/main.js" }),
handler: () =>
new Response(getClientMainTs(publicVapidKey), {
headers: {
"Content-Type": "text/javascript",
},
}),
},
{
method: "GET",
pattern: new URLPattern({ pathname: "/*" }),
handler(req) {
return serveDir(req, {
fsRoot: "public",
});
},
},
];

function defaultHandler(_req: Request) {
return new Response("Not found", { status: 404 });
}

Deno.cron("push message", "* * * * *", async () => {
console.log("Pushing message");
const keys = await subscribeKeys();

for await (const key of keys) {
await sendNotification(key, "Hello from Deno!");
};
});

const handler = route(routes, defaultHandler);

export default {
fetch(req) {
return handler(req);
},
} satisfies Deno.ServeDefaultExport;

キー情報の保存を行う Deno.KV にかかわる処理、Web-push にかかわる処理、クライアント側JSの生成を行う処理は、それぞれ別ファイルに分割しています。

クライアント側JS生成

クライアント側JS生成などと銘打ちつつも、公開鍵の埋め込みだけが実態です。
埋め込みされたこのJSがクライアントで動作します。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
export function getClientMainTs(publicKey: string): string {
return `
function urlBase64ToUint8Array(base64String) {
const padding = '='.repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, '+')
.replace(/_/g, '/');

const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);

for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}

(async function () {
if ('serviceWorker' in navigator) {
const register = await navigator.serviceWorker.register('service-worker.js');
const subscription = await register.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array("${publicKey}"),
});

await fetch("/subscribe", {
method: "POST",
body: JSON.stringify({subscription, id: "1"}),
headers: {
"Content-Type": "application/json",
},
});
}
})();
`;
}

Web-push にかかわる処理

Web-push にかかわる処理は、以下のようになります。
webpushの送信と、カギ情報の公開を担います。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// webPushServer.ts
import webpush from "web-push";
import { Subscription } from "./type.ts";

export const publicVapidKey = Deno.env.get("PUBLIC_VAPID_KEY")!;

const privateVapidKey = Deno.env.get("PRIVATE_VAPID_KEY")!;

export async function sendNotification(
subscription: Subscription,
payload: string,
) {
webpush.setVapidDetails(
`mailto:${Deno.env.get("DOMAIN")!}`,
publicVapidKey,
privateVapidKey,
);

await webpush.sendNotification(subscription, payload, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Encoding": "aesgcm",
},
contentEncoding: "aesgcm",
});
}

Deno.KV にかかわる処理

クライアントから送り込まれたsubscription情報を保存/参照する処理です。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// kvStorage.ts
import { Subscription } from "./type.ts";

const kv = await Deno.openKv();

export async function registrySubscribe(
subscription: Subscription,
): Promise<boolean> {
return (await kv.set(["subscription", subscription.keys.auth], subscription)).ok;
}

export async function subscribeKeys() {
const entries = await kv.list<Subscription>({ prefix: ["subscription"] });
const keys = [];
for await (const entry of entries) {
keys.push(entry.value);
}

return keys;
}

フロント側実装

フロント側実装は、以下のようになります。
なんということはないです。

public/index.html
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<!DOCTYPE html>
<html lang="ja-jp">

<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/css.css" />
<link rel="manifest" href="/manifest.json" />
<title>PWA Test</title>
</head>

<body>
PWA Test
</body>
<script type="module" src="/main.js">
</script>

</html>

サービスワーカー実装

サービスワーカーは次のようにします。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
const cacheFiles = ["index.html"];
const cacheName = "v1";

self.addEventListener("install", (_event) => {
caches.open(cacheName).then((cache) => cache.addAll(cacheFiles));
});

self.addEventListener("activate", (_event) => {
});

self.addEventListener("push", (e) => {
const title = e.data.text();
self.registration.showNotification(title, {
body: "web push test",
icon: "/icon-192x192.png",
});
});

動作確認

以下コマンドで、起動します。

1
$ deno serve -ERN --unstable --watch .\main.ts

ページの表示は、以下のようになります。

ここからいささか条件がはっきりしない部分があります。
何度かアクセスしていると、通知を許可するかどうかのダイアログが出てきます。
こちらは、キャプチャできませんでした。

明示的には、ChromeならブラウザのURLのところをクリックして、通知を許可するかどうかを選択できます。

許可すると、次のように通知が1分毎表示されます。(実運用で1分毎ということはないでしょうが)

メッセージを送り続けていると、通知が上がらなくなります。(それこそ1分毎送ったのがよくない可能性はあります。)

また、ブラウザで通知を停止すると、サーバー側で次のようなエラーが出ます。

1
Exception in cron handler push message WebPushError: Received unexpected response code

エラーが出たら、しばらくは通知を送らないなどの対応が必要だと考えられます。


ということで、Deno で Web Push の動作を確認しました。
しばらく対応されないことを覚悟していたところもありましたが、無事動作するようになりました。

Web push で近々何か作りたいところです。

では。