-
Notifications
You must be signed in to change notification settings - Fork 2
PWA
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 }.
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
});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.