Skip to content
Viames Marino edited this page Jul 25, 2026 · 4 revisions

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 }.

Sensitive requests are never persisted or replayed, even when background sync is requested explicitly. This includes requests with an Authorization header, token-like query parameters, or authentication/session paths such as auth, login, logout, oauth, passkey, and session. PairPWA.queueRequest() returns false for these requests, and the service worker discards matching entries created by an older version before replay.

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

Clone this wiki locally