PWA - viames/pair GitHub Wiki
Pair includes frontend files for Progressive Web App behavior without build tools.
Default assets:
public/assets/PairPWA.jspublic/assets/PairSW.jspublic/assets/PairRouter.jspublic/assets/PairSkeleton.jspublic/assets/PairDevice.jspublic/assets/PairPush.jspublic/assets/PairPasskey.jspublic/assets/PairUI.js
-
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.
<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>Main boot method.
Useful config fields:
-
swUrl(default/assets/PairSW.js) -
scope(default/) -
registerServiceWorker(defaulttrue) -
checkOnline(defaulttrue) -
emitEvents(defaulttrue) -
watchInstallPrompt(defaulttrue) -
reloadOnControllerChange(defaultfalse) -
swOfflineFallback(defaultnull) -
serviceWorkerConfig(defaultnull) -
backgroundRefresh(defaultnull)
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)
}
});if (!PairPWA.isSupported()) {
console.warn('Service worker not supported');
}Returns whether install prompt is currently available.
if (PairPWA.canInstall()) {
document.getElementById('installApp').hidden = false;
}Shows install prompt if available.
document.getElementById('installApp').addEventListener('click', async () => {
const result = await PairPWA.promptInstall();
console.log(result.outcome, result.platform);
});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
});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'
});Requests SW to flush queued items for a tag.
await PairPWA.flushSyncQueue('orders-sync');Preloads routes/resources via service worker messaging.
await PairPWA.preload(['/orders', '/customers', '/dashboard']);await PairPWA.registerServiceWorker('/assets/PairSW.js', '/');const stopRefresh = PairPWA.startBackgroundRefresh({
url: '/api/heartbeat',
intervalMs: 30000,
onlyWhenVisible: false
});
// later
stopRefresh();
PairPWA.stopBackgroundRefresh();await PairPWA.updateServiceWorker();
await PairPWA.skipWaiting();PairPWA emits custom events on window:
pair:pwa:readypair:pwa:onlinepair:pwa:offlinepair:pwa:install-availablepair:pwa:install-prompt-resultpair:pwa:installedpair:pwa:sw-registeredpair:pwa:update-readypair:pwa:controller-changedpair:pwa:sw-messagepair:pwa:sync-queuedpair:pwa:background-refresh-startpair:pwa:background-refresh-successpair: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();
});Config keys:
viewSelectorlinkSelectoractiveClasspreloadOnHovertimeoutMs
PairRouter.start({
viewSelector: '[data-pair-router-view]',
activeClass: 'is-loading',
preloadOnHover: true,
timeoutMs: 12000
});await PairRouter.navigate('/orders/42');Router events:
pair:router:loadingpair:router:navigatedpair:router:error
PairSkeleton.autoBind({
startEvents: ['pair:router:loading'],
stopEvents: ['pair:router:navigated', 'pair:router:error'],
withStyles: true
});PairSkeleton.show(document);
setTimeout(() => PairSkeleton.hide(document), 500);await PairSkeleton.wrapPromise(fetch('/api/slow-report'));PairSkeleton.ensureStyles(PairSkeleton.defaultCss());console.log(PairDevice.supports);const pos = await PairDevice.getCurrentPosition({ enableHighAccuracy: true });
console.log(pos.coords.latitude, pos.coords.longitude);const video = document.querySelector('#preview');
const stream = await PairDevice.openCamera({ video: { facingMode: 'environment' }, audio: false });
await PairDevice.attachStream(video, stream, { autoplay: true, muted: true });const stream = await PairDevice.openCamera({ video: true, audio: false });
PairDevice.stopCamera(stream);
// or
PairDevice.stopStreamFromVideo(document.querySelector('#preview'));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();const device = await PairDevice.requestBluetooth({
filters: [{ services: ['battery_service'] }]
});PairDevice.vibrate([60, 30, 60]);Pair provides:
Pair\Helpers\PwaManifestPair\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
]
]);- 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, andPairPasskey. - Handle offline queue responses (
202 queued) in frontend UX.