Files
Codeman/src/web/public/sw.js
T
arkonandClaude Opus 4.7 56c2c29009 feat(push): plumb hostname-aware prefix into Web Push notifications
Closes the Web Push gap left by #82: in-page Notification API and tab
title flash both showed `codeman:<host>` after that PR, but OS-level
notifications dispatched via the service worker — the surface that
matters most when the tab is closed and the user is reading their
system notification center across multiple Codeman instances —
still hardcoded the literal "Codeman" prefix.

Service workers run in an isolated context with no access to
document.title or any in-page state, so the hostname has to ride
along in the push payload itself.

Server (server.ts:sendPushNotifications): emit `hostTitle: this.windowTitle`
in the JSON payload alongside the existing `title` (event-specific text
like "Permission Required"). The two stay separate so the SW can compose
them — the server knows the host, the SW knows the OS context.

Service worker (sw.js): compose `${hostTitle}: ${title}` when both
present, mirroring the in-page Notification format from
notification-manager.js. Fall back to `title || hostTitle || 'Codeman'`
so older servers (which omit hostTitle) keep working — the field is
purely additive on the wire.

Tests (test/push-payload-host-title.test.ts): mock the `web-push` module
via vi.hoisted(), instantiate WebServer without binding a port, stub
the push store with one fake subscription, and verify the JSON payload
shipped to webpush.sendNotification carries the right hostTitle for
both --title-hostname overrides and the os.hostname() default. Also
mirrors the SW's title-composition logic in a small helper so any
future change to the format breaks the test instead of being caught
only by users running multiple Codeman instances.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 10:03:59 +02:00

165 lines
4.7 KiB
JavaScript

/**
* @fileoverview Service worker for PWA install + Web Push notifications.
*
* App-shell caching: on install, precaches the core UI assets so the app
* launches instantly and works offline (or on flaky connections). Uses a
* network-first strategy for navigation and API calls, cache-first for
* static assets.
*
* Push notifications: receives push events from the Codeman server (via
* web-push library) and displays OS-level notifications. Handles notification
* clicks to focus an existing Codeman tab or open a new one.
*
* Lifecycle: skipWaiting on install, claim clients on activate -- ensures the
* latest service worker takes control immediately without waiting for tab
* refresh.
*
* @dependency None (runs in ServiceWorkerGlobalScope, isolated from page scripts)
* @see src/push-store.ts -- server-side VAPID key management and subscription CRUD
*/
const CACHE_NAME = 'codeman-v1';
// Core app shell -- cached on install for instant startup
const APP_SHELL = [
'/',
'/styles.css',
'/mobile.css',
'/constants.js',
'/app.js',
'/api-client.js',
'/terminal-ui.js',
'/session-ui.js',
'/settings-ui.js',
'/panels-ui.js',
'/notification-manager.js',
'/mobile-handlers.js',
'/keyboard-accessory.js',
'/voice-input.js',
'/vendor/xterm.min.js',
'/vendor/xterm-addon-fit.min.js',
'/vendor/xterm-addon-unicode11.min.js',
'/vendor/xterm-zerolag-input.js',
'/vendor/xterm.css',
'/icon-192.png',
'/icon-512.png',
'/manifest.json',
];
// --- Install: precache app shell ---
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => {
// Use addAll but don't fail install if some assets 404 (hashed filenames)
return Promise.allSettled(
APP_SHELL.map((url) => cache.add(url).catch(() => {}))
);
})
);
self.skipWaiting();
});
// --- Activate: clean old caches, claim clients ---
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys().then((keys) =>
Promise.all(
keys.filter((k) => k !== CACHE_NAME).map((k) => caches.delete(k))
)
).then(() => self.clients.claim())
);
});
// --- Fetch: network-first with cache fallback ---
// Network-first ensures deploys take effect immediately when online.
// Cache is only used when the network is unavailable (offline/flaky).
self.addEventListener('fetch', (event) => {
const { request } = event;
// Skip non-GET, WebSocket upgrades, and SSE streams
if (request.method !== 'GET') return;
if (request.headers.get('upgrade') === 'websocket') return;
if (request.headers.get('accept') === 'text/event-stream') return;
if (request.url.includes('/api/')) return;
event.respondWith(
fetch(request)
.then((response) => {
if (response && response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, clone));
}
return response;
})
.catch(() => caches.match(request))
);
});
// --- Push notifications ---
self.addEventListener('push', (event) => {
if (!event.data) return;
let payload;
try {
payload = event.data.json();
} catch {
return;
}
const { title, hostTitle, body, tag, sessionId, urgency, actions } = payload;
const options = {
body: body || '',
tag: tag || 'codeman-default',
icon: '/icon-192.png',
badge: '/icon-192.png',
data: { sessionId, url: sessionId ? `/?session=${sessionId}` : '/' },
renotify: true,
requireInteraction: urgency === 'critical',
};
if (actions && actions.length > 0) {
options.actions = actions;
}
// Match the in-page Notification format: "codeman:<host>: <event title>".
// hostTitle is sent by servers >= the hostname-aware push payload change;
// older servers omit it and we fall back to the bare title.
const displayTitle = hostTitle && title
? `${hostTitle}: ${title}`
: (title || hostTitle || 'Codeman');
event.waitUntil(
self.registration.showNotification(displayTitle, options)
);
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const { sessionId, url } = event.notification.data || {};
const targetUrl = url || '/';
event.waitUntil(
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clients) => {
// Try to find an existing Codeman tab
for (const client of clients) {
if (client.url.includes(self.location.origin)) {
client.postMessage({
type: 'notification-click',
sessionId,
action: event.action || null,
});
return client.focus();
}
}
// No existing tab -- open a new one
return self.clients.openWindow(targetUrl);
})
);
});