mirror of
https://github.com/imjasonh/webpush
synced 2026-07-06 23:52:25 +00:00
Use go:embed for static content and simplify README to reference example
Co-authored-by: imjasonh <210737+imjasonh@users.noreply.github.com>
This commit is contained in:
parent
41f2c224ec
commit
191144839a
4 changed files with 265 additions and 631 deletions
425
README.md
425
README.md
|
|
@ -22,36 +22,22 @@ go get github.com/imjasonh/webpush
|
|||
|
||||
## Quick Start
|
||||
|
||||
### 1. Generate VAPID Keys
|
||||
See the [example/](example/) directory for a complete working demo with:
|
||||
- VAPID key generation and storage on disk
|
||||
- SQLite subscription storage
|
||||
- Web client with service worker for push notifications
|
||||
- Automatic periodic notifications and manual `/ping` endpoint
|
||||
|
||||
```go
|
||||
package main
|
||||
To run the example:
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/imjasonh/webpush/keys"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Generate a new key pair and get base64-encoded keys
|
||||
privateKey, publicKey, err := keys.GenerateKeyPair()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
fmt.Println("Private Key:", privateKey)
|
||||
fmt.Println("Public Key:", publicKey)
|
||||
|
||||
// Or generate and save to a PEM file
|
||||
signer, err := keys.GenerateKey("vapid-private.pem")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
fmt.Println("Public Key:", signer.PublicKeyBase64())
|
||||
}
|
||||
```bash
|
||||
cd example
|
||||
go run main.go
|
||||
```
|
||||
|
||||
### 2. Send Push Notifications
|
||||
Then open http://localhost:8080 in your browser.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
```go
|
||||
package main
|
||||
|
|
@ -63,9 +49,8 @@ import (
|
|||
)
|
||||
|
||||
func main() {
|
||||
// Load VAPID keys
|
||||
// Load or generate VAPID keys
|
||||
signer, err := keys.NewFileSigner("vapid-private.pem")
|
||||
// Or from base64: signer, err := keys.NewFileSignerFromBase64("your-base64-private-key")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
|
@ -73,7 +58,7 @@ func main() {
|
|||
// Create client with VAPID subject (must be mailto: or https: URL)
|
||||
client := webpush.NewClient(signer, "mailto:admin@example.com")
|
||||
|
||||
// Parse subscription from client
|
||||
// Parse subscription from client (received from browser)
|
||||
sub, err := webpush.ParseSubscription([]byte(`{
|
||||
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
|
||||
"keys": {
|
||||
|
|
@ -87,9 +72,8 @@ func main() {
|
|||
|
||||
// Send notification
|
||||
err = client.Send(context.Background(), sub, []byte(`{"title":"Hello","body":"World"}`), &webpush.Options{
|
||||
TTL: 3600, // Time-to-live in seconds
|
||||
Urgency: "high", // very-low, low, normal, high
|
||||
Topic: "updates", // For message collapsing
|
||||
TTL: 3600,
|
||||
Urgency: "high",
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
|
@ -97,97 +81,24 @@ func main() {
|
|||
}
|
||||
```
|
||||
|
||||
### 3. Store Subscriptions
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/google/uuid"
|
||||
"github.com/imjasonh/webpush"
|
||||
"github.com/imjasonh/webpush/storage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Use SQLite for production
|
||||
store, err := storage.NewSQLite("subscriptions.db")
|
||||
// Or use in-memory for testing: store := storage.NewMemory()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Save a subscription
|
||||
sub := &webpush.Subscription{
|
||||
Endpoint: "https://fcm.googleapis.com/fcm/send/...",
|
||||
Keys: webpush.Keys{
|
||||
P256dh: "...",
|
||||
Auth: "...",
|
||||
},
|
||||
}
|
||||
|
||||
record := &storage.Record{
|
||||
ID: uuid.New().String(),
|
||||
UserID: "user-123", // Optional: associate with your user
|
||||
Subscription: sub,
|
||||
}
|
||||
|
||||
err = store.Save(context.Background(), record)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Get subscriptions for a user
|
||||
records, err := store.GetByUserID(context.Background(), "user-123")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// Send to all user's devices
|
||||
for _, r := range records {
|
||||
// client.Send(ctx, r.Subscription, payload, opts)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Using Google Cloud KMS
|
||||
|
||||
For production environments, you can store your VAPID private key in Google Cloud KMS:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"github.com/imjasonh/webpush"
|
||||
"github.com/imjasonh/webpush/keys"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Key name format: projects/{project}/locations/{location}/keyRings/{keyRing}/cryptoKeys/{key}/cryptoKeyVersions/{version}
|
||||
keyName := "projects/my-project/locations/global/keyRings/webpush/cryptoKeys/vapid/cryptoKeyVersions/1"
|
||||
|
||||
signer, err := keys.NewKMSSigner(ctx, keyName)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer signer.Close()
|
||||
|
||||
client := webpush.NewClient(signer, "mailto:admin@example.com")
|
||||
// Use client as normal...
|
||||
signer, err := keys.NewKMSSigner(ctx, "projects/my-project/locations/global/keyRings/webpush/cryptoKeys/vapid/cryptoKeyVersions/1")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer signer.Close()
|
||||
|
||||
client := webpush.NewClient(signer, "mailto:admin@example.com")
|
||||
```
|
||||
|
||||
### Creating a KMS Key
|
||||
Create a KMS key:
|
||||
|
||||
```bash
|
||||
# Create a key ring
|
||||
gcloud kms keyrings create webpush --location=global
|
||||
|
||||
# Create an EC P-256 signing key
|
||||
gcloud kms keys create vapid \
|
||||
--keyring=webpush \
|
||||
--location=global \
|
||||
|
|
@ -195,287 +106,23 @@ gcloud kms keys create vapid \
|
|||
--default-algorithm=ec-sign-p256-sha256
|
||||
```
|
||||
|
||||
## JavaScript Client Integration
|
||||
|
||||
### Service Worker Setup
|
||||
|
||||
1. Create a service worker file (`sw.js`):
|
||||
|
||||
```javascript
|
||||
self.addEventListener('push', function(event) {
|
||||
const data = event.data ? event.data.json() : {};
|
||||
const title = data.title || 'Notification';
|
||||
const options = {
|
||||
body: data.body || '',
|
||||
icon: data.icon || '/icon.png',
|
||||
badge: data.badge || '/badge.png',
|
||||
data: data.data || {}
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(title, options)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
event.notification.close();
|
||||
|
||||
// Handle notification click
|
||||
if (event.notification.data && event.notification.data.url) {
|
||||
event.waitUntil(
|
||||
clients.openWindow(event.notification.data.url)
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
2. Register the service worker and subscribe to push:
|
||||
|
||||
```javascript
|
||||
// Your VAPID public key from the server (base64 URL-safe encoded)
|
||||
const VAPID_PUBLIC_KEY = 'YOUR_PUBLIC_KEY_HERE';
|
||||
|
||||
// Convert base64 to Uint8Array
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
async function subscribeToNotifications() {
|
||||
// Register service worker
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
|
||||
// Wait for service worker to be ready
|
||||
await navigator.serviceWorker.ready;
|
||||
|
||||
// Subscribe to push notifications
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||
});
|
||||
|
||||
// Send subscription to your server
|
||||
await fetch('/api/subscribe', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(subscription)
|
||||
});
|
||||
|
||||
console.log('Subscribed to push notifications');
|
||||
}
|
||||
|
||||
// Request permission and subscribe
|
||||
async function enableNotifications() {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission === 'granted') {
|
||||
await subscribeToNotifications();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Create a simple server endpoint to receive subscriptions:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/imjasonh/webpush"
|
||||
"github.com/imjasonh/webpush/keys"
|
||||
"github.com/imjasonh/webpush/storage"
|
||||
)
|
||||
|
||||
var (
|
||||
store storage.Storage
|
||||
client *webpush.Client
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize storage
|
||||
var err error
|
||||
store, err = storage.NewSQLite("subscriptions.db")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
// Initialize VAPID signer
|
||||
signer, err := keys.NewFileSigner("vapid-private.pem")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
client = webpush.NewClient(signer, "mailto:admin@example.com")
|
||||
|
||||
// Serve static files
|
||||
http.Handle("/", http.FileServer(http.Dir("static")))
|
||||
|
||||
// API endpoints
|
||||
http.HandleFunc("/api/vapid-public-key", handleVAPIDPublicKey)
|
||||
http.HandleFunc("/api/subscribe", handleSubscribe)
|
||||
http.HandleFunc("/api/push", handlePush)
|
||||
|
||||
http.ListenAndServe(":8080", nil)
|
||||
}
|
||||
|
||||
func handleVAPIDPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||
// Return the public key for the JavaScript client
|
||||
signer, _ := keys.NewFileSigner("vapid-private.pem")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"publicKey": signer.PublicKeyBase64(),
|
||||
})
|
||||
}
|
||||
|
||||
func handleSubscribe(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var sub webpush.Subscription
|
||||
if err := json.NewDecoder(r.Body).Decode(&sub); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate subscription
|
||||
if sub.Endpoint == "" || sub.Keys.P256dh == "" || sub.Keys.Auth == "" {
|
||||
http.Error(w, "Invalid subscription", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if subscription already exists
|
||||
existing, err := store.GetByEndpoint(r.Context(), sub.Endpoint)
|
||||
if err == nil {
|
||||
// Already subscribed
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": existing.ID})
|
||||
return
|
||||
}
|
||||
|
||||
// Save new subscription
|
||||
record := &storage.Record{
|
||||
ID: uuid.New().String(),
|
||||
Subscription: &sub,
|
||||
}
|
||||
|
||||
if err := store.Save(r.Context(), record); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"id": record.ID})
|
||||
}
|
||||
|
||||
func handlePush(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
payload, _ := json.Marshal(req)
|
||||
|
||||
// Get all subscriptions
|
||||
records, err := store.List(r.Context(), 1000, 0)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Send to all subscriptions
|
||||
ctx := context.Background()
|
||||
var sent, failed int
|
||||
for _, record := range records {
|
||||
err := client.Send(ctx, record.Subscription, payload, nil)
|
||||
if err != nil {
|
||||
failed++
|
||||
// If subscription is gone (410), delete it
|
||||
// if strings.Contains(err.Error(), "410") {
|
||||
// store.Delete(ctx, record.ID)
|
||||
// }
|
||||
} else {
|
||||
sent++
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]int{
|
||||
"sent": sent,
|
||||
"failed": failed,
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### Types
|
||||
|
||||
#### `Subscription`
|
||||
|
||||
```go
|
||||
type Subscription struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Keys Keys `json:"keys"`
|
||||
}
|
||||
|
||||
type Keys struct {
|
||||
P256dh string `json:"p256dh"`
|
||||
Auth string `json:"auth"`
|
||||
}
|
||||
```
|
||||
|
||||
#### `Options`
|
||||
|
||||
```go
|
||||
type Options struct {
|
||||
TTL int // Time-to-live in seconds (default: 2419200 = 4 weeks)
|
||||
Urgency string // Urgency: very-low, low, normal, high
|
||||
Urgency string // very-low, low, normal, high
|
||||
Topic string // Topic for message replacement
|
||||
}
|
||||
```
|
||||
|
||||
### Client
|
||||
|
||||
```go
|
||||
// Create a new client
|
||||
client := webpush.NewClient(signer, subject)
|
||||
|
||||
// Optionally set custom HTTP client
|
||||
client.WithHTTPClient(httpClient)
|
||||
|
||||
// Send a notification
|
||||
err := client.Send(ctx, subscription, payload, options)
|
||||
|
||||
// Parse subscription from JSON
|
||||
sub, err := webpush.ParseSubscription(jsonBytes)
|
||||
```
|
||||
|
||||
### Key Providers
|
||||
|
||||
```go
|
||||
|
|
@ -487,7 +134,6 @@ signer, err := keys.NewFileSignerFromBase64("base64-private-key")
|
|||
|
||||
// Google Cloud KMS
|
||||
signer, err := keys.NewKMSSigner(ctx, "projects/.../cryptoKeyVersions/1")
|
||||
defer signer.Close()
|
||||
|
||||
// Generate new keys
|
||||
signer, err := keys.GenerateKey("path/to/save.pem")
|
||||
|
|
@ -502,19 +148,20 @@ store := storage.NewMemory()
|
|||
|
||||
// SQLite
|
||||
store, err := storage.NewSQLite("subscriptions.db")
|
||||
defer store.Close()
|
||||
|
||||
// Operations
|
||||
err := store.Save(ctx, record)
|
||||
record, err := store.Get(ctx, id)
|
||||
record, err := store.GetByEndpoint(ctx, endpoint)
|
||||
records, err := store.GetByUserID(ctx, userID)
|
||||
records, err := store.List(ctx, limit, offset)
|
||||
err := store.Delete(ctx, id)
|
||||
err := store.DeleteByEndpoint(ctx, endpoint)
|
||||
store.Save(ctx, record)
|
||||
store.Get(ctx, id)
|
||||
store.GetByEndpoint(ctx, endpoint)
|
||||
store.GetByUserID(ctx, userID)
|
||||
store.List(ctx, limit, offset)
|
||||
store.Delete(ctx, id)
|
||||
store.DeleteByEndpoint(ctx, endpoint)
|
||||
```
|
||||
|
||||
## Custom Storage Implementation
|
||||
## Custom Implementations
|
||||
|
||||
### Custom Storage
|
||||
|
||||
Implement the `storage.Storage` interface:
|
||||
|
||||
|
|
@ -531,7 +178,7 @@ type Storage interface {
|
|||
}
|
||||
```
|
||||
|
||||
## Custom Key Provider
|
||||
### Custom Key Provider
|
||||
|
||||
Implement the `webpush.Signer` interface:
|
||||
|
||||
|
|
@ -542,7 +189,7 @@ type Signer interface {
|
|||
}
|
||||
```
|
||||
|
||||
The `Sign` method receives SHA-256 hash and should return IEEE P1363 format signature (64 bytes for P-256).
|
||||
The `Sign` method receives a SHA-256 hash and should return an IEEE P1363 format signature (64 bytes for P-256).
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
252
example/main.go
252
example/main.go
|
|
@ -10,7 +10,9 @@ package main
|
|||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
|
|
@ -24,6 +26,9 @@ import (
|
|||
"github.com/imjasonh/webpush/storage"
|
||||
)
|
||||
|
||||
//go:embed static/*
|
||||
var staticFiles embed.FS
|
||||
|
||||
const (
|
||||
keyPath = "vapid-private.pem"
|
||||
dbPath = "subscriptions.db"
|
||||
|
|
@ -74,8 +79,11 @@ func main() {
|
|||
go periodicPush()
|
||||
|
||||
// Set up HTTP handlers
|
||||
http.HandleFunc("/", handleIndex)
|
||||
http.HandleFunc("/sw.js", handleServiceWorker)
|
||||
staticFS, err := fs.Sub(staticFiles, "static")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to create static file system: %v", err)
|
||||
}
|
||||
http.Handle("/", http.FileServer(http.FS(staticFS)))
|
||||
http.HandleFunc("/api/vapid-public-key", handleVAPIDPublicKey)
|
||||
http.HandleFunc("/api/subscribe", handleSubscribe)
|
||||
http.HandleFunc("/api/unsubscribe", handleUnsubscribe)
|
||||
|
|
@ -153,20 +161,6 @@ func isGone(err error) bool {
|
|||
|
||||
// HTTP Handlers
|
||||
|
||||
func handleIndex(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
w.Write([]byte(indexHTML))
|
||||
}
|
||||
|
||||
func handleServiceWorker(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/javascript")
|
||||
w.Write([]byte(serviceWorkerJS))
|
||||
}
|
||||
|
||||
func handleVAPIDPublicKey(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
|
|
@ -268,229 +262,3 @@ func handlePing(w http.ResponseWriter, r *http.Request) {
|
|||
"message": "Push notification queued",
|
||||
})
|
||||
}
|
||||
|
||||
// Static content
|
||||
|
||||
const indexHTML = `<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Web Push Demo</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 { color: #333; }
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover { background: #0056b3; }
|
||||
button:disabled { background: #ccc; cursor: not-allowed; }
|
||||
button.danger { background: #dc3545; }
|
||||
button.danger:hover { background: #c82333; }
|
||||
button.success { background: #28a745; }
|
||||
#status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.success { background: #d4edda; color: #155724; }
|
||||
.error { background: #f8d7da; color: #721c24; }
|
||||
.info { background: #cce5ff; color: #004085; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🔔 Web Push Demo</h1>
|
||||
<p>Subscribe to receive push notifications from this server.</p>
|
||||
<p>Notifications are sent:</p>
|
||||
<ul>
|
||||
<li>Every minute automatically</li>
|
||||
<li>When someone visits <a href="/ping">/ping</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Subscription</h2>
|
||||
<button id="subscribeBtn" onclick="subscribe()">Subscribe to Notifications</button>
|
||||
<button id="unsubscribeBtn" onclick="unsubscribe()" class="danger" disabled>Unsubscribe</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Test Push</h2>
|
||||
<button onclick="sendPing()" class="success">Send Ping to All Subscribers</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let vapidPublicKey = '';
|
||||
let currentSubscription = null;
|
||||
|
||||
// Convert base64 to Uint8Array
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function setStatus(message, type) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = message;
|
||||
status.className = type;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Get VAPID public key
|
||||
const resp = await fetch('/api/vapid-public-key');
|
||||
const data = await resp.json();
|
||||
vapidPublicKey = data.publicKey;
|
||||
|
||||
// Check if already subscribed
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
currentSubscription = subscription;
|
||||
document.getElementById('subscribeBtn').disabled = true;
|
||||
document.getElementById('unsubscribeBtn').disabled = false;
|
||||
setStatus('You are subscribed to notifications', 'success');
|
||||
}
|
||||
} else {
|
||||
setStatus('Push notifications not supported in this browser', 'error');
|
||||
document.getElementById('subscribeBtn').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe() {
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
setStatus('Notification permission denied', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
||||
});
|
||||
|
||||
// Send to server
|
||||
const resp = await fetch('/api/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription.toJSON())
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
currentSubscription = subscription;
|
||||
document.getElementById('subscribeBtn').disabled = true;
|
||||
document.getElementById('unsubscribeBtn').disabled = false;
|
||||
setStatus('Successfully subscribed!', 'success');
|
||||
} else {
|
||||
setStatus('Failed to subscribe: ' + await resp.text(), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe() {
|
||||
try {
|
||||
if (currentSubscription) {
|
||||
await fetch('/api/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: currentSubscription.endpoint })
|
||||
});
|
||||
await currentSubscription.unsubscribe();
|
||||
currentSubscription = null;
|
||||
}
|
||||
document.getElementById('subscribeBtn').disabled = false;
|
||||
document.getElementById('unsubscribeBtn').disabled = true;
|
||||
setStatus('Successfully unsubscribed', 'info');
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPing() {
|
||||
try {
|
||||
const resp = await fetch('/ping');
|
||||
if (resp.ok) {
|
||||
setStatus('Ping sent to all subscribers!', 'success');
|
||||
} else {
|
||||
setStatus('Failed to send ping', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`
|
||||
|
||||
const serviceWorkerJS = `
|
||||
self.addEventListener('push', function(event) {
|
||||
let data = { title: 'Notification', body: '' };
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch (e) {
|
||||
data.body = event.data.text();
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: data.body || '',
|
||||
icon: data.icon || '',
|
||||
badge: data.badge || '',
|
||||
data: data.data || {},
|
||||
requireInteraction: false
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Notification', options)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
event.notification.close();
|
||||
|
||||
if (event.notification.data && event.notification.data.url) {
|
||||
event.waitUntil(
|
||||
clients.openWindow(event.notification.data.url)
|
||||
);
|
||||
}
|
||||
});
|
||||
`
|
||||
|
|
|
|||
186
example/static/index.html
Normal file
186
example/static/index.html
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Web Push Demo</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
max-width: 600px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
h1 { color: #333; }
|
||||
button {
|
||||
background: #007bff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
margin: 5px;
|
||||
}
|
||||
button:hover { background: #0056b3; }
|
||||
button:disabled { background: #ccc; cursor: not-allowed; }
|
||||
button.danger { background: #dc3545; }
|
||||
button.danger:hover { background: #c82333; }
|
||||
button.success { background: #28a745; }
|
||||
#status {
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
.success { background: #d4edda; color: #155724; }
|
||||
.error { background: #f8d7da; color: #721c24; }
|
||||
.info { background: #cce5ff; color: #004085; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>🔔 Web Push Demo</h1>
|
||||
<p>Subscribe to receive push notifications from this server.</p>
|
||||
<p>Notifications are sent:</p>
|
||||
<ul>
|
||||
<li>Every minute automatically</li>
|
||||
<li>When someone visits <a href="/ping">/ping</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Subscription</h2>
|
||||
<button id="subscribeBtn" onclick="subscribe()">Subscribe to Notifications</button>
|
||||
<button id="unsubscribeBtn" onclick="unsubscribe()" class="danger" disabled>Unsubscribe</button>
|
||||
<div id="status"></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Test Push</h2>
|
||||
<button onclick="sendPing()" class="success">Send Ping to All Subscribers</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let vapidPublicKey = '';
|
||||
let currentSubscription = null;
|
||||
|
||||
// Convert base64 to Uint8Array
|
||||
function urlBase64ToUint8Array(base64String) {
|
||||
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
||||
const base64 = (base64String + padding)
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/');
|
||||
const rawData = window.atob(base64);
|
||||
const outputArray = new Uint8Array(rawData.length);
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i);
|
||||
}
|
||||
return outputArray;
|
||||
}
|
||||
|
||||
function setStatus(message, type) {
|
||||
const status = document.getElementById('status');
|
||||
status.textContent = message;
|
||||
status.className = type;
|
||||
}
|
||||
|
||||
async function init() {
|
||||
// Get VAPID public key
|
||||
const resp = await fetch('/api/vapid-public-key');
|
||||
const data = await resp.json();
|
||||
vapidPublicKey = data.publicKey;
|
||||
|
||||
// Check if already subscribed
|
||||
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js');
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (subscription) {
|
||||
currentSubscription = subscription;
|
||||
document.getElementById('subscribeBtn').disabled = true;
|
||||
document.getElementById('unsubscribeBtn').disabled = false;
|
||||
setStatus('You are subscribed to notifications', 'success');
|
||||
}
|
||||
} else {
|
||||
setStatus('Push notifications not supported in this browser', 'error');
|
||||
document.getElementById('subscribeBtn').disabled = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribe() {
|
||||
try {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') {
|
||||
setStatus('Notification permission denied', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(vapidPublicKey)
|
||||
});
|
||||
|
||||
// Send to server
|
||||
const resp = await fetch('/api/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(subscription.toJSON())
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
currentSubscription = subscription;
|
||||
document.getElementById('subscribeBtn').disabled = true;
|
||||
document.getElementById('unsubscribeBtn').disabled = false;
|
||||
setStatus('Successfully subscribed!', 'success');
|
||||
} else {
|
||||
setStatus('Failed to subscribe: ' + await resp.text(), 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribe() {
|
||||
try {
|
||||
if (currentSubscription) {
|
||||
await fetch('/api/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ endpoint: currentSubscription.endpoint })
|
||||
});
|
||||
await currentSubscription.unsubscribe();
|
||||
currentSubscription = null;
|
||||
}
|
||||
document.getElementById('subscribeBtn').disabled = false;
|
||||
document.getElementById('unsubscribeBtn').disabled = true;
|
||||
setStatus('Successfully unsubscribed', 'info');
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function sendPing() {
|
||||
try {
|
||||
const resp = await fetch('/ping');
|
||||
if (resp.ok) {
|
||||
setStatus('Ping sent to all subscribers!', 'success');
|
||||
} else {
|
||||
setStatus('Failed to send ping', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus('Error: ' + err.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
33
example/static/sw.js
Normal file
33
example/static/sw.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
self.addEventListener('push', function(event) {
|
||||
let data = { title: 'Notification', body: '' };
|
||||
|
||||
if (event.data) {
|
||||
try {
|
||||
data = event.data.json();
|
||||
} catch (e) {
|
||||
data.body = event.data.text();
|
||||
}
|
||||
}
|
||||
|
||||
const options = {
|
||||
body: data.body || '',
|
||||
icon: data.icon || '',
|
||||
badge: data.badge || '',
|
||||
data: data.data || {},
|
||||
requireInteraction: false
|
||||
};
|
||||
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title || 'Notification', options)
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', function(event) {
|
||||
event.notification.close();
|
||||
|
||||
if (event.notification.data && event.notification.data.url) {
|
||||
event.waitUntil(
|
||||
clients.openWindow(event.notification.data.url)
|
||||
);
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue