oPWA v1.0.0
Transforms any WordPress site into a fully capable Progressive Web App — manifest, service worker, self-hosted push, install prompt, and analytics, all without a third-party push service.
What oPWA does
A complete PWA stack for WordPress — no FCM, no third-party service. Generates the manifest, dynamic service worker, and handles encrypted push end-to-end.
What you need
| Component | Minimum | Notes |
|---|---|---|
| WordPress | 6.0+ | |
| PHP | 7.4+ | 8.1+ recommended for native openssl_pkey_derive |
| PHP extensions | openssl with EC support | gmp required as fallback ECDH for PHP < 8.1 |
| HTTPS | Required | Service workers only run on secure origins |
| Chrome | 67+ | |
| Firefox | 63+ | |
| Edge | 79+ | |
| Safari | 16.4+ | Push not supported on all Safari versions |
gmp extension if not present.Getting started
opwa/ folder to wp-content/plugins/.opwa_subscribers, opwa_campaigns, opwa_analytics), auto-detects the site icon and sets it as the 512px source, generates a default precache list, and sets the VAPID subject to the admin email.File structure
Plugin constants
| Constant | Value |
|---|---|
OPWA_VERSION | '1.0.0' |
OPWA_PATH | Absolute path to plugin directory |
OPWA_URL | URL to plugin directory |
OPWA_OPTION | 'opwa_settings' — the wp_options key |
OPWA_DB_VERSION | '1.0' |
Admin interface
The oPWA admin uses the Orravo 3-row sticky header pattern. The WordPress sidebar is hidden on all plugin pages. Content is full-width.
| Row | Position | Contents |
|---|---|---|
| Brand bar | top: 32px | oPWA logo, version pill, WP avatar, light/dark toggle |
| Top nav | top: 82px | Horizontal tab links grouped by function |
| Topbar | top: 126px | Page title, save button, secondary actions |
Dashboard health check
A quick overview of PWA installation status — checks run both in-browser and server-side.
JS-side checks (live in browser)
| Check | How it's tested |
|---|---|
| HTTPS | location.protocol === 'https:' |
| Service Worker | navigator.serviceWorker.getRegistration('/') |
| Manifest linked | <link rel="manifest"> present in document head |
| Installed | window.matchMedia('(display-mode: standalone)') |
PHP-side checklist
- Icons generated (192px and 512px URLs configured)
- Offline fallback URL set and the page exists in WordPress
- VAPID keys generated
The dashboard also shows an iOS instructions card with the share-button steps for iOS Safari users who cannot use the native install prompt.
Web App manifest
Controls the manifest.webmanifest served at /manifest.webmanifest.
| Field | Setting key | Notes |
|---|---|---|
| App name | app_name | Used as name in manifest |
| Short name | app_short_name | Up to 12 chars recommended |
| Description | app_description | |
| Start URL | start_url | Defaults to / |
| Scope | scope | Defaults to / |
| Display | display | standalone, fullscreen, minimal-ui, browser |
| Orientation | orientation | any, portrait, landscape |
| Theme color | theme_color | Hex — browser UI tint |
| Background color | background_color | Splash screen background |
| Icon source (512px) | icon_512_id | Attachment ID for source image |
| Icon 192px URL | icon_192_url | Auto-set after generation |
| Icon 512px URL | icon_512_url | Auto-set after generation |
| Maskable icon URL | icon_maskable_url | Auto-generated with safe zone |
| Screenshots (×5) | screenshots[] | src, form_factor, label |
| Share target enabled | share_target | Adds share_target to manifest |
| Share target URL | share_target_url | Receives shared content |
wp-content/uploads/opwa-icons/. Requires the PHP GD extension.Service worker settings
Controls the dynamically-generated SW served at /sw.js.
Global caching strategies
| Asset type | Setting key | Default |
|---|---|---|
| HTML pages | strategy_pages | network-first |
| Static assets (JS/CSS) | strategy_static | cache-first |
| Images | strategy_images | cache-first |
| Fonts | strategy_fonts | cache-first |
Custom route builder
Add per-URL rules with regex patterns. Each row has:
- Pattern — JavaScript regex matched against
request.url - Strategy — one of 5 strategies
- TTL (seconds) — max age for cached entries (0 = no expiry)
- Max entries — cap on cache storage entries (0 = unlimited)
Custom routes are serialised to JSON in opwa_settings['custom_routes'].
Preset configurations
| Preset | Description |
|---|---|
| Blog | Network-first pages, cache-first static/images/fonts |
| WooCommerce | Same as Blog + network-only for checkout/cart/account |
| Portfolio | Cache-first everything, long TTLs |
Other SW options
| Option | Key | Description |
|---|---|---|
| Navigation preload | navigation_preload | Enables navigationPreload.enable() in SW — avoids startup latency on navigation requests |
| Background sync | background_sync | Enables IndexedDB form queue |
| SW version | sw_version | Integer — bump to force cache clear across all clients |
Offline experience
| Field | Key | Description |
|---|---|---|
| Offline page URL | offline_url | URL served when page is unavailable offline — must exist in WordPress |
| Offline message | offline_message | Text shown on the offline fallback template |
| Show logo on offline page | offline_show_logo | Renders the 192px icon |
| Show cached pages list | offline_show_cached | Lists previously cached page titles |
| Enable form queue | offline_form_queue | Queues form submissions via Background Sync API |
Push notification settings
Self-hosted Web Push (RFC 8030) with VAPID authentication — no FCM or other third-party service required.
VAPID wizard
OPWA_Push::generate_vapid_keys() which creates an EC P-256 key pair via OpenSSL. The public key (base64url, 65-byte uncompressed point) and private key (PEM) are stored in wp_options.mailto: or https: URI identifying your site, included in the VAPID JWT claim as sub.Push composer
| Field | Description |
|---|---|
| Title | Notification title |
| Body | Notification body text |
| Icon URL | Small notification icon |
| Image URL | Large hero image (optional) |
| Click URL | Where the notification takes the user on click |
| Tag | Notification tag for deduplication (opwa default) |
Install prompt settings
| Field | Key | Description |
|---|---|---|
| Banner message | banner_message | Main install text |
| Install button | banner_button | CTA button label |
| Dismiss days | dismiss_days | Days before showing again after dismiss |
| Show iOS overlay | ios_overlay | Adds "Tap Share → Add to Home Screen" for Safari users |
Trigger types
| Type | Key | Extra fields |
|---|---|---|
| Immediately | immediate | — |
| After N seconds | delay | trigger_delay (seconds) |
| After N page views | pageviews | trigger_pageviews |
| On scroll | scroll | trigger_scroll_pct (% page scrolled) |
| On exit intent | exit_intent | — |
Public JS API
JSwindow.opPWA.showBanner(); // Show install banner programmatically
window.opPWA.dismiss(); // Dismiss banner
Live cache management
Live cache inspection and management via a postMessage channel to the active service worker.
JS// Admin JS sends:
reg.active.postMessage({ type: 'OPWA_CACHE_STATS' }, [messageChannel.port2]);
// SW responds with:
{
'opwa-pages-v1': { count: 12, size_kb: 340 },
'opwa-static-v1': { count: 8, size_kb: 120 },
// …
}
- Load Cache Stats — queries the active SW and renders a live breakdown per cache store
- Clear All Caches — bumps
sw_version, causing the SW to delete all old caches on next activate - Clear URL — removes a specific URL from the pages cache
Analytics dashboard
All analytics collected via navigator.sendBeacon to /wp-json/opwa/v1/beacon. No impact on page performance.
Stat cards (all-time)
| Card | Metric |
|---|---|
| Push Subscribers | Count of active subscriptions |
| SW Coverage | % of page views where SW was active |
| Cache Hit Rate | cache_hits / (cache_hits + cache_misses) × 100 |
| Offline Sessions | Sessions where network was unavailable |
| Install Prompts | Times the install banner was shown |
| Installs | Times the appinstalled event fired |
| Install Rate | installs / prompts × 100 |
| Dismissals | Times the install banner was dismissed |
30-day bar charts: SW registrations per day · Installs per day · Page views per day · Cache hits per day.
Plugin settings
Performance
| Key | Description |
|---|---|
preload_links | <link rel="preload"> for critical assets |
navigation_preload | Enable Navigation Preload in SW |
lazy_subscribe | Delay push subscription prompt |
Analytics & Privacy
| Key | Default | Description |
|---|---|---|
analytics_enabled | true | Toggle beacon collection |
analytics_retention | 90 | Days to keep raw rows |
Advanced
| Key | Description |
|---|---|
debug_mode | Adds console.log statements to SW output |
bypass_logged_in | Skip SW for logged-in users |
woocommerce_mode | Forces network-only on /checkout, /cart, /my-account |
custom_sw_code | Append raw JS to the generated service worker |
Caching strategies
The SW is generated server-side by OPWA_SW_Builder::build() and served at /sw.js with Content-Type: application/javascript.
| Strategy | Behaviour | Best for |
|---|---|---|
| network-first | Try network; fall back to cache on error | HTML pages, dynamic content |
| cache-first | Serve from cache; update in background | CSS, JS, fonts, images |
| stale-while-revalidate | Serve stale cache immediately; fetch update for next time | Frequently-changing assets where slightly stale is OK |
| network-only | Never cache; always require network | Checkout, authentication, payments |
| cache-only | Only serve from cache; never network | Pre-cached, locked assets |
Cache store names
| Cache | Name pattern |
|---|---|
| Pages | opwa-pages-v{sw_version} |
| Static (JS/CSS) | opwa-static-v{sw_version} |
| Images | opwa-images-v{sw_version} |
| Offline | opwa-offline-v{sw_version} |
| Precache | opwa-precache-v{sw_version} |
Bumping sw_version causes the old caches to be deleted on the next SW activate event. Each strategy also supports maxAge (seconds) and maxEntries caps.
Self-hosted web push
VAPID (Voluntary Application Server Identification) uses an EC P-256 key pair. All payloads are end-to-end encrypted (AES-128-GCM) — only the subscriber's browser can decrypt.
VAPID key setup
OPWA_Push::generate_vapid_keys() calls openssl_pkey_new(['curve_name' => 'prime256v1']).0x04 || X || Y) base64url-encoded. Sent to the browser during subscription via applicationServerKey.wp_options, used to sign the VAPID JWT on every push send.Sending notifications
OPWA_Push::send( object $subscriber, array $payload_data, string $vapid_subject )
{"typ":"JWT","alg":"ES256"}, payload with aud, exp, and sub. Signed with private key via openssl_sign(), DER signature converted to raw R‖S (32 bytes each).OPWA_Push::encrypt_payload() — RFC 8188 / ECE aes128gcm.HTTPAuthorization: vapid t={jwt},k={public_key_b64u}
Content-Type: application/octet-stream
Content-Encoding: aes128gcm
TTL: 86400
Payload encryption
Web Push Encryption spec (RFC 8188, ECE draft-03) — AES-128-GCM with ECDH key agreement on P-256.
p256dh key. PHP 8.1+: openssl_pkey_derive(). PHP < 8.1: pure-PHP double-and-add scalar multiplication using GMP.HKDF-SHA256(salt=auth_secret, ikm=shared_secret, info="WebPush: info\x00" || recv_pub || sender_pub, len=32)HKDF-SHA256(salt=random_16_bytes, ikm=prk, info="Content-Encoding: aes128gcm\x00", len=16)HKDF-SHA256(salt, prk, "Content-Encoding: nonce\x00", len=12)AES-128-GCM(key=cek, iv=nonce, plaintext=payload + \x02 + zero_padding)salt(16) || rs(4, big-endian) || idlen(1) || sender_pub(65) || ciphertext || gcm_tag(16)Install prompt lifecycle
Built around the beforeinstallprompt browser event. For iOS Safari (which does not fire this event), the iOS overlay shows share-button instructions instead.
beforeinstallprompt and store the event (deferred).deferredPrompt.prompt() and waits for user choice.appinstalled, fires a beacon event to analytics.Dismiss persistence: Dismissal is stored in localStorage with a timestamp. The banner will not show again until dismiss_days have elapsed.
Offline experience
When a navigation request fails and the network is unavailable, the SW returns the configured offline URL from the opwa-offline cache.
The offline page can display a custom offline message, the 192px app icon, and a list of cached page titles — the SW reads opwa-pages cache keys and posts them back via postMessage.
Background sync
When background_sync is enabled, the SW intercepts POST form submissions. If the network is unavailable, the request is serialised into IndexedDB (opwa-sync-db, store offline-forms). On reconnect, the SW's sync event replays each queued request to the original form action URL.
Useful for contact forms, newsletter signups, and other POST endpoints that should not be lost when a user goes offline mid-session.
network-online listener in unsupported browsers.Analytics & beacons
The front-end script calls navigator.sendBeacon('/wp-json/opwa/v1/beacon', JSON.stringify(payload)) for the following events:
| Event | Fields |
|---|---|
page_view | sw_active (bool), url |
cache_hit | url, cache_name |
cache_miss | url |
offline_session | url |
install_prompt_shown | — |
install | — |
install_dismiss | — |
sw_registration | — |
The REST endpoint (OPWA_Analytics::rest_beacon) writes to opwa_analytics, upserting one row per date using INSERT … ON DUPLICATE KEY UPDATE.
WooCommerce mode
When WooCommerce Mode is on, the service worker applies network-only to requests matching these URL patterns — ensuring cart totals, payment steps, and account pages are never served stale.
/checkoutand sub-paths/cartand sub-paths/my-accountand sub-paths- Any URL containing
?wc-ajax=
Multisite support
oPWA supports WordPress Multisite. Each sub-site has its own settings record in its own wp_options. DB tables are created per-site on activation.
BASH# Verify each sub-site after network activation
wp site list --field=url | xargs -I{} wp --url={} opwa status
WP-CLI commands
All commands use the wp opwa namespace.
wp opwa clear-cache
Bumps sw_version by 1, forcing all clients to delete and re-create caches on next SW activation.
BASHwp opwa clear-cache
# Success: Cache cleared. New SW version: 4
wp opwa send-push
Send a push notification to all subscribers.
BASHwp opwa send-push --title="New post" --body="Check out our latest article" --url="https://site.com/post"
# Success: Done. Sent: 142 / Failed: 3 / Total: 145
| Flag | Required | Description |
|---|---|---|
--title | Yes | Notification title |
--body | Yes | Notification body |
--url | No | Click-through URL |
--icon | No | Icon URL |
wp opwa list-subscribers
BASHwp opwa list-subscribers
wp opwa list-subscribers --format=json
wp opwa list-subscribers --format=csv
# Columns: ID, Device, User ID, Subscribed, Endpoint (truncated)
wp opwa generate-icons
Generate all PWA icon sizes from a WordPress attachment ID. Must be PNG/JPG at least 512×512.
BASHwp opwa generate-icons 42
# Success: Icons generated: 72x72, 96x96, 128x128, 144x144, 152x152, 192x192, 384x384, 512x512, maskable
wp opwa generate-vapid
BASHwp opwa generate-vapid # Prompts for confirmation
wp opwa generate-vapid --yes # Skip confirmation
# Success: VAPID keys generated and saved.
# Public key: BFi2R7...
wp opwa status
Display a summary of current plugin configuration.
BASHwp opwa status
| Setting | Example value |
|---|---|
| Plugin version | 1.0.0 |
| SW version | 3 |
| App name | My Site |
| VAPID configured | Yes |
| Push subscribers | 142 |
| Pages strategy | network-first |
REST API endpoints
Base namespace: opwa/v1. All three endpoints are public (permission_callback: __return_true) and accept JSON.
POST /wp-json/opwa/v1/beacon
Record an analytics event from the front-end.
JSON// Request body
{
"event": "page_view",
"sw_active": true,
"url": "https://site.com/blog/"
}
// Response
{ "ok": true }
POST /wp-json/opwa/v1/subscribe
Register a push subscription endpoint.
JSON// Request body
{
"endpoint": "https://fcm.googleapis.com/fcm/send/…",
"keys": { "p256dh": "BN…", "auth": "zq…" }
}
// Response
{ "ok": true, "id": 17 }
POST /wp-json/opwa/v1/unsubscribe
Remove a push subscription.
JSON// Request body
{ "endpoint": "https://fcm.googleapis.com/fcm/send/…" }
// Response
{ "ok": true }
Filters & actions
Filters
opwa_manifest_data
Modify the manifest array before it is JSON-encoded and served.
PHPadd_filter( 'opwa_manifest_data', function( array $manifest ): array {
$manifest['categories'] = [ 'news', 'blog' ];
$manifest['prefer_related_applications'] = false;
return $manifest;
} );
opwa_service_worker_routes
Modify or extend the array of custom routes before the SW is generated.
PHPadd_filter( 'opwa_service_worker_routes', function( array $routes ): array {
$routes[] = [
'pattern' => '/api/.*',
'strategy' => 'network-only',
'max_age' => 0,
'max_entries' => 0,
];
return $routes;
} );
opwa_sw_extra_code
Append raw JavaScript to the generated service worker.
PHPadd_filter( 'opwa_sw_extra_code', function( string $code ): string {
$code .= "\nself.addEventListener('message', e => {
if (e.data === 'MY_MSG') { /* … */ }
});";
return $code;
} );
opwa_precache_urls
PHPadd_filter( 'opwa_precache_urls', function( array $urls ): array {
$urls[] = home_url( '/offline-assets/hero.webp' );
$urls[] = home_url( '/offline-assets/logo.svg' );
return $urls;
} );
opwa_push_payload
Modify the push notification payload before it is encrypted and sent.
PHPadd_filter( 'opwa_push_payload', function( array $payload, object $subscriber ): array {
if ( $subscriber->user_id ) {
$payload['actions'] = [ [ 'action' => 'view', 'title' => 'View now' ] ];
}
return $payload;
}, 10, 2 );
opwa_analytics_beacon_data
PHPadd_filter( 'opwa_analytics_beacon_data', function( array $data ): array {
// Strip URL to path-only for privacy
if ( isset( $data['url'] ) ) {
$data['url'] = parse_url( $data['url'], PHP_URL_PATH );
}
return $data;
} );
opwa_icon_sizes
PHPadd_filter( 'opwa_icon_sizes', function( array $sizes ): array {
return array_filter( $sizes, fn( $s ) => in_array( $s, [ 192, 512 ], true ) );
} );
Actions
opwa_after_subscribe
Fires after a new push subscriber is successfully stored.
PHPadd_action( 'opwa_after_subscribe', function( int $subscriber_id, string $endpoint ): void {
// e.g., send a welcome notification
}, 10, 2 );
opwa_after_push_send
Fires after a push campaign completes.
PHPadd_action( 'opwa_after_push_send', function( array $result, array $payload ): void {
// $result = [ 'sent' => 140, 'failed' => 2, 'total' => 142 ]
error_log( 'Push sent: ' . wp_json_encode( $result ) );
}, 10, 2 );
opwa_after_cache_clear
PHPadd_action( 'opwa_after_cache_clear', function( int $new_version ): void {
do_action( 'my_plugin_purge_cdn' );
} );
opwa_on_activate
Fires after the plugin runs its full activation routine (tables created, defaults set).
PHPadd_action( 'opwa_on_activate', function(): void {
// One-time setup for your own plugin
} );
Database schema
opwa_subscribers
| Column | Type | Description |
|---|---|---|
id | BIGINT UNSIGNED AUTO_INCREMENT | Primary key |
endpoint | TEXT NOT NULL | Push subscription URL |
p256dh | TEXT NOT NULL | Client public key |
auth | VARCHAR(255) | Auth secret |
user_id | BIGINT UNSIGNED NULL | WP user ID if logged in |
device_type | VARCHAR(20) | mobile, tablet, desktop |
user_agent | TEXT | Raw UA string |
created_at | DATETIME | Subscription time |
Unique index on MD5 hash of endpoint to prevent duplicates.
opwa_campaigns
| Column | Type | Description |
|---|---|---|
id | BIGINT UNSIGNED AUTO_INCREMENT | Primary key |
title | VARCHAR(255) | Notification title |
body | TEXT | Notification body |
icon_url | VARCHAR(1000) | Icon URL |
click_url | VARCHAR(1000) | Click-through URL |
sent | INT | Successful deliveries |
failed | INT | Failed deliveries |
total | INT | Total subscribers at send time |
created_at | DATETIME | Send time |
opwa_analytics
One row per calendar date. Uses INSERT … ON DUPLICATE KEY UPDATE so concurrent requests safely increment counters.
| Column | Type | Description |
|---|---|---|
id | BIGINT UNSIGNED AUTO_INCREMENT | Primary key |
stat_date | DATE NOT NULL | Date (YYYY-MM-DD) — unique key |
page_views_total | INT | Total page view beacons |
page_views_sw | INT | Page views where SW was active |
cache_hits | INT | Cache-served responses |
cache_misses | INT | Network-fallback responses |
offline_sessions | INT | Sessions without network |
install_prompts_shown | INT | Install banner impressions |
installs | INT | appinstalled events |
install_dismissals | INT | Banner dismissals |
sw_registrations | INT | SW registration events |
Security model
Nonce protection
All admin AJAX endpoints verify wp_verify_nonce( $nonce, 'opwa_admin' ) and check current_user_can('manage_options') before processing.
Input sanitisation
| Data type | Function used |
|---|---|
| URLs | esc_url_raw() |
| Text | sanitize_text_field() |
| HTML fields | wp_kses_post() |
| Integers | intval() |
| Booleans | (bool) cast |
VAPID private key storage
The VAPID private key PEM is stored in wp_options. Access requires the manage_options capability. If your site has untrusted admin users, consider encrypting the PEM at rest.
Push payload security
All push payloads are end-to-end encrypted (AES-128-GCM) before transmission. Only the subscriber's browser can decrypt the payload. The beacon endpoint writes aggregated counts only — no raw URL storage.
'self' for worker-src (for the SW) and connect-src for the beacon REST endpoint.Performance notes
| Topic | Guidance |
|---|---|
| SW script size | ~8–15 KB before gzip. Keep custom routes minimal. |
| Precache list | Large lists slow SW install. Limit to 20–30 critical URLs. |
| Push payload size | Web Push limits payloads to 4 KB including encryption overhead. Keep notification bodies under 1 KB. |
| Analytics beacon | Uses navigator.sendBeacon — non-blocking, no impact on page performance. |
| Navigation Preload | Enable navigation_preload to avoid SW startup latency on navigation requests. |
| Icon generation | GD-based icon generation is a one-time operation. Generated PNGs cached in uploads/opwa-icons/. |
Common issues
Service worker not registering
- Verify the site is served over HTTPS (or
localhost). - Check browser DevTools → Application → Service Workers for errors.
- Ensure no other plugin intercepts requests to
/sw.js. The SW is served atinitpriority 1 via WordPress, not as a real file. - If using a CDN, ensure
/sw.jsand/manifest.webmanifestare excluded from the CDN cache.
Push notifications not received
--allow-feature=web-push in some testing environments.Icons not generating
- Verify the PHP GD extension is active:
php -m | grep gd - The source attachment must be at least 512×512 pixels.
- The
wp-content/uploads/opwa-icons/directory must be writable.
ECDH / encryption errors
- PHP < 8.1 requires the GMP extension for the P-256 fallback:
php -m | grep gmp - PHP 8.1+ uses
openssl_pkey_derive()natively. - OpenSSL must be compiled with EC curve support:
openssl ecparam -list_curves | grep prime256v1
Other issues
| Issue | Fix |
|---|---|
| Manifest not linking | Check another plugin isn't outputting a <link rel="manifest"> pointing elsewhere. The plugin hooks into wp_head — ensure your theme doesn't remove it. |
| Background sync not firing | Only supported in Chromium. Verify background_sync is enabled. Check SW DevTools → Background Sync for queued tags. |
| WooCommerce checkout issues | Enable WooCommerce Mode in Settings. For custom checkout URLs, add a network-only custom route. |
Got a question about oPWA?
Reach out directly — Kenneth replies within 24 hours.
