├── backend ├── .gitignore ├── package.json ├── index.js ├── subscriptionHandler.js └── yarn.lock ├── frontend ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── src │ ├── index.js │ ├── setupTests.js │ ├── App.test.js │ ├── index.css │ ├── App.css │ ├── sw.js │ ├── logo.svg │ ├── App.js │ ├── usePushNotifications.js │ └── serviceWorker.js ├── .gitignore ├── package.json └── README.md └── README.md /backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinhyenvodoi98/Push_Notification_Nodejs_Reactjs/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinhyenvodoi98/Push_Notification_Nodejs_Reactjs/HEAD/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vinhyenvodoi98/Push_Notification_Nodejs_Reactjs/HEAD/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /frontend/src/setupTests.js: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /frontend/src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | const { getByText } = render(); 7 | const linkElement = getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 4 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', 5 | sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | } 9 | 10 | code { 11 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 12 | monospace; 13 | } 14 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .firebaserc 26 | firebase.json 27 | .firebase -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node index.js" 9 | }, 10 | "dependencies": { 11 | "body-parser": "^1.19.0", 12 | "cors": "^2.8.5", 13 | "crypto": "^1.0.1", 14 | "express": "^4.17.1", 15 | "morgan": "^1.10.0", 16 | "web-push": "^3.3.5" 17 | }, 18 | "keywords": [], 19 | "author": "", 20 | "license": "ISC" 21 | } 22 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /backend/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const bodyParser = require('body-parser'); 3 | const logger = require('morgan'); 4 | const cors = require('cors'); 5 | const subscriptionHandler = require('./subscriptionHandler'); 6 | 7 | var port = process.env.PORT || 4000; 8 | 9 | const app = express(); 10 | 11 | app.use( 12 | cors({ 13 | origin: '*', // allow to server to accept request from different origin 14 | methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', 15 | credentials: true // allow session cookie from browser to pass through 16 | }) 17 | ); 18 | 19 | app.use(logger('dev')); 20 | app.use(bodyParser.urlencoded({ extended: true })); 21 | app.use(bodyParser.json()); 22 | 23 | app.post('/subscription', subscriptionHandler.handlePushNotificationSubscription); 24 | app.get('/subscription/:id', subscriptionHandler.sendPushNotification); 25 | 26 | app.listen(port); 27 | console.log('The magic happens on port ' + port); 28 | -------------------------------------------------------------------------------- /frontend/src/sw.js: -------------------------------------------------------------------------------- 1 | function receivePushNotification(event) { 2 | console.log('[Service Worker] Push Received.'); 3 | 4 | const { image, tag, url, title, text } = event.data.json(); 5 | 6 | const options = { 7 | data: url, 8 | body: text, 9 | icon: image, 10 | vibrate: [200, 100, 200], 11 | tag: tag, 12 | image: image, 13 | badge: 'https://spyna.it/icons/favicon.ico', 14 | actions: [{ action: 'Detail', title: 'View', icon: 'https://via.placeholder.com/128/ff0000' }] 15 | }; 16 | event.waitUntil(self.registration.showNotification(title, options)); 17 | } 18 | 19 | function openPushNotification(event) { 20 | console.log('[Service Worker] Notification click Received.', event.notification.data); 21 | 22 | event.notification.close(); 23 | event.waitUntil(clients.openWindow(event.notification.data)); 24 | } 25 | 26 | self.addEventListener('push', receivePushNotification); 27 | self.addEventListener('notificationclick', openPushNotification); 28 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@testing-library/jest-dom": "^4.2.4", 7 | "@testing-library/react": "^9.3.2", 8 | "@testing-library/user-event": "^7.1.2", 9 | "axios": "^0.21.1", 10 | "react": "^16.13.1", 11 | "react-dom": "^16.13.1", 12 | "react-scripts": "3.4.1", 13 | "serve": "^11.3.0" 14 | }, 15 | "scripts": { 16 | "start": "react-scripts start", 17 | "build": "rm -rf build/ && react-scripts build && npm run-script sw", 18 | "sw": "cat src/sw.js >> build/service-worker.js", 19 | "test": "react-scripts test", 20 | "eject": "react-scripts eject" 21 | }, 22 | "eslintConfig": { 23 | "extends": "react-app" 24 | }, 25 | "browserslist": { 26 | "production": [ 27 | ">0.2%", 28 | "not dead", 29 | "not op_mini all" 30 | ], 31 | "development": [ 32 | "last 1 chrome version", 33 | "last 1 firefox version", 34 | "last 1 safari version" 35 | ] 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /backend/subscriptionHandler.js: -------------------------------------------------------------------------------- 1 | const subscriptions = {}; 2 | var crypto = require('crypto'); 3 | const webpush = require('web-push'); 4 | 5 | const vapidKeys = { 6 | privateKey: 'bdSiNzUhUP6piAxLH-tW88zfBlWWveIx0dAsDO66aVU', 7 | publicKey: 8 | 'BIN2Jc5Vmkmy-S3AUrcMlpKxJpLeVRAfu9WBqUbJ70SJOCWGCGXKY-Xzyh7HDr6KbRDGYHjqZ06OcS3BjD7uAm8' 9 | }; 10 | 11 | webpush.setVapidDetails('mailto:example@yourdomain.org', vapidKeys.publicKey, vapidKeys.privateKey); 12 | 13 | function createHash(input) { 14 | const md5sum = crypto.createHash('md5'); 15 | md5sum.update(Buffer.from(input)); 16 | return md5sum.digest('hex'); 17 | } 18 | 19 | function handlePushNotificationSubscription(req, res) { 20 | const subscriptionRequest = req.body.data; 21 | const susbscriptionId = createHash(JSON.stringify(subscriptionRequest)); 22 | subscriptions[susbscriptionId] = subscriptionRequest; 23 | res.status(201).json({ id: susbscriptionId }); 24 | } 25 | 26 | function sendPushNotification(req, res) { 27 | const subscriptionId = req.params.id; 28 | const pushSubscription = subscriptions[subscriptionId]; 29 | webpush 30 | .sendNotification( 31 | pushSubscription, 32 | JSON.stringify({ 33 | title: 'New Product Available ', 34 | text: 'HEY! Take a look at this brand new t-shirt!', 35 | image: '/images/jason-leung-HM6TMmevbZQ-unsplash.jpg', 36 | tag: 'new-product', 37 | url: '/new-product-jason-leung-HM6TMmevbZQ-unsplash.html' 38 | }) 39 | ) 40 | .catch((err) => { 41 | console.log(err); 42 | }); 43 | 44 | res.status(202).json({}); 45 | } 46 | 47 | module.exports = { handlePushNotificationSubscription, sendPushNotification }; 48 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /frontend/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /frontend/src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import logo from './logo.svg'; 3 | import usePushNotifications from './usePushNotifications'; 4 | import './App.css'; 5 | 6 | function App() { 7 | const { 8 | userConsent, 9 | pushNotificationSupported, 10 | userSubscription, 11 | onClickAskUserPermission, 12 | onClickSusbribeToPushNotification, 13 | onClickSendSubscriptionToPushServer, 14 | pushServerSubscriptionId, 15 | onClickSendNotification, 16 | error, 17 | loading 18 | } = usePushNotifications(); 19 | 20 | const Loading = ({ loading }) => 21 | loading ?
Please wait, we are loading something...
: null; 22 | const Error = ({ error }) => 23 | error ? ( 24 |
25 |

{error.name}

26 |

Error message : {error.message}

27 |

Error code : {error.code}

28 |
29 | ) : null; 30 | 31 | const isConsentGranted = userConsent === 'granted'; 32 | 33 | return ( 34 |
35 |
36 | logo 37 | 38 | 39 |

Push notification are {!pushNotificationSupported && 'NOT'} supported by your device.

40 | 41 |

42 | User consent to recevie push notificaitons is {userConsent}. 43 |

44 | 45 | 46 | 47 | 52 | 53 | 58 | 59 | 66 | 67 | {pushServerSubscriptionId && ( 68 |
69 |

The server accepted the push subscrption!

70 | 71 |
72 | )} 73 |
74 |
75 | ); 76 | } 77 | 78 | export default App; 79 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 2 | 3 | ## Available Scripts 4 | 5 | In the project directory, you can run: 6 | 7 | ### `yarn start` 8 | 9 | Runs the app in the development mode.
10 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 11 | 12 | The page will reload if you make edits.
13 | You will also see any lint errors in the console. 14 | 15 | ### `yarn test` 16 | 17 | Launches the test runner in the interactive watch mode.
18 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 19 | 20 | ### `yarn build` 21 | 22 | Builds the app for production to the `build` folder.
23 | It correctly bundles React in production mode and optimizes the build for the best performance. 24 | 25 | The build is minified and the filenames include the hashes.
26 | Your app is ready to be deployed! 27 | 28 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 29 | 30 | ### `yarn eject` 31 | 32 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 33 | 34 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 35 | 36 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 37 | 38 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 39 | 40 | ## Learn More 41 | 42 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 43 | 44 | To learn React, check out the [React documentation](https://reactjs.org/). 45 | 46 | ### Code Splitting 47 | 48 | This section has moved here: https://facebook.github.io/create-react-app/docs/code-splitting 49 | 50 | ### Analyzing the Bundle Size 51 | 52 | This section has moved here: https://facebook.github.io/create-react-app/docs/analyzing-the-bundle-size 53 | 54 | ### Making a Progressive Web App 55 | 56 | This section has moved here: https://facebook.github.io/create-react-app/docs/making-a-progressive-web-app 57 | 58 | ### Advanced Configuration 59 | 60 | This section has moved here: https://facebook.github.io/create-react-app/docs/advanced-configuration 61 | 62 | ### Deployment 63 | 64 | This section has moved here: https://facebook.github.io/create-react-app/docs/deployment 65 | 66 | ### `yarn build` fails to minify 67 | 68 | This section has moved here: https://facebook.github.io/create-react-app/docs/troubleshooting#npm-run-build-fails-to-minify 69 | -------------------------------------------------------------------------------- /frontend/src/usePushNotifications.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import * as serviceWorker from './serviceWorker'; 4 | 5 | const pushNotificationSupported = serviceWorker.isPushNotificationSupported(); 6 | // check push notifications are supported by the browser 7 | 8 | export default function usePushNotifications() { 9 | const [userConsent, setSuserConsent] = useState(Notification.permission); 10 | //to manage the user consent: Notification.permission is a JavaScript native function that return the current state of the permission 11 | //We initialize the userConsent with that value 12 | const [userSubscription, setUserSubscription] = useState(null); 13 | //to manage the use push notification subscription 14 | const [pushServerSubscriptionId, setPushServerSubscriptionId] = useState(); 15 | //to manage the push server subscription 16 | const [error, setError] = useState(null); 17 | //to manage errors 18 | const [loading, setLoading] = useState(true); 19 | //to manage async actions 20 | 21 | useEffect(() => { 22 | if (pushNotificationSupported) { 23 | setLoading(true); 24 | setError(false); 25 | serviceWorker.register(); 26 | } 27 | }, []); 28 | //if the push notifications are supported, registers the service worker 29 | //this effect runs only the first render 30 | 31 | useEffect(() => { 32 | setLoading(true); 33 | setError(false); 34 | const getExixtingSubscription = async () => { 35 | const existingSubscription = await serviceWorker.getUserSubscription(); 36 | setUserSubscription(existingSubscription); 37 | setLoading(false); 38 | }; 39 | getExixtingSubscription(); 40 | }, []); 41 | //Retrieve if there is any push notification subscription for the registered service worker 42 | // this use effect runs only in the first render 43 | 44 | /** 45 | * define a click handler that asks the user permission, 46 | * it uses the setSuserConsent state, to set the consent of the user 47 | * If the user denies the consent, an error is created with the setError hook 48 | */ 49 | const onClickAskUserPermission = () => { 50 | setLoading(true); 51 | setError(false); 52 | serviceWorker.askUserPermission().then((consent) => { 53 | setSuserConsent(consent); 54 | if (consent !== 'granted') { 55 | setError({ 56 | name: 'Consent denied', 57 | message: 'You denied the consent to receive notifications', 58 | code: 0 59 | }); 60 | } 61 | setLoading(false); 62 | }); 63 | }; 64 | // 65 | 66 | /** 67 | * define a click handler that creates a push notification subscription. 68 | * Once the subscription is created, it uses the setUserSubscription hook 69 | */ 70 | const onClickSusbribeToPushNotification = () => { 71 | setLoading(true); 72 | setError(false); 73 | serviceWorker 74 | .createNotificationSubscription() 75 | .then(function(subscrition) { 76 | setUserSubscription(subscrition); 77 | setLoading(false); 78 | }) 79 | .catch((err) => { 80 | console.error( 81 | "Couldn't create the notification subscription", 82 | err, 83 | 'name:', 84 | err.name, 85 | 'message:', 86 | err.message, 87 | 'code:', 88 | err.code 89 | ); 90 | setError(err); 91 | setLoading(false); 92 | }); 93 | }; 94 | 95 | /** 96 | * define a click handler that sends the push susbcribtion to the push server. 97 | * Once the subscription ics created on the server, it saves the id using the hook setPushServerSubscriptionId 98 | */ 99 | const onClickSendSubscriptionToPushServer = () => { 100 | setLoading(true); 101 | setError(false); 102 | axios 103 | .post('http://localhost:4000/subscription', { data: userSubscription }) 104 | .then(function(response) { 105 | setPushServerSubscriptionId(response.data.id); 106 | setLoading(false); 107 | }) 108 | .catch((err) => { 109 | setLoading(false); 110 | setError(err); 111 | }); 112 | }; 113 | 114 | /** 115 | * define a click handler that requests the push server to send a notification, passing the id of the saved subscription 116 | */ 117 | const onClickSendNotification = async () => { 118 | setLoading(true); 119 | setError(false); 120 | axios.get(`http://localhost:4000/subscription/${pushServerSubscriptionId}`).catch((error) => { 121 | setLoading(false); 122 | setError(error); 123 | }); 124 | setLoading(false); 125 | }; 126 | 127 | return { 128 | onClickAskUserPermission, 129 | onClickSusbribeToPushNotification, 130 | onClickSendSubscriptionToPushServer, 131 | pushServerSubscriptionId, 132 | onClickSendNotification, 133 | userConsent, 134 | pushNotificationSupported, 135 | userSubscription, 136 | error, 137 | loading 138 | }; 139 | } 140 | -------------------------------------------------------------------------------- /frontend/src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.0/8 are considered localhost for IPv4. 18 | window.location.hostname.match(/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/) 19 | ); 20 | 21 | const pushServerPublicKey = 22 | 'BIN2Jc5Vmkmy-S3AUrcMlpKxJpLeVRAfu9WBqUbJ70SJOCWGCGXKY-Xzyh7HDr6KbRDGYHjqZ06OcS3BjD7uAm8'; 23 | 24 | export function register(config) { 25 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 26 | // The URL constructor is available in all browsers that support SW. 27 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 28 | if (publicUrl.origin !== window.location.origin) { 29 | // Our service worker won't work if PUBLIC_URL is on a different origin 30 | // from what our page is served on. This might happen if a CDN is used to 31 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 32 | return; 33 | } 34 | 35 | window.addEventListener('load', () => { 36 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 37 | 38 | if (isLocalhost) { 39 | // This is running on localhost. Let's check if a service worker still exists or not. 40 | checkValidServiceWorker(swUrl, config); 41 | 42 | // Add some additional logging to localhost, pointing developers to the 43 | // service worker/PWA documentation. 44 | navigator.serviceWorker.ready.then(() => { 45 | console.log( 46 | 'This web app is being served cache-first by a service ' + 47 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 48 | ); 49 | }); 50 | } else { 51 | // Is not localhost. Just register service worker 52 | registerValidSW(swUrl, config); 53 | console.log('aaa'); 54 | } 55 | }); 56 | } 57 | } 58 | 59 | function registerValidSW(swUrl, config) { 60 | navigator.serviceWorker 61 | .register(swUrl) 62 | .then((registration) => { 63 | registration.onupdatefound = () => { 64 | const installingWorker = registration.installing; 65 | if (installingWorker == null) { 66 | return; 67 | } 68 | installingWorker.onstatechange = () => { 69 | if (installingWorker.state === 'installed') { 70 | if (navigator.serviceWorker.controller) { 71 | // At this point, the updated precached content has been fetched, 72 | // but the previous service worker will still serve the older 73 | // content until all client tabs are closed. 74 | console.log( 75 | 'New content is available and will be used when all ' + 76 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 77 | ); 78 | 79 | // Execute callback 80 | if (config && config.onUpdate) { 81 | config.onUpdate(registration); 82 | } 83 | } else { 84 | // At this point, everything has been precached. 85 | // It's the perfect time to display a 86 | // "Content is cached for offline use." message. 87 | console.log('Content is cached for offline use.'); 88 | 89 | // Execute callback 90 | if (config && config.onSuccess) { 91 | config.onSuccess(registration); 92 | } 93 | } 94 | } 95 | }; 96 | }; 97 | }) 98 | .catch((error) => { 99 | console.error('Error during service worker registration:', error); 100 | }); 101 | } 102 | 103 | function checkValidServiceWorker(swUrl, config) { 104 | // Check if the service worker can be found. If it can't reload the page. 105 | fetch(swUrl, { 106 | headers: { 'Service-Worker': 'script' } 107 | }) 108 | .then((response) => { 109 | // Ensure service worker exists, and that we really are getting a JS file. 110 | const contentType = response.headers.get('content-type'); 111 | if ( 112 | response.status === 404 || 113 | (contentType != null && contentType.indexOf('javascript') === -1) 114 | ) { 115 | // No service worker found. Probably a different app. Reload the page. 116 | navigator.serviceWorker.ready.then((registration) => { 117 | registration.unregister().then(() => { 118 | window.location.reload(); 119 | }); 120 | }); 121 | } else { 122 | // Service worker found. Proceed as normal. 123 | registerValidSW(swUrl, config); 124 | } 125 | }) 126 | .catch(() => { 127 | console.log('No internet connection found. App is running in offline mode.'); 128 | }); 129 | } 130 | 131 | export function unregister() { 132 | if ('serviceWorker' in navigator) { 133 | navigator.serviceWorker.ready 134 | .then((registration) => { 135 | registration.unregister(); 136 | }) 137 | .catch((error) => { 138 | console.error(error.message); 139 | }); 140 | } 141 | } 142 | 143 | export async function askUserPermission() { 144 | return await Notification.requestPermission(); 145 | } 146 | 147 | export async function createNotificationSubscription() { 148 | //wait for service worker installation to be ready 149 | 150 | const serviceWorker = await navigator.serviceWorker.ready; 151 | 152 | // subscribe and return the subscription 153 | return await serviceWorker.pushManager.subscribe({ 154 | userVisibleOnly: true, 155 | applicationServerKey: pushServerPublicKey 156 | }); 157 | } 158 | 159 | export function getUserSubscription() { 160 | //wait for service worker installation to be ready, and then 161 | return navigator.serviceWorker.ready 162 | .then(function(serviceWorker) { 163 | return serviceWorker.pushManager.getSubscription(); 164 | }) 165 | .then(function(pushSubscription) { 166 | return pushSubscription; 167 | }); 168 | } 169 | 170 | export function isPushNotificationSupported() { 171 | return 'serviceWorker' in navigator && 'PushManager' in window; 172 | } 173 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Push_Notification_Nodejs_Reactjs 2 | 3 | Gần đây mình có nghe một người bạn kể về một task trong công việc đó là làm sao để bắn thông báo cho ng dùng thông qua browser . Lúc đấy vs mình thì khái niệm này khá mơ hồ và mình cho rằng nếu đã tắt tab ứng dụng rồi thì làm sao bắn đc Notification chứ. Rồi mình thử tìm hiểu xem có thực sự làm đc chuyện đấy không . Bài viết này mình sẽ chia sẻ các thức hoạt động cũng như một ví dụ minh họa nhỏ mà mình tìm hiểu và làm được về Push notification . 4 | ![](https://images.viblo.asia/9f702884-72a9-4242-8940-2bf2a16baf98.png) 5 | 6 | À hóa ra là nó đây thứ mà bấy lâu nay mình nghĩ nó là quảng cáo và nhấn vào Block mà chẳng mảy may suy nghĩ nó thực sự là gì. Không dài dòng thêm nữa chúng ta cùng bắt đầu tìm hiểu về nó nào 7 | 8 | ## Service Worker 9 | Ủa ủa đang tìm hiểu về Push notification lên browser cơ mà. Vâng mình ko nhầm bài viết đâu ạ. Đầu tiên phải tìm hiểu Service Worker trước đã, Service Worker là một **script** hoạt động ngầm trên browser ( browser underground ) mà không có sự tương tác của người dùng . Ngoài ra, nó giống như một proxy hoạt động ở phía người dùng. 10 | 11 | ### Chúng ta có thể làm gì với Service Worker? 12 | 13 | * Kiểm soát Network Traffic! 14 | * Cache lại dữ liệu req/res từ đó có thể chạy web offline 15 | * Thực hiện một số tính năng chạy background như: **Push notification** và background synchronization. 16 | * Service Worker chỉ có thể làm việc trên giao thức HTTPS. Bạn cũng có thể làm việc trên localhost trong quá trình phát triển. 17 | * ... 18 | 19 | Trong bài này thì mình chỉ tập trung vào phần Push notification thôi nhé . Đầu tiên chúng ta cần nắm rõ hoạt động Push này có 2 phần: 20 | * **Push** : việc gửi thông báo từ máy chủ đến ứng dụng. vì thế bạn sẽ cần một server 21 | * **Notification** : thông báo được hiển thị trên thanh trạng thái của điện thoại hoặc trên browser. 22 | 23 | ## Web push notifications flow 24 | Chúng ta sẽ có 4 tác nhân chính trong quá trình này : 25 | * **User** : Ng dùng muốn nhận thông báo 26 | * **Application** : Ứng dụng chạy ở phía ng dùng 27 | * **Service worker** : Chạy trên browser 28 | * **Push Server** : Server bắn tin nhắn đến service workers 29 | ![](https://images.viblo.asia/feee5b2b-d030-4de6-974a-f0e59aa006ba.png) 30 | 31 | Giải thích flow : 32 | 33 | **1,** Luồng bắt đầu khi ứng dụng **yêu cầu người dùng đồng ý** hiển thị notifications ( giống như ảnh ban đầu ý ). User sẽ có một vài kịch bản có thể xảy ra : 34 | 35 | * Do nothing : `default` và như vậy thì notification sẽ ko hiện ra 36 | * Grant : `granded` notification sẽ có thể hiển thị 37 | * Deny : `denied` notification sẽ ko hiện ra 38 | 39 | **2, 3,** Sau khi người dùng đồng ý nhận notifications ứng dụng sẽ register một service worker 40 | 41 | **4, 5, 6,** Sau khi đăng kí thành công sẽ tiến hành tạo push notification **subscription** 42 | 43 | **7,** Sau đó ứng dụng sẽ gửi đến server . Tất nhiên là server cần phải có 1 endpoint subscription để gửi noti 44 | 45 | **8,** Sau đó server sẽ lưu subcription đó 46 | 47 | **9,** Sau đó Serer có thể gửi notification đến service worker . 48 | 49 | **10, 11, 12,** Service sẽ hiển thị cho ng dùng . Sau đó nếu ng dùng nhấn vào notification đấy server worker sẽ nhận được hành động đó thông quá **notification click event**. Sau đó nó có thể kích hoạt một số kịch bản như hiển thị trang web , gọi API , v..v.... 50 | 51 | ## Code tí vận động ngón tay nào 52 | Ví dụ này mình sẽ làm bằng **Nodejs** và **Reactjs** vì thế mặc định là mọi người có một số kiến thức cũng như môi trường cài đặt nhé . 53 | ### Tạo server 54 | Vẫn những câu lệnh quen thuộc tạo một server nodejs + express và lần này có thêm một thư viện `web-push` 55 | ``` 56 | npm init -y 57 | yarn add body-parser cors crypto express morgan web-push 58 | ``` 59 | 60 | tạo file `index.js` 61 | ```js 62 | const express = require('express'); 63 | const bodyParser = require('body-parser'); 64 | const logger = require('morgan'); 65 | const cors = require('cors'); 66 | const subscriptionHandler = require('./subscriptionHandler'); 67 | 68 | var port = process.env.PORT || 4000; 69 | 70 | const app = express(); 71 | 72 | app.use( 73 | cors({ 74 | origin: '*', // allow to server to accept request from different origin 75 | methods: 'GET,HEAD,PUT,PATCH,POST,DELETE', 76 | credentials: true // allow session cookie from browser to pass through 77 | }) 78 | ); 79 | 80 | app.use(logger('dev')); 81 | app.use(bodyParser.urlencoded({ extended: true })); 82 | app.use(bodyParser.json()); 83 | 84 | app.post('/subscription', subscriptionHandler.handlePushNotificationSubscription); 85 | app.get('/subscription/:id', subscriptionHandler.sendPushNotification); 86 | 87 | app.listen(port); 88 | console.log('The magic happens on port ' + port); 89 | ``` 90 | 91 | tạo file `subscriptionHandler.js` để handle việc **push notification** phía server 92 | ```js 93 | const subscriptions = {}; 94 | var crypto = require('crypto'); 95 | const webpush = require('web-push'); 96 | 97 | const vapidKeys = { 98 | privateKey: 'bdSiNzUhUP6piAxLH-tW88zfBlWWveIx0dAsDO66aVU', 99 | publicKey: 100 | 'BIN2Jc5Vmkmy-S3AUrcMlpKxJpLeVRAfu9WBqUbJ70SJOCWGCGXKY-Xzyh7HDr6KbRDGYHjqZ06OcS3BjD7uAm8' 101 | }; 102 | 103 | webpush.setVapidDetails('mailto:example@yourdomain.org', vapidKeys.publicKey, vapidKeys.privateKey); 104 | 105 | function createHash(input) { 106 | const md5sum = crypto.createHash('md5'); 107 | md5sum.update(Buffer.from(input)); 108 | return md5sum.digest('hex'); 109 | } 110 | 111 | function handlePushNotificationSubscription(req, res) { 112 | const subscriptionRequest = req.body.data; 113 | const susbscriptionId = createHash(JSON.stringify(subscriptionRequest)); 114 | subscriptions[susbscriptionId] = subscriptionRequest; 115 | res.status(201).json({ id: susbscriptionId }); 116 | } 117 | 118 | function sendPushNotification(req, res) { 119 | const subscriptionId = req.params.id; 120 | const pushSubscription = subscriptions[subscriptionId]; 121 | webpush 122 | .sendNotification( 123 | pushSubscription, 124 | JSON.stringify({ 125 | title: 'New Product Available ', 126 | text: 'HEY! Take a look at this brand new t-shirt!', 127 | image: '/images/jason-leung-HM6TMmevbZQ-unsplash.jpg', 128 | tag: 'new-product', 129 | url: '/new-product-jason-leung-HM6TMmevbZQ-unsplash.html' 130 | }) 131 | ) 132 | .catch((err) => { 133 | console.log(err); 134 | }); 135 | 136 | res.status(202).json({}); 137 | } 138 | 139 | module.exports = { handlePushNotificationSubscription, sendPushNotification }; 140 | ``` 141 | 142 | #### Kịch bản 143 | Server này chúng ta tạo 2 api **POST** `/subscription` và **GET** `/subscription/:id` để đăng kí và kích hoạt push notification từ phía server tất nhiên là phần **GET** có thể đc kích hoạt bằng những xử lý logic trong project thực sự như khi có thêm sản phẩm mới ở web bán hàng chứ ko phải client gọi api để bắn notification về mình. 144 | 145 | Hơn nữa mình cũng ko dùng database nào nên dữ liệu sẽ đc fix cứng trong function `sendPushNotification` . Danh sách các client đăng kí cũng đc lưu trong obj `subscriptions` và phân biệt vs nhau bằng id đc generate từ function `createHash` 146 | 147 | Như vậy là đã xong phần server rồi . 148 | 149 | ### Tạo React app 150 | Cách nhanh nhất để tạo khởi tạo React là chạy lệnh `create-react-app`: 151 | 152 | ``` 153 | npx create-react-app frontend 154 | cd frontend 155 | yarn add axios serve 156 | ``` 157 | 158 | Mình install luôn thư viện **axios** để tiện request đến server thay vì fetch 159 | **serve** để chạy web trên file build 160 | ### Chú ý 161 | Mặc dù có thể chạy service worker ở local nhưng react yêu cầu phải chạy từ file build. Vì thế chạy service worker trên React thì phải chạy build rồi start bằng lệnh . Hơn nữa vì service worker có cache lại nên để test chuẩn nhất thì nên thỉnh thoảng xóa cache của browser . 162 | ``` 163 | yarn serve -s build 164 | ``` 165 | 166 | Tạo file `usePushNotifications.js` ( một custom Hook ) 167 | ```js 168 | import { useState, useEffect } from 'react'; 169 | import axios from 'axios'; 170 | import * as serviceWorker from './serviceWorker'; 171 | 172 | const pushNotificationSupported = serviceWorker.isPushNotificationSupported(); 173 | // check push notifications are supported by the browser 174 | 175 | export default function usePushNotifications() { 176 | const [userConsent, setSuserConsent] = useState(Notification.permission); 177 | //to manage the user consent: Notification.permission is a JavaScript native function that return the current state of the permission 178 | //We initialize the userConsent with that value 179 | const [userSubscription, setUserSubscription] = useState(null); 180 | //to manage the use push notification subscription 181 | const [pushServerSubscriptionId, setPushServerSubscriptionId] = useState(); 182 | //to manage the push server subscription 183 | const [error, setError] = useState(null); 184 | //to manage errors 185 | const [loading, setLoading] = useState(true); 186 | //to manage async actions 187 | 188 | useEffect(() => { 189 | if (pushNotificationSupported) { 190 | setLoading(true); 191 | setError(false); 192 | serviceWorker.register(); 193 | } 194 | }, []); 195 | //if the push notifications are supported, registers the service worker 196 | //this effect runs only the first render 197 | 198 | useEffect(() => { 199 | setLoading(true); 200 | setError(false); 201 | const getExixtingSubscription = async () => { 202 | const existingSubscription = await serviceWorker.getUserSubscription(); 203 | setUserSubscription(existingSubscription); 204 | setLoading(false); 205 | }; 206 | getExixtingSubscription(); 207 | }, []); 208 | //Retrieve if there is any push notification subscription for the registered service worker 209 | // this use effect runs only in the first render 210 | 211 | /** 212 | * define a click handler that asks the user permission, 213 | * it uses the setSuserConsent state, to set the consent of the user 214 | * If the user denies the consent, an error is created with the setError hook 215 | */ 216 | const onClickAskUserPermission = () => { 217 | setLoading(true); 218 | setError(false); 219 | serviceWorker.askUserPermission().then((consent) => { 220 | setSuserConsent(consent); 221 | if (consent !== 'granted') { 222 | setError({ 223 | name: 'Consent denied', 224 | message: 'You denied the consent to receive notifications', 225 | code: 0 226 | }); 227 | } 228 | setLoading(false); 229 | }); 230 | }; 231 | // 232 | 233 | /** 234 | * define a click handler that creates a push notification subscription. 235 | * Once the subscription is created, it uses the setUserSubscription hook 236 | */ 237 | const onClickSusbribeToPushNotification = () => { 238 | setLoading(true); 239 | setError(false); 240 | serviceWorker 241 | .createNotificationSubscription() 242 | .then(function(subscrition) { 243 | setUserSubscription(subscrition); 244 | setLoading(false); 245 | }) 246 | .catch((err) => { 247 | console.error( 248 | "Couldn't create the notification subscription", 249 | err, 250 | 'name:', 251 | err.name, 252 | 'message:', 253 | err.message, 254 | 'code:', 255 | err.code 256 | ); 257 | setError(err); 258 | setLoading(false); 259 | }); 260 | }; 261 | 262 | /** 263 | * define a click handler that sends the push susbcribtion to the push server. 264 | * Once the subscription ics created on the server, it saves the id using the hook setPushServerSubscriptionId 265 | */ 266 | const onClickSendSubscriptionToPushServer = () => { 267 | setLoading(true); 268 | setError(false); 269 | axios 270 | .post('http://localhost:4000/subscription', { data: userSubscription }) 271 | .then(function(response) { 272 | setPushServerSubscriptionId(response.data.id); 273 | setLoading(false); 274 | }) 275 | .catch((err) => { 276 | setLoading(false); 277 | setError(err); 278 | }); 279 | }; 280 | 281 | /** 282 | * define a click handler that requests the push server to send a notification, passing the id of the saved subscription 283 | */ 284 | const onClickSendNotification = async () => { 285 | setLoading(true); 286 | setError(false); 287 | axios.get(`http://localhost:4000/subscription/${pushServerSubscriptionId}`).catch((error) => { 288 | setLoading(false); 289 | setError(error); 290 | }); 291 | setLoading(false); 292 | }; 293 | 294 | return { 295 | onClickAskUserPermission, 296 | onClickSusbribeToPushNotification, 297 | onClickSendSubscriptionToPushServer, 298 | pushServerSubscriptionId, 299 | onClickSendNotification, 300 | userConsent, 301 | pushNotificationSupported, 302 | userSubscription, 303 | error, 304 | loading 305 | }; 306 | } 307 | ``` 308 | 309 | Nhiều code thế @@ 310 | * Đầu tiên là check browser có support push notification không 311 | * Nếu push notifications đã được support tiến hành registers service worker 312 | * Handler click bằng `onClickAskUserPermission` để yêu cầu ng dùng cung cấp quyền 313 | * Handler click bằng `onClickSubscribeToPushNotification` để tạo push notification subscription 314 | * Handler click bằng `onClickSendSubscriptionToPushServer` để gửi `push subscription` đến server 315 | * Handler click bằng `onclickSendNotification` để mô phỏng việc server gửi notification cho mình 316 | 317 | Tiếp theo thêm một vài function vào `serviceWorker.js` file này đã đc tạo sẵn rồi 318 | ```js 319 | const pushServerPublicKey = 320 | 'BIN2Jc5Vmkmy-S3AUrcMlpKxJpLeVRAfu9WBqUbJ70SJOCWGCGXKY-Xzyh7HDr6KbRDGYHjqZ06OcS3BjD7uAm8'; 321 | 322 | export async function askUserPermission() { 323 | return await Notification.requestPermission(); 324 | } 325 | 326 | export async function createNotificationSubscription() { 327 | //wait for service worker installation to be ready 328 | 329 | const serviceWorker = await navigator.serviceWorker.ready; 330 | 331 | // subscribe and return the subscription 332 | return await serviceWorker.pushManager.subscribe({ 333 | userVisibleOnly: true, 334 | applicationServerKey: pushServerPublicKey 335 | }); 336 | } 337 | 338 | export function getUserSubscription() { 339 | //wait for service worker installation to be ready, and then 340 | return navigator.serviceWorker.ready 341 | .then(function(serviceWorker) { 342 | return serviceWorker.pushManager.getSubscription(); 343 | }) 344 | .then(function(pushSubscription) { 345 | return pushSubscription; 346 | }); 347 | } 348 | 349 | export function isPushNotificationSupported() { 350 | return 'serviceWorker' in navigator && 'PushManager' in window; 351 | } 352 | ``` 353 | 354 | **Chú thích** : chúng ta thêm các hàm 355 | * **askUserPermission** để xin quyền của user 356 | * **isPushNotificationSupported** để check browser có hỗ trợ ko ( cái này sẽ check đc ví dụ mở ẩn danh sẽ ko hỗ trợ ) 357 | * **pushServerPublicKey** của server 358 | * **createNotificationSubscription** để tạo Notification Subscription mà chúng ta đã tạo sự kiện `onClickSusbribeToPushNotification` ở `usePushNotifications.js` bên trên 359 | 360 | ### Mấu chốt để bắn noti về browser 361 | ```js 362 | return await serviceWorker.pushManager.subscribe({ 363 | userVisibleOnly: true, 364 | applicationServerKey: pushServerPublicKey 365 | }); 366 | ``` 367 | Để tạo chúng ta cần 2 tham số 368 | - **userVisibleOnly** : giá trị kiểu **Boolean** chỉ ra push subscription được server lại sẽ chỉ được sử dụng để hiển thị cho người dùng . 369 | - **applicationServerKey** : là một Base64-encoded **DOMString** hoặc **ArrayBuffer** chứa ECDSA P-256 public key mà server push notification sau sẽ dùng để xác thực . 370 | 371 | ```js 372 | endpoint: '.....', 373 | keys: { 374 | auth: '.....', 375 | p256dh: '.....' 376 | } 377 | ``` 378 | 379 | Đây chính là những gì mà hàm **createNotificationSubscription** tạo ra và chúng ta dùng để register trên server. 380 | - **endpoint** : là endpoint mang tính chất duy nhất và được sử dụng để định tuyến tin nhắn mà server gửi đến đúng thiết bị . 381 | - **keys**: dùng để xác thực và giải mã tin nhắn 382 | 383 | Vậy còn khi server push thì phía browser sẽ nghe ở đâu ? . Đó chính là chúng ta phải ghi đè vào file **service-worker.js** . Thực ra file `serviceWorker.js` mà create-react-app tạo sẵn ko phải là service-worker.js mà browser chạy ngầm mà sau khi build xong file đấy mới thực sự xuất hiện . 384 | 385 | 386 | 387 | 388 | ![](https://images.viblo.asia/ef302fee-8d56-4436-a33f-46ba292d4bea.png) 389 | 390 | Vì thế chúng ta phải code thêm file `sw.js` cùng cấp vs file `serviceWorker.js` 391 | ```js 392 | function receivePushNotification(event) { 393 | console.log('[Service Worker] Push Received.'); 394 | 395 | const { image, tag, url, title, text } = event.data.json(); 396 | 397 | const options = { 398 | data: url, 399 | body: text, 400 | icon: image, 401 | vibrate: [200, 100, 200], 402 | tag: tag, 403 | image: image, 404 | badge: 'https://spyna.it/icons/favicon.ico', 405 | actions: [{ action: 'Detail', title: 'View', icon: 'https://via.placeholder.com/128/ff0000' }] 406 | }; 407 | event.waitUntil(self.registration.showNotification(title, options)); 408 | } 409 | 410 | function openPushNotification(event) { 411 | console.log('[Service Worker] Notification click Received.', event.notification.data); 412 | 413 | event.notification.close(); 414 | event.waitUntil(clients.openWindow(event.notification.data)); 415 | } 416 | 417 | self.addEventListener('push', receivePushNotification); 418 | self.addEventListener('notificationclick', openPushNotification); 419 | ``` 420 | 421 | Mục đích là để lắng nghe **event** và handle việc ng dùng **click vào notification** đấy. Sau đó sửa lại một chút phần file `package.js` phần build và thêm lệnh sw ở dưới để nối file sw vào file `service-worker.js` . 422 | ```json 423 | "build": "rm -rf build/ && react-scripts build && npm run-script sw", 424 | "sw": "cat src/sw.js >> build/service-worker.js", 425 | ``` 426 | 427 | Cuối cùng là phần giao diện .Mình sẽ tận dụng luôn file `App.js` làm demo 428 | ```js 429 | import React from 'react'; 430 | import logo from './logo.svg'; 431 | import usePushNotifications from './usePushNotifications'; 432 | import './App.css'; 433 | 434 | function App() { 435 | const { 436 | userConsent, 437 | pushNotificationSupported, 438 | userSubscription, 439 | onClickAskUserPermission, 440 | onClickSusbribeToPushNotification, 441 | onClickSendSubscriptionToPushServer, 442 | pushServerSubscriptionId, 443 | onClickSendNotification, 444 | error, 445 | loading 446 | } = usePushNotifications(); 447 | 448 | const Loading = ({ loading }) => 449 | loading ?
Please wait, we are loading something...
: null; 450 | const Error = ({ error }) => 451 | error ? ( 452 |
453 |

{error.name}

454 |

Error message : {error.message}

455 |

Error code : {error.code}

456 |
457 | ) : null; 458 | 459 | const isConsentGranted = userConsent === 'granted'; 460 | 461 | return ( 462 |
463 |
464 | logo 465 | 466 | 467 |

Push notification are {!pushNotificationSupported && 'NOT'} supported by your device.

468 | 469 |

470 | User consent to recevie push notificaitons is {userConsent}. 471 |

472 | 473 | 474 | 475 | 480 | 481 | 486 | 487 | 494 | 495 | {pushServerSubscriptionId && ( 496 |
497 |

The server accepted the push subscrption!

498 | 499 |
500 | )} 501 |
502 |
503 | ); 504 | } 505 | 506 | export default App; 507 | ``` 508 | 509 | nhớ cuối cùng là chạy lệnh : 510 | ```sh 511 | yarn build 512 | yarn serve -s build 513 | ``` 514 | 515 | Demo : sau khi nhấn 3 nút từ trên xuống dưới và nhấn nút cuối cùng để kích hoạt bắn notification về browser 516 | 517 | ![](https://images.viblo.asia/d5843b85-a213-420f-af89-8c7e4a55e29e.png) 518 | 519 | 520 | Link github : https://github.com/vinhyenvodoi98/Push_Notification_Nodejs_Reactjs 521 | 522 | Reference : 523 | - https://developers.google.com/web/ilt/pwa/introduction-to-push-notifications 524 | - https://itnext.io/react-push-notifications-with-hooks-d293d36f4836 525 | - https://itnext.io/an-introduction-to-web-push-notifications-a701783917ce 526 | - https://stackoverflow.com/questions/57185722/how-to-add-event-listeners-to-create-react-app-default-sw-js-file/57185956 527 | -------------------------------------------------------------------------------- /backend/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | accepts@~1.3.7: 6 | version "1.3.7" 7 | resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.7.tgz#531bc726517a3b2b41f850021c6cc15eaab507cd" 8 | integrity sha512-Il80Qs2WjYlJIBNzNkK6KYqlVMTbZLXgHx2oT0pU/fjRHyEp+PEfEPY0R3WCwAGVOtauxh1hOxNgIf5bv7dQpA== 9 | dependencies: 10 | mime-types "~2.1.24" 11 | negotiator "0.6.2" 12 | 13 | agent-base@^4.3.0: 14 | version "4.3.0" 15 | resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-4.3.0.tgz#8165f01c436009bccad0b1d122f05ed770efc6ee" 16 | integrity sha512-salcGninV0nPrwpGNn4VTXBb1SOuXQBiqbrNXoeizJsHrsL6ERFM2Ne3JUSBWRE6aeNJI2ROP/WEEIDUiDe3cg== 17 | dependencies: 18 | es6-promisify "^5.0.0" 19 | 20 | array-flatten@1.1.1: 21 | version "1.1.1" 22 | resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" 23 | integrity sha1-ml9pkFGx5wczKPKgCJaLZOopVdI= 24 | 25 | asn1.js@^5.0.0: 26 | version "5.3.0" 27 | resolved "https://registry.yarnpkg.com/asn1.js/-/asn1.js-5.3.0.tgz#439099fe9174e09cff5a54a9dda70260517e8689" 28 | integrity sha512-WHnQJFcOrIWT1RLOkFFBQkFVvyt9BPOOrH+Dp152Zk4R993rSzXUGPmkybIcUFhHE2d/iHH+nCaOWVCDbO8fgA== 29 | dependencies: 30 | bn.js "^4.0.0" 31 | inherits "^2.0.1" 32 | minimalistic-assert "^1.0.0" 33 | safer-buffer "^2.1.0" 34 | 35 | basic-auth@~2.0.1: 36 | version "2.0.1" 37 | resolved "https://registry.yarnpkg.com/basic-auth/-/basic-auth-2.0.1.tgz#b998279bf47ce38344b4f3cf916d4679bbf51e3a" 38 | integrity sha512-NF+epuEdnUYVlGuhaxbbq+dvJttwLnGY+YixlXlME5KpQ5W3CnXA5cVTneY3SPbPDRkcjMbifrwmFYcClgOZeg== 39 | dependencies: 40 | safe-buffer "5.1.2" 41 | 42 | bn.js@^4.0.0: 43 | version "4.11.8" 44 | resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.11.8.tgz#2cde09eb5ee341f484746bb0309b3253b1b1442f" 45 | integrity sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA== 46 | 47 | body-parser@1.19.0, body-parser@^1.19.0: 48 | version "1.19.0" 49 | resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.19.0.tgz#96b2709e57c9c4e09a6fd66a8fd979844f69f08a" 50 | integrity sha512-dhEPs72UPbDnAQJ9ZKMNTP6ptJaionhP5cBb541nXPlW60Jepo9RV/a4fX4XWW9CuFNK22krhrj1+rgzifNCsw== 51 | dependencies: 52 | bytes "3.1.0" 53 | content-type "~1.0.4" 54 | debug "2.6.9" 55 | depd "~1.1.2" 56 | http-errors "1.7.2" 57 | iconv-lite "0.4.24" 58 | on-finished "~2.3.0" 59 | qs "6.7.0" 60 | raw-body "2.4.0" 61 | type-is "~1.6.17" 62 | 63 | buffer-equal-constant-time@1.0.1: 64 | version "1.0.1" 65 | resolved "https://registry.yarnpkg.com/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz#f8e71132f7ffe6e01a5c9697a4c6f3e48d5cc819" 66 | integrity sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk= 67 | 68 | bytes@3.1.0: 69 | version "3.1.0" 70 | resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.0.tgz#f6cf7933a360e0588fa9fde85651cdc7f805d1f6" 71 | integrity sha512-zauLjrfCG+xvoyaqLoV8bLVXXNGC4JqlxFCutSDWA6fJrTo2ZuvLYTqZ7aHBLZSMOopbzwv8f+wZcVzfVTI2Dg== 72 | 73 | content-disposition@0.5.3: 74 | version "0.5.3" 75 | resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.3.tgz#e130caf7e7279087c5616c2007d0485698984fbd" 76 | integrity sha512-ExO0774ikEObIAEV9kDo50o+79VCUdEB6n6lzKgGwupcVeRlhrj3qGAfwq8G6uBJjkqLrhT0qEYFcWng8z1z0g== 77 | dependencies: 78 | safe-buffer "5.1.2" 79 | 80 | content-type@~1.0.4: 81 | version "1.0.4" 82 | resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" 83 | integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== 84 | 85 | cookie-signature@1.0.6: 86 | version "1.0.6" 87 | resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" 88 | integrity sha1-4wOogrNCzD7oylE6eZmXNNqzriw= 89 | 90 | cookie@0.4.0: 91 | version "0.4.0" 92 | resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.4.0.tgz#beb437e7022b3b6d49019d088665303ebe9c14ba" 93 | integrity sha512-+Hp8fLp57wnUSt0tY0tHEXh4voZRDnoIrZPqlo3DPiI4y9lwg/jqx+1Om94/W6ZaPDOUbnjOt/99w66zk+l1Xg== 94 | 95 | cors@^2.8.5: 96 | version "2.8.5" 97 | resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.5.tgz#eac11da51592dd86b9f06f6e7ac293b3df875d29" 98 | integrity sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g== 99 | dependencies: 100 | object-assign "^4" 101 | vary "^1" 102 | 103 | crypto@^1.0.1: 104 | version "1.0.1" 105 | resolved "https://registry.yarnpkg.com/crypto/-/crypto-1.0.1.tgz#2af1b7cad8175d24c8a1b0778255794a21803037" 106 | integrity sha512-VxBKmeNcqQdiUQUW2Tzq0t377b54N2bMtXO/qiLa+6eRRmmC4qT3D4OnTGoT/U6O9aklQ/jTwbOtRMTTY8G0Ig== 107 | 108 | debug@2.6.9: 109 | version "2.6.9" 110 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" 111 | integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== 112 | dependencies: 113 | ms "2.0.0" 114 | 115 | debug@^3.1.0: 116 | version "3.2.6" 117 | resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" 118 | integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== 119 | dependencies: 120 | ms "^2.1.1" 121 | 122 | depd@~1.1.2: 123 | version "1.1.2" 124 | resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" 125 | integrity sha1-m81S4UwJd2PnSbJ0xDRu0uVgtak= 126 | 127 | depd@~2.0.0: 128 | version "2.0.0" 129 | resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" 130 | integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== 131 | 132 | destroy@~1.0.4: 133 | version "1.0.4" 134 | resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" 135 | integrity sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA= 136 | 137 | ecdsa-sig-formatter@1.0.11: 138 | version "1.0.11" 139 | resolved "https://registry.yarnpkg.com/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz#ae0f0fa2d85045ef14a817daa3ce9acd0489e5bf" 140 | integrity sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ== 141 | dependencies: 142 | safe-buffer "^5.0.1" 143 | 144 | ee-first@1.1.1: 145 | version "1.1.1" 146 | resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" 147 | integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= 148 | 149 | encodeurl@~1.0.2: 150 | version "1.0.2" 151 | resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" 152 | integrity sha1-rT/0yG7C0CkyL1oCw6mmBslbP1k= 153 | 154 | es6-promise@^4.0.3: 155 | version "4.2.8" 156 | resolved "https://registry.yarnpkg.com/es6-promise/-/es6-promise-4.2.8.tgz#4eb21594c972bc40553d276e510539143db53e0a" 157 | integrity sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w== 158 | 159 | es6-promisify@^5.0.0: 160 | version "5.0.0" 161 | resolved "https://registry.yarnpkg.com/es6-promisify/-/es6-promisify-5.0.0.tgz#5109d62f3e56ea967c4b63505aef08291c8a5203" 162 | integrity sha1-UQnWLz5W6pZ8S2NQWu8IKRyKUgM= 163 | dependencies: 164 | es6-promise "^4.0.3" 165 | 166 | escape-html@~1.0.3: 167 | version "1.0.3" 168 | resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" 169 | integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= 170 | 171 | etag@~1.8.1: 172 | version "1.8.1" 173 | resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" 174 | integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc= 175 | 176 | express@^4.17.1: 177 | version "4.17.1" 178 | resolved "https://registry.yarnpkg.com/express/-/express-4.17.1.tgz#4491fc38605cf51f8629d39c2b5d026f98a4c134" 179 | integrity sha512-mHJ9O79RqluphRrcw2X/GTh3k9tVv8YcoyY4Kkh4WDMUYKRZUq0h1o0w2rrrxBqM7VoeUVqgb27xlEMXTnYt4g== 180 | dependencies: 181 | accepts "~1.3.7" 182 | array-flatten "1.1.1" 183 | body-parser "1.19.0" 184 | content-disposition "0.5.3" 185 | content-type "~1.0.4" 186 | cookie "0.4.0" 187 | cookie-signature "1.0.6" 188 | debug "2.6.9" 189 | depd "~1.1.2" 190 | encodeurl "~1.0.2" 191 | escape-html "~1.0.3" 192 | etag "~1.8.1" 193 | finalhandler "~1.1.2" 194 | fresh "0.5.2" 195 | merge-descriptors "1.0.1" 196 | methods "~1.1.2" 197 | on-finished "~2.3.0" 198 | parseurl "~1.3.3" 199 | path-to-regexp "0.1.7" 200 | proxy-addr "~2.0.5" 201 | qs "6.7.0" 202 | range-parser "~1.2.1" 203 | safe-buffer "5.1.2" 204 | send "0.17.1" 205 | serve-static "1.14.1" 206 | setprototypeof "1.1.1" 207 | statuses "~1.5.0" 208 | type-is "~1.6.18" 209 | utils-merge "1.0.1" 210 | vary "~1.1.2" 211 | 212 | finalhandler@~1.1.2: 213 | version "1.1.2" 214 | resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.1.2.tgz#b7e7d000ffd11938d0fdb053506f6ebabe9f587d" 215 | integrity sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA== 216 | dependencies: 217 | debug "2.6.9" 218 | encodeurl "~1.0.2" 219 | escape-html "~1.0.3" 220 | on-finished "~2.3.0" 221 | parseurl "~1.3.3" 222 | statuses "~1.5.0" 223 | unpipe "~1.0.0" 224 | 225 | forwarded@~0.1.2: 226 | version "0.1.2" 227 | resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.1.2.tgz#98c23dab1175657b8c0573e8ceccd91b0ff18c84" 228 | integrity sha1-mMI9qxF1ZXuMBXPozszZGw/xjIQ= 229 | 230 | fresh@0.5.2: 231 | version "0.5.2" 232 | resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" 233 | integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= 234 | 235 | http-errors@1.7.2: 236 | version "1.7.2" 237 | resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.2.tgz#4f5029cf13239f31036e5b2e55292bcfbcc85c8f" 238 | integrity sha512-uUQBt3H/cSIVfch6i1EuPNy/YsRSOUBXTVfZ+yR7Zjez3qjBz6i9+i4zjNaoqcoFVI4lQJ5plg63TvGfRSDCRg== 239 | dependencies: 240 | depd "~1.1.2" 241 | inherits "2.0.3" 242 | setprototypeof "1.1.1" 243 | statuses ">= 1.5.0 < 2" 244 | toidentifier "1.0.0" 245 | 246 | http-errors@~1.7.2: 247 | version "1.7.3" 248 | resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" 249 | integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== 250 | dependencies: 251 | depd "~1.1.2" 252 | inherits "2.0.4" 253 | setprototypeof "1.1.1" 254 | statuses ">= 1.5.0 < 2" 255 | toidentifier "1.0.0" 256 | 257 | http_ece@1.1.0: 258 | version "1.1.0" 259 | resolved "https://registry.yarnpkg.com/http_ece/-/http_ece-1.1.0.tgz#74780c6eb32d8ddfe9e36a83abcd81fe0cd4fb75" 260 | integrity sha512-bptAfCDdPJxOs5zYSe7Y3lpr772s1G346R4Td5LgRUeCwIGpCGDUTJxRrhTNcAXbx37spge0kWEIH7QAYWNTlA== 261 | dependencies: 262 | urlsafe-base64 "~1.0.0" 263 | 264 | https-proxy-agent@^3.0.0: 265 | version "3.0.1" 266 | resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-3.0.1.tgz#b8c286433e87602311b01c8ea34413d856a4af81" 267 | integrity sha512-+ML2Rbh6DAuee7d07tYGEKOEi2voWPUGan+ExdPbPW6Z3svq+JCqr0v8WmKPOkz1vOVykPCBSuobe7G8GJUtVg== 268 | dependencies: 269 | agent-base "^4.3.0" 270 | debug "^3.1.0" 271 | 272 | iconv-lite@0.4.24: 273 | version "0.4.24" 274 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" 275 | integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== 276 | dependencies: 277 | safer-buffer ">= 2.1.2 < 3" 278 | 279 | inherits@2.0.3: 280 | version "2.0.3" 281 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" 282 | integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= 283 | 284 | inherits@2.0.4, inherits@^2.0.1: 285 | version "2.0.4" 286 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 287 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 288 | 289 | ipaddr.js@1.9.1: 290 | version "1.9.1" 291 | resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" 292 | integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== 293 | 294 | jwa@^1.4.1: 295 | version "1.4.1" 296 | resolved "https://registry.yarnpkg.com/jwa/-/jwa-1.4.1.tgz#743c32985cb9e98655530d53641b66c8645b039a" 297 | integrity sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA== 298 | dependencies: 299 | buffer-equal-constant-time "1.0.1" 300 | ecdsa-sig-formatter "1.0.11" 301 | safe-buffer "^5.0.1" 302 | 303 | jws@^3.1.3: 304 | version "3.2.2" 305 | resolved "https://registry.yarnpkg.com/jws/-/jws-3.2.2.tgz#001099f3639468c9414000e99995fa52fb478304" 306 | integrity sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA== 307 | dependencies: 308 | jwa "^1.4.1" 309 | safe-buffer "^5.0.1" 310 | 311 | media-typer@0.3.0: 312 | version "0.3.0" 313 | resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" 314 | integrity sha1-hxDXrwqmJvj/+hzgAWhUUmMlV0g= 315 | 316 | merge-descriptors@1.0.1: 317 | version "1.0.1" 318 | resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" 319 | integrity sha1-sAqqVW3YtEVoFQ7J0blT8/kMu2E= 320 | 321 | methods@~1.1.2: 322 | version "1.1.2" 323 | resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" 324 | integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4= 325 | 326 | mime-db@1.43.0: 327 | version "1.43.0" 328 | resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.43.0.tgz#0a12e0502650e473d735535050e7c8f4eb4fae58" 329 | integrity sha512-+5dsGEEovYbT8UY9yD7eE4XTc4UwJ1jBYlgaQQF38ENsKR3wj/8q8RFZrF9WIZpB2V1ArTVFUva8sAul1NzRzQ== 330 | 331 | mime-types@~2.1.24: 332 | version "2.1.26" 333 | resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.26.tgz#9c921fc09b7e149a65dfdc0da4d20997200b0a06" 334 | integrity sha512-01paPWYgLrkqAyrlDorC1uDwl2p3qZT7yl806vW7DvDoxwXi46jsjFbg+WdwotBIk6/MbEhO/dh5aZ5sNj/dWQ== 335 | dependencies: 336 | mime-db "1.43.0" 337 | 338 | mime@1.6.0: 339 | version "1.6.0" 340 | resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 341 | integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 342 | 343 | minimalistic-assert@^1.0.0: 344 | version "1.0.1" 345 | resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" 346 | integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== 347 | 348 | minimist@^1.2.0: 349 | version "1.2.5" 350 | resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.5.tgz#67d66014b66a6a8aaa0c083c5fd58df4e4e97602" 351 | integrity sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw== 352 | 353 | morgan@^1.10.0: 354 | version "1.10.0" 355 | resolved "https://registry.yarnpkg.com/morgan/-/morgan-1.10.0.tgz#091778abc1fc47cd3509824653dae1faab6b17d7" 356 | integrity sha512-AbegBVI4sh6El+1gNwvD5YIck7nSA36weD7xvIxG4in80j/UoK8AEGaWnnz8v1GxonMCltmlNs5ZKbGvl9b1XQ== 357 | dependencies: 358 | basic-auth "~2.0.1" 359 | debug "2.6.9" 360 | depd "~2.0.0" 361 | on-finished "~2.3.0" 362 | on-headers "~1.0.2" 363 | 364 | ms@2.0.0: 365 | version "2.0.0" 366 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 367 | integrity sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g= 368 | 369 | ms@2.1.1: 370 | version "2.1.1" 371 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" 372 | integrity sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg== 373 | 374 | ms@^2.1.1: 375 | version "2.1.2" 376 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" 377 | integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 378 | 379 | negotiator@0.6.2: 380 | version "0.6.2" 381 | resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" 382 | integrity sha512-hZXc7K2e+PgeI1eDBe/10Ard4ekbfrrqG8Ep+8Jmf4JID2bNg7NvCPOZN+kfF574pFQI7mum2AUqDidoKqcTOw== 383 | 384 | object-assign@^4: 385 | version "4.1.1" 386 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 387 | integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM= 388 | 389 | on-finished@~2.3.0: 390 | version "2.3.0" 391 | resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" 392 | integrity sha1-IPEzZIGwg811M3mSoWlxqi2QaUc= 393 | dependencies: 394 | ee-first "1.1.1" 395 | 396 | on-headers@~1.0.2: 397 | version "1.0.2" 398 | resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" 399 | integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== 400 | 401 | parseurl@~1.3.3: 402 | version "1.3.3" 403 | resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" 404 | integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== 405 | 406 | path-to-regexp@0.1.7: 407 | version "0.1.7" 408 | resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" 409 | integrity sha1-32BBeABfUi8V60SQ5yR6G/qmf4w= 410 | 411 | proxy-addr@~2.0.5: 412 | version "2.0.6" 413 | resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.6.tgz#fdc2336505447d3f2f2c638ed272caf614bbb2bf" 414 | integrity sha512-dh/frvCBVmSsDYzw6n926jv974gddhkFPfiN8hPOi30Wax25QZyZEGveluCgliBnqmuM+UJmBErbAUFIoDbjOw== 415 | dependencies: 416 | forwarded "~0.1.2" 417 | ipaddr.js "1.9.1" 418 | 419 | qs@6.7.0: 420 | version "6.7.0" 421 | resolved "https://registry.yarnpkg.com/qs/-/qs-6.7.0.tgz#41dc1a015e3d581f1621776be31afb2876a9b1bc" 422 | integrity sha512-VCdBRNFTX1fyE7Nb6FYoURo/SPe62QCaAyzJvUjwRaIsc+NePBEniHlvxFmmX56+HZphIGtV0XeCirBtpDrTyQ== 423 | 424 | range-parser@~1.2.1: 425 | version "1.2.1" 426 | resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" 427 | integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== 428 | 429 | raw-body@2.4.0: 430 | version "2.4.0" 431 | resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.0.tgz#a1ce6fb9c9bc356ca52e89256ab59059e13d0332" 432 | integrity sha512-4Oz8DUIwdvoa5qMJelxipzi/iJIi40O5cGV1wNYp5hvZP8ZN0T+jiNkL0QepXs+EsQ9XJ8ipEDoiH70ySUJP3Q== 433 | dependencies: 434 | bytes "3.1.0" 435 | http-errors "1.7.2" 436 | iconv-lite "0.4.24" 437 | unpipe "1.0.0" 438 | 439 | safe-buffer@5.1.2: 440 | version "5.1.2" 441 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" 442 | integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== 443 | 444 | safe-buffer@^5.0.1: 445 | version "5.2.0" 446 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.0.tgz#b74daec49b1148f88c64b68d49b1e815c1f2f519" 447 | integrity sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg== 448 | 449 | "safer-buffer@>= 2.1.2 < 3", safer-buffer@^2.1.0: 450 | version "2.1.2" 451 | resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" 452 | integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 453 | 454 | send@0.17.1: 455 | version "0.17.1" 456 | resolved "https://registry.yarnpkg.com/send/-/send-0.17.1.tgz#c1d8b059f7900f7466dd4938bdc44e11ddb376c8" 457 | integrity sha512-BsVKsiGcQMFwT8UxypobUKyv7irCNRHk1T0G680vk88yf6LBByGcZJOTJCrTP2xVN6yI+XjPJcNuE3V4fT9sAg== 458 | dependencies: 459 | debug "2.6.9" 460 | depd "~1.1.2" 461 | destroy "~1.0.4" 462 | encodeurl "~1.0.2" 463 | escape-html "~1.0.3" 464 | etag "~1.8.1" 465 | fresh "0.5.2" 466 | http-errors "~1.7.2" 467 | mime "1.6.0" 468 | ms "2.1.1" 469 | on-finished "~2.3.0" 470 | range-parser "~1.2.1" 471 | statuses "~1.5.0" 472 | 473 | serve-static@1.14.1: 474 | version "1.14.1" 475 | resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.14.1.tgz#666e636dc4f010f7ef29970a88a674320898b2f9" 476 | integrity sha512-JMrvUwE54emCYWlTI+hGrGv5I8dEwmco/00EvkzIIsR7MqrHonbD9pO2MOfFnpFntl7ecpZs+3mW+XbQZu9QCg== 477 | dependencies: 478 | encodeurl "~1.0.2" 479 | escape-html "~1.0.3" 480 | parseurl "~1.3.3" 481 | send "0.17.1" 482 | 483 | setprototypeof@1.1.1: 484 | version "1.1.1" 485 | resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.1.1.tgz#7e95acb24aa92f5885e0abef5ba131330d4ae683" 486 | integrity sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw== 487 | 488 | "statuses@>= 1.5.0 < 2", statuses@~1.5.0: 489 | version "1.5.0" 490 | resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" 491 | integrity sha1-Fhx9rBd2Wf2YEfQ3cfqZOBR4Yow= 492 | 493 | toidentifier@1.0.0: 494 | version "1.0.0" 495 | resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.0.tgz#7e1be3470f1e77948bc43d94a3c8f4d7752ba553" 496 | integrity sha512-yaOH/Pk/VEhBWWTlhI+qXxDFXlejDGcQipMlyxda9nthulaxLZUNcUqFxokp0vcYnvteJln5FNQDRrxj3YcbVw== 497 | 498 | type-is@~1.6.17, type-is@~1.6.18: 499 | version "1.6.18" 500 | resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" 501 | integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== 502 | dependencies: 503 | media-typer "0.3.0" 504 | mime-types "~2.1.24" 505 | 506 | unpipe@1.0.0, unpipe@~1.0.0: 507 | version "1.0.0" 508 | resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" 509 | integrity sha1-sr9O6FFKrmFltIF4KdIbLvSZBOw= 510 | 511 | urlsafe-base64@^1.0.0, urlsafe-base64@~1.0.0: 512 | version "1.0.0" 513 | resolved "https://registry.yarnpkg.com/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz#23f89069a6c62f46cf3a1d3b00169cefb90be0c6" 514 | integrity sha1-I/iQaabGL0bPOh07ABac77kL4MY= 515 | 516 | utils-merge@1.0.1: 517 | version "1.0.1" 518 | resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" 519 | integrity sha1-n5VxD1CiZ5R7LMwSR0HBAoQn5xM= 520 | 521 | vary@^1, vary@~1.1.2: 522 | version "1.1.2" 523 | resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" 524 | integrity sha1-IpnwLG3tMNSllhsLn3RSShj2NPw= 525 | 526 | web-push@^3.3.5: 527 | version "3.4.3" 528 | resolved "https://registry.yarnpkg.com/web-push/-/web-push-3.4.3.tgz#88bdf8a8079b24efbc569da7d1f2c3556126f407" 529 | integrity sha512-nt/hRSlfRDTwvem//7jle1+cy62lBoxFshad8ai2Q4SlHZS00oHnrw5Dul3jSWXR+bOcnZkwnRs3tW+daNTuyA== 530 | dependencies: 531 | asn1.js "^5.0.0" 532 | http_ece "1.1.0" 533 | https-proxy-agent "^3.0.0" 534 | jws "^3.1.3" 535 | minimist "^1.2.0" 536 | urlsafe-base64 "^1.0.0" 537 | --------------------------------------------------------------------------------