kk-web

Next.jsにPWAとWeb Pushを実装する方法

2024-12-13

Next.js で Web Push を実装する場合、公式ドキュメント に沿ったら問題ないと思うのですが。

もうちょっとシンプルに実装したいなーと思い色々と調べてみたところ、Serwist というサービスが良さそうだったので使用してみました。

以下実装感です、自分から自分に通知を行う挙動となっています。


PushButton.tsx

"use client";
import { useEffect, useState } from "react";

type NotificationResponse = {
  error?: string;
  success?: boolean;
};

type PushMessage = {
  message: string;
  subscription: PushSubscription;
};

export default function PushButton(): React.JSX.Element {
  const [subscription, setSubscription] = useState<PushSubscription | null>(
    null,
  );
  const [isLoading, setIsLoading] = useState(false);
  const subscribe = async (): Promise<void> => {
    setIsLoading(true);

    try {
      const permission = await Notification.requestPermission();

      if (permission !== "granted") {
        throw new Error("通知の許可が得られませんでした");
      }

      const registration = await navigator.serviceWorker.ready;
      const pushSubscription = await registration.pushManager.subscribe({
        applicationServerKey: process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY,
        userVisibleOnly: true,
      });
      const response = await fetch("/api/subscribe", {
        body: JSON.stringify(pushSubscription),
        headers: { "Content-Type": "application/json" },
        method: "POST",
      });

      if (!response.ok) {
        throw new Error("サーバーへの登録に失敗しました");
      }

      setSubscription(pushSubscription);
    } catch (error) {
      const errorMessage =
        error instanceof Error ? error.message : "不明なエラーが発生しました";

      alert(errorMessage);
      console.error("Subscription error:", error);
    } finally {
      setIsLoading(false);
    }
  };
  const sendNotification = async (): Promise<void> => {
    if (!subscription) return;
    setIsLoading(true);

    try {
      const pushMessage: PushMessage = {
        message: "テスト通知です!",
        subscription,
      };
      const response = await fetch("/api/push", {
        body: JSON.stringify(pushMessage),
        headers: { "Content-Type": "application/json" },
        method: "POST",
      });
      const data = (await response.json()) as NotificationResponse;

      if (!response.ok) {
        throw new Error(data.error || "通知の送信に失敗しました");
      }
    } catch (error) {
      const errorMessage =
        error instanceof Error ? error.message : "通知の送信に失敗しました";

      alert(errorMessage);
      console.error("Notification error:", error);
    } finally {
      setIsLoading(false);
    }
  };

  useEffect(() => {
    const checkSubscription = async (): Promise<void> => {
      try {
        const registration = await navigator.serviceWorker.ready;
        const existingSubscription =
          await registration.pushManager.getSubscription();

        if (existingSubscription) {
          setSubscription(existingSubscription);
        }
      } catch (error) {
        console.error("Subscription check failed:", error);
      }
    };

    void checkSubscription();
  }, []);

  return (
    <div>
      <button disabled={!!subscription || isLoading} onClick={subscribe}>
        {isLoading ? "処理中..." : subscription ? "登録済み" : "通知を登録"}
      </button>
      <button disabled={!subscription || isLoading} onClick={sendNotification}>
        {isLoading ? "送信中..." : "通知を送信"}
      </button>
    </div>
  );
}

/app/api/push/route.ts

import { NextResponse } from "next/server";
import getWebPushInstance from "@/lib/getWebPushInstance";

export async function POST(request: Request): Promise<NextResponse> {
  try {
    const webpush = getWebPushInstance();
    const { message, subscription } = await request.json();

    if (
      !process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ||
      !process.env.VAPID_PRIVATE_KEY
    ) {
      console.error("VAPID keys are not set");

      return NextResponse.json(
        { error: "VAPID keys are not configured" },
        { status: 500 },
      );
    }

    console.log("Subscription:", subscription);
    console.log("Message:", message);

    const result = await webpush.sendNotification(
      subscription,
      JSON.stringify({ body: message, title: "プッシュ通知" }),
    );

    console.log("Push sent:", result);

    return NextResponse.json({ success: true });
  } catch (err) {
    console.error("Push notification error:", err);

    return NextResponse.json(
      { error: err instanceof Error ? err.message : "Unknown error" },
      { status: 500 },
    );
  }
}

/app/api/subscribe/route.ts

import { NextResponse } from "next/server";
import getWebPushInstance from "@/lib/getWebPushInstance";

export async function POST(request: Request): Promise<NextResponse> {
  getWebPushInstance();

  const subscription = await request.json();

  return NextResponse.json({ message: "Subscribed" });
}

/app/layout.tsx

// eslint-disable-next-line filenames/match-exported
import type { Metadata, Viewport } from "next";

const APP_NAME = "PWA App";
const APP_DEFAULT_TITLE = "My Awesome PWA App";
const APP_TITLE_TEMPLATE = "%s - PWA App";
const APP_DESCRIPTION = "Best PWA app in the world!";

export const metadata: Metadata = {
  appleWebApp: {
    capable: true,
    statusBarStyle: "default",
    title: APP_DEFAULT_TITLE,
    // startUpImage: [],
  },
  applicationName: APP_NAME,
  description: APP_DESCRIPTION,
  formatDetection: {
    telephone: false,
  },
  openGraph: {
    description: APP_DESCRIPTION,
    siteName: APP_NAME,
    title: {
      default: APP_DEFAULT_TITLE,
      template: APP_TITLE_TEMPLATE,
    },
    type: "website",
  },
  title: {
    default: APP_DEFAULT_TITLE,
    template: APP_TITLE_TEMPLATE,
  },
  twitter: {
    card: "summary",
    description: APP_DESCRIPTION,
    title: {
      default: APP_DEFAULT_TITLE,
      template: APP_TITLE_TEMPLATE,
    },
  },
};

export const viewport: Viewport = {
  themeColor: "#FFFFFF",
};

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>): React.JSX.Element {
  return (
    <html lang="ja">
      <body>{children}</body>
    </html>
  );
}

/app/manifest.ts

import type { MetadataRoute } from "next";

export default function manifest(): MetadataRoute.Manifest {
  return {
    background_color: "#FFFFFF",
    display: "standalone",
    icons: [
      {
        purpose: "maskable",
        sizes: "192x192",
        src: "/android-chrome-192x192.png",
        type: "image/png",
      },
      {
        sizes: "512x512",
        src: "/icon-512x512.png",
        type: "image/png",
      },
    ],
    name: "My Awesome PWA app",
    orientation: "portrait",
    short_name: "PWA App",
    start_url: "/",
    theme_color: "#FFFFFF",
  };
}

/app/sw.ts

import { defaultCache } from "@serwist/next/worker";
import { type PrecacheEntry, Serwist, type SerwistGlobalConfig } from "serwist";

declare global {
  interface WorkerGlobalScope extends SerwistGlobalConfig {
    __SW_MANIFEST: (PrecacheEntry | string)[] | undefined;
  }
}

declare const self: ServiceWorkerGlobalScope;

type PushNotificationData = {
  body: string;
  title: string;
};

self.addEventListener("push", (event: PushEvent) => {
  const data = (event.data?.json() ?? {
    body: "",
    title: "",
  }) as PushNotificationData;
  const notificationPromise = self.registration.showNotification(data.title, {
    body: data.body,
  });

  event.waitUntil(notificationPromise);
});

const serwist = new Serwist({
  clientsClaim: true,
  navigationPreload: true,
  precacheEntries: self.__SW_MANIFEST,
  runtimeCaching: defaultCache,
  skipWaiting: true,
});

serwist.addEventListeners();

/lib/getWebPushInstance.ts

// lib/webpush.ts
import webpush from "web-push";

let isInitialized = false;

export default function getWebPushInstance(): typeof webpush {
  if (!isInitialized) {
    webpush.setVapidDetails(
      "mailto:[email protected]",
      process.env.NEXT_PUBLIC_VAPID_PUBLIC_KEY!,
      process.env.VAPID_PRIVATE_KEY!,
    );
    isInitialized = true;
  }

  return webpush;
}

サーバーアクションでも実装できると思いますが、とりあえず。

設定周りは公式ドキュメントを参照していただければと思います。

© 2018 kk-web