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;
}
サーバーアクションでも実装できると思いますが、とりあえず。
設定周りは公式ドキュメントを参照していただければと思います。