oPWA v1.0.0
Transforms any WordPress site into a fully capable Progressive Web App with manifest, service worker, self-hosted push, install prompt, and analytics, all without a third-party push service.
What oPWA does
A complete self-hosted PWA stack for WordPress. 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 192px URL | icon_192_url | Set after running the Icon Generator |
| Icon 512px URL | icon_512_url | Set after running the Icon Generator |
| Maskable icon URL | icon_maskable_url | Auto-generated with safe zone |
| Screenshots (×5) | screenshots[] | src, form_factor, label |
| Share target enabled | share_target_enabled | Boolean toggle. When on, the manifest exposes a share target at /?opwa-share=1 (URL is hardcoded, not configurable). |
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 |
| Assets (JS / CSS / fonts) | strategy_assets | cache-first |
| Images | (hardcoded) | cache-first (hardcoded; not configurable in v1) |
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. Runs entirely on your server.
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 | (none) |
| 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 | (none) |
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) so 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, store 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 | (none) |
install | (none) |
install_dismiss | (none) |
sw_registration | (none) |
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.
'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.

