💬

Vue+Viteでweb-pushを無理やり動かす

に公開

概要

WebPush API の使い方を試行錯誤する過程の初心者ゆえの過ちで、Vue+Vite の dev 環境で web-push ライブラリを無理やり動かす小細工をしました。

簡単に言うと、Buffer のメソッドをフックして encoding に base64url を食わせないようにすれば最低限は動きました。どのみち CORS にブロックされるので何の役にも立ちませんが、実験用にどうぞ。

Vue(フロントエンド)で web-push は動かない

web-push はバックエンドで動かす想定の Node.js 用のライブラリなので、Vue などブラウザ側のアプリでは基本的に動かなくても不思議ではないです。でも nodepolyfills を入れたら動きそうな気配を感じたので少し頑張ってみました。

GCM API キーはコマンドラインで作って、あとは web-push の README に書かれている通りに setValidDetails() と pushSubscription() を呼んだところ、何やら例外で落ちます。

Uncaught (in promise) TypeError: Unknown encoding: base64url

Node.js は Buffer が base64url に対応してるけど nodepolyfills は対応してない? Buffer.isEncoding('base64url')も false を返します。

nodepolyfills のソースを見たところ、これの最新版(6.0.3)を取り込んでます。

さらにそのソースを探ったところ..

Buffer.isEncoding = function isEncoding(encoding) {
  switch (String(encoding).toLowerCase()) {
    case "hex":
    case "utf8":
    case "utf-8":
    case "ascii":
    case "latin1":
    case "binary":
    case "base64":
    case "ucs2":
    case "ucs-2":
    case "utf16le":
    case "utf-16le":
      return true;
    default:
      return false;
  }
};

おお、確かに base64url が無い。何故わざわざ外したのか、ブラウザ界の事情があるんでしょうか..? とりあえず原因は分かったので、Buffer に base64url を食わせないようにすれば良さそう。

Buffer のメソッドをフックする

web-push の処理をデバッガで追ったところ、base64url を食べて落ちてるのは Buffer.from() と Buffer.prototype.toString() でした。そこでこの二つを横取りして書き換えます。
Buffer には base64 を食わせ、base64<=>base64url の変換はbase64url ライブラリで処理したのが以下のコードです。

const orgBufferFrom = Buffer.from;
Buffer.from = function () {
  //console.debug("Buffer.from: args=%o", [...arguments])
  if (arguments && arguments[1] === "base64url") {
    arguments[1] = "base64";
    return orgBufferFrom.apply(base64url.toBase64(this), arguments);
  } else {
    return orgBufferFrom.apply(this, arguments);
  }
};
const orgBufferProtoTypeToString = Buffer.prototype.toString;
Buffer.prototype.toString = function () {
  //console.debug("Buffer.prototype.toString: args=%o", [...arguments])
  if (arguments && arguments[0] === "base64url") {
    return base64url(this);
  } else {
    return orgBufferProtoTypeToString.apply(this, arguments);
  }
};

Web Push API を試してみる

適当なフロントエンドアプリを作る

Vue+Vite を想定してますが、適切に読み換え/書き換えができるならフレームワークは何でも良いです。ただしサービスワーカーが動いている必要があるので、PWA のテンプレートで作るのが手っ取り早いと思います。

必要なライブラリ

以下のものを npm で入れましょう。

GCM API キーの準備

前準備として GCM API キーを環境変数に書いておく必要があります。
web-push の README に書いてある方法で鍵を生成します。

shell
$ npm install web-push -g
$ web-push generate-vapid-keys

出力された公開鍵と秘密鍵を.env に書いておきます。下記の例の鍵の値は web-push の README に書いてあるサンプルです。自分で生成した値に置き換えてください

.env
VITE_WEB_PUSH_VAPID_PUBLIC_KEY = "BGtkbcjrO12YMoDuq2sCQeHlu47uPx3SHTgFKZFYiBW8Qr0D9vgyZSZPdw6_4ZFEI9Snk1VEAj2qTYI1I1YxBXE","privateKey"
VITE_WEB_PUSH_VAPID_PRIVATE_KEY = "I0_d0vnesxbBSUmlDdOKibGo6vEXRO-Vu88QlSlm5j0"

サンプルコードをアプリに取り込む

以下、前述のフック処理を入れて Web Push API を叩くサンプルコード(メソッドのみ)です。準備したアプリに取り込んでください。

import base64url from "base64url";

export async function subscribeWebPush() {
  await navigator.serviceWorker.ready
    .then(async (reg) => {
      await reg.pushManager.getSubscription().then((subscription) => {
        if (subscription) {
          subscription.unsubscribe();
        }
      });
      await reg.pushManager
        .subscribe({
          userVisibleOnly: true,
          applicationServerKey: import.meta.env.VITE_WEB_PUSH_VAPID_PUBLIC_KEY,
        })
        .then((subscription) => {
          console.debug("webpush subscription=%o", subscription);
          localStorage.setItem("webpush-endpoint", subscription.endpoint);
        })
        .catch((err) => console.error(err));
    })
    .catch((err) => console.error(err));
}

export async function sendWebPush(params) {
  const endpoint = localStorage.getItem("webpush-endpoint");
  await navigator.serviceWorker.ready.then(async (reg) => {
    await reg.pushManager.getSubscription().then(async (subscription) => {
      if (subscription) {
        //console.debug("webpush: subscription=%o", JSON.stringify(subscription));
        const subject = "mailto:somebody@localhost";
        const keys = { public: import.meta.env.VITE_WEB_PUSH_VAPID_PUBLIC_KEY, private: import.meta.env.VITE_WEB_PUSH_VAPID_PRIVATE_KEY };
        const subsc = {
          endpoint: endpoint,
          keys: { p256dh: base64url(subscription.getKey("p256dh")), auth: base64url(subscription.getKey("auth")) },
        };
        const payload = "Hello, WebPush";
        const body = { subject: subject, keys: keys, subscription: subsc, payload: payload, useBufferOverride: params.useBufferOverride };
        return await sendWebPushViaBrowser(body, params);
      }
    });
  });
}

async function sendWebPushViaBrowser(body, params) {
  const webpush = await import("web-push");
  const { Buffer } = await import("buffer"); // このインポートは重要

  const orgBufferFrom = Buffer.from;
  Buffer.from = function () {
    //console.debug("Buffer.from: args=%o", [...arguments])
    if (arguments && arguments[1] === "base64url") {
      arguments[1] = "base64";
      return orgBufferFrom.apply(base64url.toBase64(this), arguments);
    } else {
      return orgBufferFrom.apply(this, arguments);
    }
  };
  const orgBufferProtoTypeToString = Buffer.prototype.toString;
  Buffer.prototype.toString = function () {
    //console.debug("Buffer.prototype.toString: args=%o", [...arguments])
    if (arguments && arguments[0] === "base64url") {
      return base64url(this);
    } else {
      return orgBufferProtoTypeToString.apply(this, arguments);
    }
  };

  webpush.setVapidDetails(body.subject, body.keys.public, body.keys.private);
  await webpush
    .sendNotification(body.subscription, body.payload)
    .then(() => {
      console.info("sendNotification complete");
    })
    .catch((err) => {
      console.error(err);
    });
}

サンプルコードを動かす

npm run dev等を実行し、アプリを dev 環境(Vite サーバ)で動かしてください。build すると minification の影響か、前述のフック処理が効かなくなるようです。

subscribeWebPush() を呼んでブラウザに登録してから、 sendWebPush() を呼びます。登録は普通にできるはずです。sendWebPush()は、アプリが endpoint に向けてリクエストを出すところまでは動き、その結果がどうなるかは環境次第かと。

でもリクエストはエラーになる

自分の環境(Windows11 の Chrome)では CORS制約に阻まれて以下のエラーが出ます。

--disable-web-security で Chrome を動かすと一見 CORS のエラーは出なくなりますが、403 Forbidden が返ってきます。

レスポンスの body に Google(サーバ)からのメッセージが何か書いてあり、なんだか冷たくあしらわれてる感あり。

net-export で通信をキャプチャして netlog-viewer で見てみると、ブラウザには怒られなくなっただけで、結局 CORS に引っかかってるんですかね..

t=11209 [st= 142]    QUIC_CHROMIUM_CLIENT_STREAM_READ_RESPONSE_HEADERS
                     --> fin = true
                     --> :status: 403
                         content-length: 1449
                         content-security-policy-report-only: script-src 'none'; form-action 'none'; frame-src 'none'; report-uri https://csp.withgoogle.com/csp/goa-520bfc14_2
                         content-type: text/html; charset=utf-8
                         cross-origin-opener-policy: same-origin
                         vary: Sec-Fetch-SiteSec-Fetch-ModeSec-Fetch-Dest
                         x-content-type-options: nosniff
                         x-frame-options: SAMEORIGIN
                         x-goa-security-debug: cross-origin request blocked by Goa's sandboxed serving (see go/goa-http-serving#sandboxing for more information)
                         x-xss-protection: 0
                         date: Sat, 26 Apr 2025 02:53:55 GMT
                         alt-svc: h3=":443"; ma=2592000,h3-29=":443"; ma=2592000

どうすればリクエストが通るか

フロントエンドアプリ(Vue)とは別にバックエンドの http サーバ(Node.js)を動かして、バックエンドで web-push を使ってリクエストを proxy し、Vue(Chrome) → web-push(Node) → endpoint(fcm.googleapis.com) の流れでリクエストを送ります。
さらにバックエンド(Node)からフロントエンド(Vue/Chrome)へのレスポンスに CORS 関連のヘッダを付けてあげれば最後まで通り、フロント側のサービスワーカに push イベントが来ました。
CORS 関連のヘッダは、インターネット側から見えない個人のサーバで一時的に実験するだけなら以下の設定で OK かと。

const headers = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Headers": "*",
  "Access-Control-Allow-Methods": "*",
};

ちなみにバックエンドに https でアクセスしたときのブラウザのセキュリティ警告を引っ込めるため、バックエンドでは process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; を定義してあります。関係ないと思いますが、この設定なしでは試してないので念のため注記。

役に立たない情報でスミマセン

この小細工も含めて Web Push API の試行錯誤の過程で CORS について知りました。最初から知ってたら試さなかったかもしれないです。早い段階で気付きはしましたが、403 が返ってくる原因が単にフック処理のバグなのかどうかを確認したくて強行。全く同じフック処理をバックエンド側で入れた場合はリクエストが通ったので、小細工自体は一応上手くいってるようで。

結果的にいろいろ初めて試してみる機会となり、まあ良い勉強になりました。

GitHubで編集を提案

Discussion