Implement proof of concept push notifications

This commit is contained in:
Hornwitser 2025-02-28 15:32:03 +01:00
parent e3210afe3a
commit 6007f4caeb
13 changed files with 407 additions and 1 deletions

View file

@ -1,3 +1,5 @@
node_modules
.next/
next-env.d.ts
#env*
push-subscriptions.json

3
.gitignore vendored
View file

@ -39,3 +39,6 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# misc
push-subscriptions.json

View file

@ -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

View file

@ -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) {

View file

@ -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."}));
}

View file

@ -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."}));
}

View file

@ -61,6 +61,10 @@ label>* {
margin-inline-start: 0.5rem;
}
p + p {
margin-block-start: 0.5rem;
}
label + label {
margin-block-start: 0.5rem;
}

View file

@ -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() {
<p>
Study carefully, we only hold these events once a year.
</p>
<p>
Get notified about updates
</p>
<PushNotification vapidPublicKey={process.env.VAPID_PUBLIC_KEY!} />
<h2>Schedule</h2>
<Timetable />
<EventsEdit />

10
generate-vapid-keys.mjs Normal file
View file

@ -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");

View file

@ -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"
},

120
pnpm-lock.yaml generated
View file

@ -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

25
public/sw.js Normal file
View file

@ -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");
})
);
});

120
ui/push-notification.tsx Normal file
View file

@ -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<boolean | undefined>(undefined);
const [subscription, setSubscription] = useState<PushSubscription | null>(null);
useEffect(() => {
const isUnsupported = notificationUnsupported();
setUnsupported(isUnsupported);
if (isUnsupported) {
return;
}
getSubscription(setSubscription);
}, [props.vapidPublicKey])
return <section>
Notifications are: <b>{subscription ? "Enabled" : "Disabled"}</b>
<br />
<button
disabled={unsupported}
onClick={() => {
if (!subscription)
registerAndSubscribe(props.vapidPublicKey, setSubscription)
else
unsubscribe(subscription, () => setSubscription(null))
}}
>
{unsupported === undefined && "Checking for support"}
{unsupported === true && "Notifications are not supported :(."}
{unsupported === false && subscription ? "Disable notifications" : "Enable notifications"}
</button>
<details>
<summary>Debug</summary>
<pre>
<code>
{JSON.stringify(subscription?.toJSON(), undefined, 4) ?? "No subscription set"}
</code>
</pre>
</details>
</section>;
}