mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 22:49:41 +02:00
Review fixes. Two of these are defects in the previous commit.
1. The fetch deadline only covered time-to-headers. `await fetch()` settles on
response headers, so clearing the abort timer in a finally around it left the
body — the multi-megabyte `?full=1` capture the deadline exists for —
completely unbounded; it only ever bounded a server that accepts a connection
and never replies. Measured against a server that sends headers immediately
and stalls the body 4s under a 1s deadline: fetch resolved at 30ms, timer
cleared there, body completed at 4026ms unaborted. Now the body is read
inside `_fetchTerminalCapture`, which returns {json, headers, headersAt} —
headers because two callers read server-timing, headersAt because those same
callers measure header-vs-body time and can no longer observe that moment.
`_terminalCaptureInflight` is scoped the same way, so a body still streaming
counts toward a capture starting beside it. Same test now aborts at 1005ms.
2. The precache could never be hit, and the previous commit made that expensive
rather than free. `renderIndexHtml` runs `cacheBustAssets`, which appends
`?v=<mtime>` to every same-origin .js/.css reference INCLUDING content-hashed
names — confirmed against a running instance:
`vendor/xterm-zerolag-input.6fee72f2.js?v=1789402869101`. `caches.match` is
query-sensitive, so entries keyed on the bare hashed path were unreachable;
deriving the list from the manifest turned cheap 404s into ~1.3MB downloaded
at every install that nothing could read back, once per deploy now that
CACHE_NAME rotates. The fallback match takes `{ ignoreSearch: true }`, which
also lets runtime-cached entries survive an mtime change.
3. `_wsOutputGapSession` was only cleared in ws.onopen, so paths that already
repaint the buffer left it set and the socket replayed everything a second
time. `selectSession` loads the buffer and only THEN calls `_connectWs`, so
neither the _isLoadingBuffer nor the _terminalRefreshOwner guard applied.
`_markTerminalBufferReconciled()` is now called from _onSessionNeedsRefresh's
finally, from selectSession after its load, and from _cleanupSessionData.
The scope claim was also wrong and is corrected in the comment: when the
network drops, SSE drops with it and handleInit's keepTerminal branch already
reconciles. The genuinely uncovered case is the WS dying while SSE stays up,
where _onSSETerminal discards SSE terminal frames until _wsReady flips in
onclose — up to the ping+pong window of output nothing writes.
4. CLAUDE.md said "all of them measured rather than reasoned", which the PR's
own "not verified" section contradicted. Split explicitly: the replay race is
measured, the watchdog mechanism is verified against xterm 6.0.0 under jsdom
(field path resolves, a forced stale handle makes refreshRows a no-op, the
kick schedules a fresh frame), and the iOS rAF-discard premise is reasoned
and still wants a device. Adds the two missing entries — the WebSocket
reconcile and the sw.js/build.mjs "keep these in sync or the build throws"
contract.
Also: test/xterm-private-api.test.ts pins the RESOLVED lockfile version instead
of the declared `^6.0.0` range, which was the wrong assertion in both directions
— a real upgrade to 6.4.0 can rename a private field while resolving inside the
range, and an innocuous range edit failed while changing nothing installed. And
test/sw-precache-manifest.test.ts now parses HASHABLE out of scripts/build.mjs
rather than hand-copying it, which was the same drift this PR exists to fix; the
parse is guarded against silently matching nothing.
The deadline fix has a behavioural test against a real socket plus a source
guard asserting `await res.json()` precedes the finally — verified to fail when
the helper is reverted to the old shape, so it is not vacuous.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
216 lines
8.0 KiB
JavaScript
216 lines
8.0 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
|
|
*/
|
|
|
|
// Build identity. scripts/build.mjs rewrites this declaration after it content-
|
|
// hashes the assets; the literal below is what dev serves, and dev wants a
|
|
// stable key.
|
|
//
|
|
// Why the cache key MUST carry it: `activate` deletes every cache whose key is
|
|
// not the current one, so the old constant key meant that cleanup never deleted
|
|
// anything — hashed assets from every release ever deployed accumulated in one
|
|
// bucket until the origin hit its storage quota.
|
|
const BUILD_ID = 'dev';
|
|
const CACHE_NAME = `codeman-${BUILD_ID}`;
|
|
|
|
// Reverse-proxy base path: the worker is served at `<base>/sw.js`, so its own
|
|
// location tells us the mount prefix ('' at root, or '/codeman'). Every URL below
|
|
// is prefixed through B() so the cached shell, icons and API calls resolve under
|
|
// the mount instead of escaping to the origin root.
|
|
const SW_BASE = self.location.pathname.replace(/\/sw\.js$/, '');
|
|
const B = (p) => (p && p[0] === '/' ? SW_BASE + p : p);
|
|
|
|
// Content-hashed assets. scripts/build.mjs rewrites this declaration with the
|
|
// filenames it actually emitted; dev has no hashing, so the empty literal below
|
|
// is correct there and the unhashed modules are simply cached on first use by
|
|
// the runtime handler further down.
|
|
//
|
|
// This list used to be maintained by hand with the PRE-hash names, which the
|
|
// build then renamed — so in production every entry 404'd and the silent
|
|
// `.catch()` in install swallowed all of it. Measured against a running
|
|
// instance: 15 of 23 entries failed. Offline still worked, because the fetch
|
|
// handler caches every successful GET at runtime, but the precache warmed
|
|
// nothing while looking like it did. Deriving it from the same manifest that
|
|
// renames the files is the only thing that keeps the two from drifting again.
|
|
const HASHED_ASSETS = [];
|
|
|
|
// Core app shell -- cached on install for instant startup
|
|
const APP_SHELL = [
|
|
'/',
|
|
...HASHED_ASSETS.map((p) => '/' + p),
|
|
'/vendor/xterm.min.js',
|
|
'/vendor/xterm-addon-fit.min.js',
|
|
'/vendor/xterm-addon-unicode11.min.js',
|
|
'/vendor/xterm.css',
|
|
'/icon-192.png',
|
|
'/icon-512.png',
|
|
'/manifest.json',
|
|
].map(B);
|
|
|
|
// --- 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;
|
|
})
|
|
// ignoreSearch, or the precache can never be hit. `renderIndexHtml` runs
|
|
// `cacheBustAssets`, which appends `?v=<mtime>` to EVERY same-origin
|
|
// `.js`/`.css` reference — content-hashed names included, so the page asks
|
|
// for `/app.556be563.js?v=1789423735875` while the precache stored
|
|
// `/app.556be563.js`. `caches.match` is query-sensitive by default, so
|
|
// every precached entry was unreachable and only `/`, the icons and the
|
|
// manifest could ever be served offline.
|
|
//
|
|
// It also makes runtime-cached entries survive an mtime change: the same
|
|
// file re-requested under a new `?v=` still matches the copy already held.
|
|
.catch(() => caches.match(request, { ignoreSearch: true }))
|
|
);
|
|
});
|
|
|
|
// --- 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, approvalId, urgency, actions } = payload;
|
|
|
|
const options = {
|
|
body: body || '',
|
|
tag: tag || 'codeman-default',
|
|
icon: B('/icon-192.png'),
|
|
badge: B('/icon-192.png'),
|
|
data: { sessionId, approvalId, url: sessionId ? B(`/?session=${sessionId}`) : B('/') },
|
|
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, approvalId, url } = event.notification.data || {};
|
|
const targetUrl = url || B('/');
|
|
const action = event.action || null;
|
|
|
|
// Approve/Deny action buttons answer the Approvals Inbox item directly from
|
|
// the worker, so they work with NO Codeman tab open (lock-screen approvals).
|
|
// Same-origin POST with cookie credentials; the CSRF Origin check passes
|
|
// because a service worker fetch carries the worker's own (same) origin.
|
|
if ((action === 'approve' || action === 'deny') && approvalId) {
|
|
event.waitUntil(
|
|
fetch(B(`/api/approvals/${encodeURIComponent(approvalId)}/answer`), {
|
|
method: 'POST',
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ action }),
|
|
}).then((res) => {
|
|
if (res && res.ok) return undefined;
|
|
// 401/404/409: let the human see the state by falling back to a tab.
|
|
return openOrFocus(sessionId, action, approvalId, targetUrl);
|
|
}).catch(() => openOrFocus(sessionId, action, approvalId, targetUrl))
|
|
);
|
|
return;
|
|
}
|
|
|
|
event.waitUntil(openOrFocus(sessionId, action, approvalId, targetUrl));
|
|
});
|
|
|
|
function openOrFocus(sessionId, action, approvalId, targetUrl) {
|
|
return 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,
|
|
approvalId,
|
|
action,
|
|
});
|
|
return client.focus();
|
|
}
|
|
}
|
|
// No existing tab -- open a new one
|
|
return self.clients.openWindow(targetUrl);
|
|
});
|
|
}
|