notifications sketch

+2
.gitignore
··· 1 + node_modules/ 2 + keys.json
+98
server/index.js
··· 1 + #!/usr/bin/env node 2 + 3 + const webpush = require('web-push'); 4 + const fs = require('node:fs'); 5 + const http = require('http'); 6 + 7 + const getOrCreateKeys = filename => { 8 + let keys; 9 + try { 10 + const data = fs.readFileSync(filename); 11 + keys = JSON.parse(data); 12 + } catch (err) { 13 + if (err.code != 'ENOENT') throw err; 14 + keys = webpush.generateVAPIDKeys(); 15 + const data = JSON.stringify(keys); 16 + fs.writeFileSync(filename, data); 17 + } 18 + console.log(`Keys ready with pubkey: ${keys.publicKey}`); 19 + return keys; 20 + } 21 + 22 + const getRequesBody = async req => new Promise((resolve, reject) => { 23 + let body = ''; 24 + req.on('data', chunk => body += chunk); 25 + req.on('end', () => resolve(body)); 26 + req.on('error', err => reject(err)); 27 + }); 28 + 29 + const handleFile = (fname, ftype) => async (req, res, replace = {}) => { 30 + let content 31 + try { 32 + content = await fs.promises.readFile(__dirname + '/web-content/' + fname); 33 + content = content.toString(); 34 + } catch (err) { 35 + console.error(err); 36 + res.writeHead(500); 37 + res.end('Internal server error'); 38 + return; 39 + } 40 + res.setHeader('Content-Type', ftype); 41 + res.writeHead(200); 42 + for (let k in replace) { 43 + content = content.replace(k, JSON.stringify(replace[k])); 44 + } 45 + res.end(content); 46 + } 47 + const handleIndex = handleFile('index.html', 'text/html'); 48 + const handleServiceWorker = handleFile('service-worker.js', 'application/javascript'); 49 + 50 + const handleSubscribe = async (req, res) => { 51 + const body = await getRequesBody(req); 52 + console.log('got body', body); 53 + doStuff(JSON.parse(body)); 54 + res.setHeader('Content-Type', 'application/json'); 55 + res.writeHead(201); 56 + res.end('{"oh": "hi"}'); 57 + } 58 + 59 + const doStuff = sub => { 60 + let n = 0; 61 + setInterval(() => { 62 + webpush.sendNotification(sub, `oh hi: ${n}`); 63 + n += 1; 64 + }, 2000); 65 + } 66 + 67 + const requestListener = pubkey => (req, res) => { 68 + if (req.method === 'GET' && req.url === '/') 69 + return handleIndex(req, res, { PUBKEY: pubkey }); 70 + 71 + if (req.method === 'GET' && req.url === '/service-worker.js') 72 + return handleServiceWorker(req, res, { PUBKEY: pubkey }); 73 + 74 + if (req.method === 'POST' && req.url === '/subscribe') 75 + return handleSubscribe(req, res); 76 + 77 + res.writeHead(200); 78 + res.end('sup'); 79 + } 80 + 81 + const main = env => { 82 + if (!env.KEY_FILE) throw new Error('KEY_FILE is required to run'); 83 + const keys = getOrCreateKeys(env.KEY_FILE); 84 + webpush.setVapidDetails( 85 + 'mailto:phil@bad-example.com', 86 + keys.publicKey, 87 + keys.privateKey, 88 + ); 89 + 90 + const host = env.HOST || 'localhost'; 91 + const port = parseInt(env.PORT || 8000, 10); 92 + 93 + http 94 + .createServer(requestListener(keys.publicKey)) 95 + .listen(port, host, () => console.log(`listening at http://${host}:${port}`)); 96 + }; 97 + 98 + main(process.env);
+186
server/package-lock.json
··· 1 + { 2 + "name": "server", 3 + "lockfileVersion": 3, 4 + "requires": true, 5 + "packages": { 6 + "": { 7 + "dependencies": { 8 + "web-push": "^3.6.7" 9 + } 10 + }, 11 + "node_modules/agent-base": { 12 + "version": "7.1.3", 13 + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", 14 + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", 15 + "license": "MIT", 16 + "engines": { 17 + "node": ">= 14" 18 + } 19 + }, 20 + "node_modules/asn1.js": { 21 + "version": "5.4.1", 22 + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", 23 + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", 24 + "license": "MIT", 25 + "dependencies": { 26 + "bn.js": "^4.0.0", 27 + "inherits": "^2.0.1", 28 + "minimalistic-assert": "^1.0.0", 29 + "safer-buffer": "^2.1.0" 30 + } 31 + }, 32 + "node_modules/bn.js": { 33 + "version": "4.12.2", 34 + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", 35 + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", 36 + "license": "MIT" 37 + }, 38 + "node_modules/buffer-equal-constant-time": { 39 + "version": "1.0.1", 40 + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", 41 + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", 42 + "license": "BSD-3-Clause" 43 + }, 44 + "node_modules/debug": { 45 + "version": "4.4.1", 46 + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", 47 + "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", 48 + "license": "MIT", 49 + "dependencies": { 50 + "ms": "^2.1.3" 51 + }, 52 + "engines": { 53 + "node": ">=6.0" 54 + }, 55 + "peerDependenciesMeta": { 56 + "supports-color": { 57 + "optional": true 58 + } 59 + } 60 + }, 61 + "node_modules/ecdsa-sig-formatter": { 62 + "version": "1.0.11", 63 + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", 64 + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", 65 + "license": "Apache-2.0", 66 + "dependencies": { 67 + "safe-buffer": "^5.0.1" 68 + } 69 + }, 70 + "node_modules/http_ece": { 71 + "version": "1.2.0", 72 + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", 73 + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", 74 + "license": "MIT", 75 + "engines": { 76 + "node": ">=16" 77 + } 78 + }, 79 + "node_modules/https-proxy-agent": { 80 + "version": "7.0.6", 81 + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", 82 + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", 83 + "license": "MIT", 84 + "dependencies": { 85 + "agent-base": "^7.1.2", 86 + "debug": "4" 87 + }, 88 + "engines": { 89 + "node": ">= 14" 90 + } 91 + }, 92 + "node_modules/inherits": { 93 + "version": "2.0.4", 94 + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", 95 + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", 96 + "license": "ISC" 97 + }, 98 + "node_modules/jwa": { 99 + "version": "2.0.1", 100 + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", 101 + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", 102 + "license": "MIT", 103 + "dependencies": { 104 + "buffer-equal-constant-time": "^1.0.1", 105 + "ecdsa-sig-formatter": "1.0.11", 106 + "safe-buffer": "^5.0.1" 107 + } 108 + }, 109 + "node_modules/jws": { 110 + "version": "4.0.0", 111 + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", 112 + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", 113 + "license": "MIT", 114 + "dependencies": { 115 + "jwa": "^2.0.0", 116 + "safe-buffer": "^5.0.1" 117 + } 118 + }, 119 + "node_modules/minimalistic-assert": { 120 + "version": "1.0.1", 121 + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", 122 + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", 123 + "license": "ISC" 124 + }, 125 + "node_modules/minimist": { 126 + "version": "1.2.8", 127 + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", 128 + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", 129 + "license": "MIT", 130 + "funding": { 131 + "url": "https://github.com/sponsors/ljharb" 132 + } 133 + }, 134 + "node_modules/ms": { 135 + "version": "2.1.3", 136 + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", 137 + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", 138 + "license": "MIT" 139 + }, 140 + "node_modules/safe-buffer": { 141 + "version": "5.2.1", 142 + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", 143 + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", 144 + "funding": [ 145 + { 146 + "type": "github", 147 + "url": "https://github.com/sponsors/feross" 148 + }, 149 + { 150 + "type": "patreon", 151 + "url": "https://www.patreon.com/feross" 152 + }, 153 + { 154 + "type": "consulting", 155 + "url": "https://feross.org/support" 156 + } 157 + ], 158 + "license": "MIT" 159 + }, 160 + "node_modules/safer-buffer": { 161 + "version": "2.1.2", 162 + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", 163 + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", 164 + "license": "MIT" 165 + }, 166 + "node_modules/web-push": { 167 + "version": "3.6.7", 168 + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", 169 + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", 170 + "license": "MPL-2.0", 171 + "dependencies": { 172 + "asn1.js": "^5.3.0", 173 + "http_ece": "1.2.0", 174 + "https-proxy-agent": "^7.0.0", 175 + "jws": "^4.0.0", 176 + "minimist": "^1.2.5" 177 + }, 178 + "bin": { 179 + "web-push": "src/cli.js" 180 + }, 181 + "engines": { 182 + "node": ">= 16" 183 + } 184 + } 185 + } 186 + }
+5
server/package.json
··· 1 + { 2 + "dependencies": { 3 + "web-push": "^3.6.7" 4 + } 5 + }
+103
server/web-content/index.html
··· 1 + <!doctype html> 2 + <style> 3 + #error-message { 4 + color: tomato; 5 + } 6 + </style> 7 + 8 + hiiiiii 9 + 10 + <button id="subscribe">subscribe</button> 11 + 12 + <p id="error-message"></p> 13 + 14 + 15 + <script> 16 + const err = m => { 17 + document.getElementById('error-message').textContent = m; 18 + throw new Error(m); 19 + }; 20 + 21 + if (!('serviceWorker' in navigator)) err('service worker not supported'); 22 + 23 + if (!('PushManager' in window)) err('push not supported'); 24 + 25 + function urlBase64ToUint8Array(base64String) { 26 + var padding = '='.repeat((4 - base64String.length % 4) % 4); 27 + var base64 = (base64String + padding) 28 + .replace(/\-/g, '+') 29 + .replace(/_/g, '/'); 30 + 31 + var rawData = window.atob(base64); 32 + var outputArray = new Uint8Array(rawData.length); 33 + 34 + for (var i = 0; i < rawData.length; ++i) { 35 + outputArray[i] = rawData.charCodeAt(i); 36 + } 37 + return outputArray; 38 + } 39 + 40 + function registerServiceWorker() { 41 + return navigator.serviceWorker 42 + .register('/service-worker.js') 43 + .then(function (registration) { 44 + console.log('Service worker successfully registered.'); 45 + return registration; 46 + }) 47 + .catch(function (err) { 48 + console.error('Unable to register service worker.', err); 49 + }); 50 + } 51 + 52 + function askPermission() { 53 + return new Promise(function (resolve, reject) { 54 + const permissionResult = Notification.requestPermission(function (result) { 55 + resolve(result); 56 + }); 57 + 58 + if (permissionResult) { 59 + permissionResult.then(resolve, reject); 60 + } 61 + }).then(function (permissionResult) { 62 + if (permissionResult !== 'granted') { 63 + throw new Error("We weren't granted permission."); 64 + } 65 + }); 66 + } 67 + 68 + function subscribeUserToPush() { 69 + return navigator.serviceWorker 70 + .register('/service-worker.js') 71 + .then(function (registration) { 72 + const subscribeOptions = { 73 + userVisibleOnly: true, 74 + applicationServerKey: urlBase64ToUint8Array(PUBKEY), 75 + }; 76 + 77 + return registration.pushManager.subscribe(subscribeOptions); 78 + }) 79 + .then(function (pushSubscription) { 80 + console.log( 81 + 'Received PushSubscription: ', 82 + JSON.stringify(pushSubscription), 83 + ); 84 + return pushSubscription; 85 + }); 86 + } 87 + 88 + document.getElementById('subscribe').addEventListener('click', async () => { 89 + const perm = await askPermission(); 90 + console.log({ perm }); 91 + const sub = await subscribeUserToPush(); 92 + console.log({ sub }); 93 + const res = await fetch('/subscribe', { 94 + method: 'POST', 95 + body: JSON.stringify(sub), 96 + headers: { 97 + 'Content-Type': 'application/json', 98 + }, 99 + }); 100 + console.log('res', res); 101 + }); 102 + 103 + </script>
+60
server/web-content/service-worker.js
··· 1 + console.log('hello????'); 2 + 3 + function urlBase64ToUint8Array(base64String) { 4 + var padding = '='.repeat((4 - base64String.length % 4) % 4); 5 + var base64 = (base64String + padding) 6 + .replace(/\-/g, '+') 7 + .replace(/_/g, '/'); 8 + 9 + var rawData = window.atob(base64); 10 + var outputArray = new Uint8Array(rawData.length); 11 + 12 + for (var i = 0; i < rawData.length; ++i) { 13 + outputArray[i] = rawData.charCodeAt(i); 14 + } 15 + return outputArray; 16 + } 17 + 18 + self.addEventListener('push', function(event) { 19 + console.log('Received a push message', event); 20 + 21 + // Display notification or handle data 22 + // Example: show a notification 23 + const title = 'New Notification'; 24 + const body = event.data.text(); 25 + const icon = '/images/icon.png'; 26 + const tag = 'simple-push-demo-notification-tag'; 27 + 28 + event.waitUntil( 29 + self.registration.showNotification(title, { 30 + body: body, 31 + icon: icon, 32 + tag: tag 33 + }) 34 + ); 35 + 36 + // Attempt to resubscribe after receiving a notification 37 + event.waitUntil(resubscribeToPush()); 38 + }); 39 + 40 + function resubscribeToPush() { 41 + return self.registration.pushManager.getSubscription() 42 + .then(function(subscription) { 43 + if (subscription) { 44 + return subscription.unsubscribe(); 45 + } 46 + }) 47 + .then(function() { 48 + return self.registration.pushManager.subscribe({ 49 + userVisibleOnly: true, 50 + applicationServerKey: urlBase64ToUint8Array(PUBKEY) 51 + }); 52 + }) 53 + .then(function(subscription) { 54 + console.log('Resubscribed to push notifications:', subscription); 55 + // Optionally, send new subscription details to your server 56 + }) 57 + .catch(function(error) { 58 + console.error('Failed to resubscribe:', error); 59 + }); 60 + }