From 6007f4caeb00736296b12c42014afb074b66fc40 Mon Sep 17 00:00:00 2001 From: Hornwitser Date: Fri, 28 Feb 2025 15:32:03 +0100 Subject: [PATCH] Implement proof of concept push notifications --- .dockerignore | 2 + .gitignore | 3 + Dockerfile | 1 + app/api/events/actions.ts | 59 +++++++++++++++++ app/api/subscribe/route.ts | 29 +++++++++ app/api/unsubscribe/route.ts | 26 ++++++++ app/globals.css | 4 ++ app/schedule/page.tsx | 5 ++ generate-vapid-keys.mjs | 10 +++ package.json | 4 +- pnpm-lock.yaml | 120 +++++++++++++++++++++++++++++++++++ public/sw.js | 25 ++++++++ ui/push-notification.tsx | 120 +++++++++++++++++++++++++++++++++++ 13 files changed, 407 insertions(+), 1 deletion(-) create mode 100644 app/api/subscribe/route.ts create mode 100644 app/api/unsubscribe/route.ts create mode 100644 generate-vapid-keys.mjs create mode 100644 public/sw.js create mode 100644 ui/push-notification.tsx diff --git a/.dockerignore b/.dockerignore index 932f6d7..4b720b6 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,3 +1,5 @@ node_modules .next/ next-env.d.ts +#env* +push-subscriptions.json diff --git a/.gitignore b/.gitignore index 5ef6a52..021a1c7 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# misc +push-subscriptions.json diff --git a/Dockerfile b/Dockerfile index fd69d6f..563b5e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -34,6 +34,7 @@ COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --chown=nextjs:nodejs schedule.json . +RUN echo '[]' > push-subscriptions.json && chown nextjs:nodejs push-subscriptions.json USER nextjs diff --git a/app/api/events/actions.ts b/app/api/events/actions.ts index cbf0d34..520172c 100644 --- a/app/api/events/actions.ts +++ b/app/api/events/actions.ts @@ -1,8 +1,63 @@ "use server"; +import webPush from "web-push"; import { Schedule } from "@/app/schedule/types"; import { readFile, writeFile } from "fs/promises"; import { broadcastUpdate } from "./streams"; +webPush.setVapidDetails( + "mailto:webmaster@hornwitser.no", + process.env.VAPID_PUBLIC_KEY!, + process.env.VAPID_PRIVATE_KEY!, +) + +async function sendPush(title: string, body: string) { + const payload = JSON.stringify({ title, body }); + let subscriptions: PushSubscriptionJSON[]; + try { + subscriptions = JSON.parse(await readFile("push-subscriptions.json", "utf-8")); + } catch (err: any) { + if (err.code !== "ENOENT") { + console.log(`Dropping "${payload}", no push subscribers`); + return; + } + subscriptions = []; + } + console.log(`Sending "${payload}" to ${subscriptions.length} subscribers`); + const removeIndexes = []; + for (let index = 0; index < subscriptions.length; index += 1) { + const subscription = subscriptions[index]; + try { + await webPush.sendNotification( + subscription as webPush.PushSubscription, + payload, + { + TTL: 3600, + urgency: "high", + } + ) + } catch (err: any) { + console.error("Received error sending push notice:", err.message, err?.statusCode) + console.error(err); + if (err?.statusCode >= 400 && err?.statusCode < 500) { + removeIndexes.push(index) + } + } + } + if (removeIndexes.length) { + console.log(`Removing indexes ${removeIndexes} from subscriptions`) + removeIndexes.reverse(); + for (const index of removeIndexes) { + subscriptions.splice(index, 1); + } + await writeFile( + "push-subscriptions.json", + JSON.stringify(subscriptions, undefined, "\t"), + "utf-8" + ); + } + console.log("Push notices sent"); +} + export async function createEvent(formData: FormData) { const schedule: Schedule = JSON.parse(await readFile("schedule.json", "utf-8")); const id = formData.get("id") as string; @@ -26,6 +81,7 @@ export async function createEvent(formData: FormData) { }); broadcastUpdate(schedule); await writeFile("schedule.json", JSON.stringify(schedule, null, "\t"), "utf-8"); + await sendPush("New event", `${name} will start at ${start}`); } export async function modifyEvent(formData: FormData) { @@ -40,6 +96,7 @@ export async function modifyEvent(formData: FormData) { if (index === -1) { throw Error("No such event"); } + const timeChanged = schedule.events[index].slots[0].start !== start + "Z"; schedule.events[index] = { name, id, @@ -55,6 +112,8 @@ export async function modifyEvent(formData: FormData) { }; broadcastUpdate(schedule); await writeFile("schedule.json", JSON.stringify(schedule, null, "\t"), "utf-8"); + if (timeChanged) + await sendPush(`New time for ${name}`, `${name} will now start at ${start}`); } export async function deleteEvent(formData: FormData) { diff --git a/app/api/subscribe/route.ts b/app/api/subscribe/route.ts new file mode 100644 index 0000000..fde326f --- /dev/null +++ b/app/api/subscribe/route.ts @@ -0,0 +1,29 @@ +import { readFile, writeFile } from "fs/promises"; + +export async function POST(request: Request) { + const body: { subscription: PushSubscriptionJSON } = await request.json(); + let subscriptions: PushSubscriptionJSON[]; + try { + subscriptions = JSON.parse(await readFile("push-subscriptions.json", "utf-8")); + } catch (err: any) { + if (err.code !== "ENOENT") { + throw err; + } + subscriptions = []; + } + const existingIndex = subscriptions.findIndex(sub => sub.endpoint === body.subscription.endpoint); + if (existingIndex !== -1) { + subscriptions[existingIndex] = body.subscription; + } else { + subscriptions.push(body.subscription); + } + await writeFile( + "push-subscriptions.json", + JSON.stringify(subscriptions, undefined, "\t"), + "utf-8" + ); + if (existingIndex !== -1) { + return new Response(JSON.stringify({ message: "Existing subscription refreshed."})); + } + return new Response(JSON.stringify({ message: "New subscription registered."})); +} diff --git a/app/api/unsubscribe/route.ts b/app/api/unsubscribe/route.ts new file mode 100644 index 0000000..830781e --- /dev/null +++ b/app/api/unsubscribe/route.ts @@ -0,0 +1,26 @@ +import { readFile, writeFile } from "fs/promises"; + +export async function POST(request: Request) { + const body: { subscription: PushSubscriptionJSON } = await request.json(); + let subscriptions: PushSubscriptionJSON[]; + try { + subscriptions = JSON.parse(await readFile("push-subscriptions.json", "utf-8")); + } catch (err: any) { + if (err.code !== "ENOENT") { + throw err; + } + subscriptions = []; + } + const existingIndex = subscriptions.findIndex(sub => sub.endpoint === body.subscription.endpoint); + if (existingIndex !== -1) { + subscriptions.splice(existingIndex, 1); + } else { + return new Response(JSON.stringify({ message: "No subscription registered."})); + } + await writeFile( + "push-subscriptions.json", + JSON.stringify(subscriptions, undefined, "\t"), + "utf-8" + ); + return new Response(JSON.stringify({ message: "Existing subscription removed."})); +} diff --git a/app/globals.css b/app/globals.css index b6c5b22..b392990 100644 --- a/app/globals.css +++ b/app/globals.css @@ -61,6 +61,10 @@ label>* { margin-inline-start: 0.5rem; } +p + p { + margin-block-start: 0.5rem; +} + label + label { margin-block-start: 0.5rem; } diff --git a/app/schedule/page.tsx b/app/schedule/page.tsx index e0f7770..c3eab5a 100644 --- a/app/schedule/page.tsx +++ b/app/schedule/page.tsx @@ -5,6 +5,7 @@ import { ScheduleProvider } from "./context" import { Events } from "@/ui/events"; import { Locations } from "@/ui/locations"; import { EventsEdit } from "@/ui/events-edit"; +import { PushNotification } from "@/ui/push-notification"; export const dynamic = "force-dynamic"; @@ -17,6 +18,10 @@ export default async function page() {

Study carefully, we only hold these events once a year.

+

+ Get notified about updates +

+

Schedule

diff --git a/generate-vapid-keys.mjs b/generate-vapid-keys.mjs new file mode 100644 index 0000000..2fd9b9f --- /dev/null +++ b/generate-vapid-keys.mjs @@ -0,0 +1,10 @@ +import webPush from "web-push"; +import fs from "node:fs/promises"; + +const vapidKeys = webPush.generateVAPIDKeys(); + +const envData = `\ +VAPID_PUBLIC_KEY=${vapidKeys.publicKey} +VAPID_PRIVATE_KEY=${vapidKeys.privateKey} +`; +await fs.writeFile(".env", envData, "utf-8"); diff --git a/package.json b/package.json index 0926378..bd0ccc2 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,14 @@ "dependencies": { "next": "15.1.7", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "web-push": "^3.6.7" }, "devDependencies": { "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", + "@types/web-push": "^3.6.4", "csstype": "^3.1.3", "typescript": "^5" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a32729a..28caba7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ dependencies: react-dom: specifier: ^19.0.0 version: 19.0.0(react@19.0.0) + web-push: + specifier: ^3.6.7 + version: 3.6.7 devDependencies: '@types/node': @@ -25,6 +28,9 @@ devDependencies: '@types/react-dom': specifier: ^19 version: 19.0.0 + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 csstype: specifier: ^3.1.3 version: 3.1.3 @@ -324,6 +330,34 @@ packages: csstype: 3.1.3 dev: true + /@types/web-push@3.6.4: + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + dependencies: + '@types/node': 20.0.0 + dev: true + + /agent-base@7.1.3: + resolution: {integrity: sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==} + engines: {node: '>= 14'} + dev: false + + /asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + dependencies: + bn.js: 4.12.1 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + dev: false + + /bn.js@4.12.1: + resolution: {integrity: sha512-k8TVBiPkPJT9uHLdOKfFpqcfprwBFOAAXXozRubr7R7PfIuKvQlzcI4M0pALeqXN09vdaMbUdUj+pass+uULAg==} + dev: false + + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /busboy@1.6.0: resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==} engines: {node: '>=10.16.0'} @@ -377,6 +411,18 @@ packages: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} dev: true + /debug@4.4.0: + resolution: {integrity: sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: false + /detect-libc@2.0.3: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} @@ -384,12 +430,64 @@ packages: dev: false optional: true + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + + /http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + dev: false + + /https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.3 + debug: 4.4.0 + transitivePeerDependencies: + - supports-color + dev: false + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: false + /is-arrayish@0.3.2: resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} requiresBuild: true dev: false optional: true + /jwa@2.0.0: + resolution: {integrity: sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@4.0.0: + resolution: {integrity: sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==} + dependencies: + jwa: 2.0.0 + safe-buffer: 5.2.1 + dev: false + + /minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + dev: false + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: false + + /ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + dev: false + /nanoid@3.3.8: resolution: {integrity: sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -468,6 +566,14 @@ packages: engines: {node: '>=0.10.0'} dev: false + /safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: false + + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: false + /scheduler@0.25.0: resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} dev: false @@ -555,3 +661,17 @@ packages: engines: {node: '>=12.20'} hasBin: true dev: true + + /web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.0 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + dev: false diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..205c28f --- /dev/null +++ b/public/sw.js @@ -0,0 +1,25 @@ +self.addEventListener("push", function (event) { + console.log(event); + if (!event.data) + return; + + const payload = event.data.json(); + const { body, icon, image, badge, url, title } = payload; + const notificationTitle = title ?? "No title"; + const notificationOptions = { + body, + icon, + image, + data: { + url, + }, + badge, + }; + + event.waitUntil( + self.registration.showNotification(notificationTitle, notificationOptions) + .then(() => { + console.log("Web push delivered"); + }) + ); +}); diff --git a/ui/push-notification.tsx b/ui/push-notification.tsx new file mode 100644 index 0000000..cfbe648 --- /dev/null +++ b/ui/push-notification.tsx @@ -0,0 +1,120 @@ +"use client"; +import { useEffect, useState } from "react"; + +function notificationUnsupported() { + return ( + !("serviceWorker" in navigator) + || !("PushManager" in window) + || !("showNotification" in ServiceWorkerRegistration.prototype) + ) +} + +async function registerAndSubscribe( + vapidPublicKey: string, + onSubscribe: (subs: PushSubscription | null ) => void, +) { + try { + await navigator.serviceWorker.register("/sw.js"); + await subscribe(vapidPublicKey, onSubscribe); + } catch (err) { + console.error("Failed to register service worker:", err); + } +} + +async function subscribe( + vapidPublicKey: string, + onSubscribe: (subs: PushSubscription | null) => void +) { + try { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidPublicKey, + }); + console.log("Got subscription object", subscription.toJSON()); + await submitSubscription(subscription); + onSubscribe(subscription); + } catch (err) { + console.error("Failed to subscribe:" , err); + } +} + +async function unsubscribe( + subscription: PushSubscription, + onUnsubscribed: () => void, +) { + const body = JSON.stringify({ subscription }); + await subscription.unsubscribe(); + const res = await fetch("/api/unsubscribe", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body, + }); + const result = await res.json(); + console.log("/api/unsubscribe returned", result); + onUnsubscribed(); +} + +async function submitSubscription(subscription: PushSubscription) { + const res = await fetch("/api/subscribe", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ subscription }), + }); + const result = await res.json(); + console.log("/api/subscribe returned", result); +} + +async function getSubscription( + onSubscribe: (subs: PushSubscription | null) => void, +) { + const registration = await navigator.serviceWorker.ready; + const subscription = await registration.pushManager.getSubscription(); + if (subscription) { + onSubscribe(subscription); + } +} + +export function PushNotification(props: { vapidPublicKey: string }) { + const [unsupported, setUnsupported] = useState(undefined); + const [subscription, setSubscription] = useState(null); + useEffect(() => { + const isUnsupported = notificationUnsupported(); + setUnsupported(isUnsupported); + if (isUnsupported) { + return; + } + + getSubscription(setSubscription); + }, [props.vapidPublicKey]) + + return
+ Notifications are: {subscription ? "Enabled" : "Disabled"} +
+ +
+ Debug +
+				
+					{JSON.stringify(subscription?.toJSON(), undefined, 4) ?? "No subscription set"}
+				
+			
+
+
; +}