mirror of
https://github.com/imjasonh/webpush
synced 2026-07-08 00:15:21 +00:00
No description
|
|
||
|---|---|---|
| keys | ||
| storage | ||
| vapid | ||
| go.mod | ||
| go.sum | ||
| integration_test.go | ||
| LICENSE | ||
| README.md | ||
| webpush.go | ||
| webpush_test.go | ||
webpush
A Go library for sending Web Push API notifications with VAPID authentication.
Features
- RFC 8291 compliant message encryption (aes128gcm)
- RFC 8292 VAPID authentication
- Pluggable VAPID key providers:
- File-based (PEM or base64 encoded)
- Google Cloud KMS
- Pluggable subscription storage:
- In-memory (for testing/development)
- SQLite
- Easy integration with JavaScript Push API clients
Installation
go get github.com/imjasonh/webpush
Quick Start
1. Generate VAPID Keys
package main
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())
}
2. Send Push Notifications
package main
import (
"context"
"github.com/imjasonh/webpush"
"github.com/imjasonh/webpush/keys"
)
func main() {
// Load 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)
}
// Create client with VAPID subject (must be mailto: or https: URL)
client := webpush.NewClient(signer, "mailto:admin@example.com")
// Parse subscription from client
sub, err := webpush.ParseSubscription([]byte(`{
"endpoint": "https://fcm.googleapis.com/fcm/send/...",
"keys": {
"p256dh": "...",
"auth": "..."
}
}`))
if err != nil {
panic(err)
}
// 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
})
if err != nil {
panic(err)
}
}
3. Store Subscriptions
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:
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...
}
Creating a KMS Key
# 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 \
--purpose=asymmetric-signing \
--default-algorithm=ec-sign-p256-sha256
JavaScript Client Integration
Service Worker Setup
- Create a service worker file (
sw.js):
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)
);
}
});
- Register the service worker and subscribe to push:
// 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();
}
}
- Create a simple server endpoint to receive subscriptions:
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
type Subscription struct {
Endpoint string `json:"endpoint"`
Keys Keys `json:"keys"`
}
type Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
}
Options
type Options struct {
TTL int // Time-to-live in seconds (default: 2419200 = 4 weeks)
Urgency string // Urgency: very-low, low, normal, high
Topic string // Topic for message replacement
}
Client
// 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
// File-based (PEM)
signer, err := keys.NewFileSigner("path/to/key.pem")
// File-based (base64)
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")
privateB64, publicB64, err := keys.GenerateKeyPair()
Storage
// In-memory (for testing)
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)
Custom Storage Implementation
Implement the storage.Storage interface:
type Storage interface {
Save(ctx context.Context, record *Record) error
Get(ctx context.Context, id string) (*Record, error)
GetByEndpoint(ctx context.Context, endpoint string) (*Record, error)
GetByUserID(ctx context.Context, userID string) ([]*Record, error)
Delete(ctx context.Context, id string) error
DeleteByEndpoint(ctx context.Context, endpoint string) error
List(ctx context.Context, limit, offset int) ([]*Record, error)
Close() error
}
Custom Key Provider
Implement the webpush.Signer interface:
type Signer interface {
Sign(ctx context.Context, data []byte) ([]byte, error)
PublicKey() []byte
}
The Sign method receives SHA-256 hash and should return IEEE P1363 format signature (64 bytes for P-256).
License
Apache 2.0 - See LICENSE for details.