PWA - viames/pair GitHub Wiki

Pair framework: PWA helpers

Pair includes frontend files for Progressive Web App behavior without build tools.

Default assets:

  • public/assets/PairPWA.js
  • public/assets/PairSW.js
  • public/assets/PairRouter.js
  • public/assets/PairSkeleton.js
  • public/assets/PairDevice.js
  • public/assets/PairPush.js
  • public/assets/PairPasskey.js
  • public/assets/PairUI.js

What each file does

  • PairPWA.js: service worker lifecycle, install prompt, offline queue, background refresh, preload.
  • PairSW.js: default service worker runtime.
  • PairRouter.js: progressive navigation for server-rendered pages.
  • PairSkeleton.js: loading skeleton orchestration.
  • PairDevice.js: wrappers around device/browser APIs.
  • PairPush.js: push subscription lifecycle.
  • PairPasskey.js: passkey/WebAuthn helper.
  • PairUI.js: reactive islands and DOM bindings.

Frontend quick start

<script src="/assets/PairUI.js" defer></script>
<script src="/assets/PairPWA.js" defer></script>
<script src="/assets/PairRouter.js" defer></script>
<script src="/assets/PairSkeleton.js" defer></script>
<script src="/assets/PairDevice.js" defer></script>
<script src="/assets/PairPush.js" defer></script>
<script src="/assets/PairPasskey.js" defer></script>
<script>
document.addEventListener('DOMContentLoaded', async () => {
  await PairPWA.init({
    swUrl: '/assets/PairSW.js',
    scope: '/',
    swOfflineFallback: '/offline.html',
    reloadOnControllerChange: true,
    serviceWorkerConfig: {
      cache: {
        pageStrategy: 'network-first',
        apiStrategy: 'network-first',
        assetStrategy: 'stale-while-revalidate'
      }
    }
  });

  PairRouter.start({ viewSelector: '[data-pair-router-view]' });
  PairSkeleton.autoBind({ withStyles: true });
});
</script>

PairPWA API

PairPWA.init(config = {}): Promise<ServiceWorkerRegistration|null>

Main boot method.

Useful config fields:

  • swUrl (default /assets/PairSW.js)
  • scope (default /)
  • registerServiceWorker (default true)
  • checkOnline (default true)
  • emitEvents (default true)
  • watchInstallPrompt (default true)
  • reloadOnControllerChange (default false)
  • swOfflineFallback (default null)
  • serviceWorkerConfig (default null)
  • backgroundRefresh (default null)

Example with background refresh:

await PairPWA.init({
  swUrl: '/assets/PairSW.js',
  scope: '/',
  backgroundRefresh: {
    url: '/api/notifications/unread',
    intervalMs: 45000,
    onlyWhenVisible: true,
    onSuccess: async (response) => {
      const data = await response.json();
      console.log('Unread:', data.count);
    },
    onError: (error) => console.error(error)
  }
});

PairPWA.isSupported(): boolean

if (!PairPWA.isSupported()) {
  console.warn('Service worker not supported');
}

PairPWA.canInstall(): boolean

Returns whether install prompt is currently available.

if (PairPWA.canInstall()) {
  document.getElementById('installApp').hidden = false;
}

PairPWA.promptInstall(): Promise<{ outcome, platform }>

Shows install prompt if available.

document.getElementById('installApp').addEventListener('click', async () => {
  const result = await PairPWA.promptInstall();
  console.log(result.outcome, result.platform);
});

PairPWA.fetchWithQueue(url, options = {}, queueOptions = {})

Tries normal fetch. On network failure for write methods, queues request in SW and returns 202 JSON { queued: true, offline: true }.

const response = await PairPWA.fetchWithQueue('/api/orders/save', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-Pair-Background-Sync': '1'
  },
  body: JSON.stringify({ orderId: 123, status: 'confirmed' })
}, {
  tag: 'orders-sync',
  queueOnFail: true
});

Handle queued vs immediate responses explicitly:

const response = await PairPWA.fetchWithQueue('/api/orders/save', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ orderId: 123 })
});

if (response.status === 202) {
  const payload = await response.json();
  console.log('Saved in sync queue:', payload.queued, payload.offline);
} else {
  const payload = await response.json();
  console.log('Saved immediately:', payload);
}

Disable queuing when required:

await PairPWA.fetchWithQueue('/api/orders/save', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({ orderId: 123 })
}, {
  queueOnFail: false
});

PairPWA.queueRequest({...})

Queues a request explicitly.

await PairPWA.queueRequest({
  url: '/api/audit/log',
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: { action: 'PROFILE_UPDATED' },
  tag: 'audit-sync'
});

PairPWA.flushSyncQueue(tag = 'pair-sync-default')

Requests SW to flush queued items for a tag.

await PairPWA.flushSyncQueue('orders-sync');

PairPWA.preload(urls = [])

Preloads routes/resources via service worker messaging.

await PairPWA.preload(['/orders', '/customers', '/dashboard']);

PairPWA.registerServiceWorker(swUrl, scope)

await PairPWA.registerServiceWorker('/assets/PairSW.js', '/');

PairPWA.startBackgroundRefresh(config) and PairPWA.stopBackgroundRefresh()

const stopRefresh = PairPWA.startBackgroundRefresh({
  url: '/api/heartbeat',
  intervalMs: 30000,
  onlyWhenVisible: false
});

// later
stopRefresh();
PairPWA.stopBackgroundRefresh();

PairPWA.skipWaiting() and PairPWA.updateServiceWorker()

await PairPWA.updateServiceWorker();
await PairPWA.skipWaiting();

PairPWA browser events

PairPWA emits custom events on window:

  • pair:pwa:ready
  • pair:pwa:online
  • pair:pwa:offline
  • pair:pwa:install-available
  • pair:pwa:install-prompt-result
  • pair:pwa:installed
  • pair:pwa:sw-registered
  • pair:pwa:update-ready
  • pair:pwa:controller-changed
  • pair:pwa:sw-message
  • pair:pwa:sync-queued
  • pair:pwa:background-refresh-start
  • pair:pwa:background-refresh-success
  • pair:pwa:background-refresh-error

Example listener:

window.addEventListener('pair:pwa:sync-queued', (event) => {
  console.log('Queued offline request:', event.detail);
});

Update-ready flow example:

window.addEventListener('pair:pwa:update-ready', async (event) => {
  const hasWaiting = !!(event.detail && event.detail.hasWaiting);
  if (!hasWaiting) return;

  // show your "new version available" UI and, on confirm:
  await PairPWA.skipWaiting();
});

window.addEventListener('pair:pwa:controller-changed', () => {
  location.reload();
});

PairRouter methods

PairRouter.start(config = {})

Config keys:

  • viewSelector
  • linkSelector
  • activeClass
  • preloadOnHover
  • timeoutMs
PairRouter.start({
  viewSelector: '[data-pair-router-view]',
  activeClass: 'is-loading',
  preloadOnHover: true,
  timeoutMs: 12000
});

PairRouter.navigate(url, { pushState = true })

await PairRouter.navigate('/orders/42');

Router events:

  • pair:router:loading
  • pair:router:navigated
  • pair:router:error

PairSkeleton methods

PairSkeleton.autoBind(options)

PairSkeleton.autoBind({
  startEvents: ['pair:router:loading'],
  stopEvents: ['pair:router:navigated', 'pair:router:error'],
  withStyles: true
});

PairSkeleton.show(root?) / PairSkeleton.hide(root?)

PairSkeleton.show(document);
setTimeout(() => PairSkeleton.hide(document), 500);

PairSkeleton.wrapPromise(promise, root?)

await PairSkeleton.wrapPromise(fetch('/api/slow-report'));

PairSkeleton.ensureStyles(cssText?) / PairSkeleton.defaultCss()

PairSkeleton.ensureStyles(PairSkeleton.defaultCss());

PairDevice methods

Capability map

console.log(PairDevice.supports);

PairDevice.getCurrentPosition(options)

const pos = await PairDevice.getCurrentPosition({ enableHighAccuracy: true });
console.log(pos.coords.latitude, pos.coords.longitude);

PairDevice.openCamera(constraints) + attachStream(...)

const video = document.querySelector('#preview');
const stream = await PairDevice.openCamera({ video: { facingMode: 'environment' }, audio: false });
await PairDevice.attachStream(video, stream, { autoplay: true, muted: true });

PairDevice.stopCamera(stream) / PairDevice.stopStreamFromVideo(video)

const stream = await PairDevice.openCamera({ video: true, audio: false });
PairDevice.stopCamera(stream);

// or
PairDevice.stopStreamFromVideo(document.querySelector('#preview'));

PairDevice.queryPermission(name) / PairDevice.watchPermission(name, callback)

const state = await PairDevice.queryPermission('geolocation');
console.log('Current permission:', state);

const unwatch = await PairDevice.watchPermission('geolocation', (nextState) => {
  console.log('Permission changed:', nextState);
});

// later
if (unwatch) unwatch();

PairDevice.requestBluetooth(options)

const device = await PairDevice.requestBluetooth({
  filters: [{ services: ['battery_service'] }]
});

PairDevice.vibrate(pattern)

PairDevice.vibrate([60, 30, 60]);

Backend helpers for manifest and SW config

Pair provides:

  • Pair\Helpers\PwaManifest
  • Pair\Helpers\PwaConfig

Example:

use Pair\Helpers\PwaConfig;
use Pair\Helpers\PwaManifest;

PwaManifest::write(APPLICATION_PATH . '/public/manifest.webmanifest', [
    'name' => 'My Pair App',
    'short_name' => 'PairApp',
    'start_url' => '/',
    'scope' => '/',
    'theme_color' => '#1b6ec2',
    'background_color' => '#ffffff',
]);

$swUrl = PwaConfig::buildServiceWorkerUrl('/assets/PairSW.js', [
    'offlineFallback' => '/offline.html',
    'cache' => [
        'pageStrategy' => 'network-first',
        'apiStrategy' => 'network-first',
        'assetStrategy' => 'stale-while-revalidate',
        'maxRuntimeEntries' => 400
    ],
    'sync' => [
        'maxQueueEntries' => 300,
        'maxAttempts' => 6
    ]
]);

Important notes

  • Service workers require HTTPS (except localhost).
  • Keep pages usable without JS (progressive enhancement).
  • Keep manifest linked in <head>.
  • Use one service worker URL consistently across PairPWA, PairPush, and PairPasskey.
  • Handle offline queue responses (202 queued) in frontend UX.

Related pages

⚠️ **GitHub.com Fallback** ⚠️