feat(errors): Introduce session recovery logic with Service Worker caching for expired sessions and failed auth attempts

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-08 21:10:26 -07:00
parent cb789435d8
commit ea2f1a4ea4
3 changed files with 111 additions and 6 deletions

View file

@ -7,12 +7,14 @@ let recoveryPromise: Promise<string> | null = null;
export async function createSession(apiBaseUrl: string, personaId?: string): Promise<string> {
try {
const body = personaId ? JSON.stringify({ persona_id: personaId }) : undefined;
const res = await fetch(`${apiBaseUrl}/session`, {
method: 'POST',
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body,
});
const init: RequestInit = personaId
? {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ persona_id: personaId }),
}
: { method: 'POST' };
const res = await fetch(`${apiBaseUrl}/session`, init);
if (!res.ok) throw new Error(`POST /session failed: ${res.status}`);
const { session_id } = (await res.json()) as { session_id: string };
sessionStorage.setItem(SESSION_STORAGE_KEY, session_id);

View file

@ -0,0 +1,85 @@
/// <reference lib="webworker" />
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
declare const self: ServiceWorkerGlobalScope;
// Workbox injects the precache manifest here at build time
precacheAndRoute(self.__WB_MANIFEST);
// Remove outdated caches from previous SW versions
cleanupOutdatedCaches();
// Skip waiting immediately so the new SW activates as soon as it installs
self.skipWaiting();
self.addEventListener('activate', (event) => {
event.waitUntil(self.clients.claim());
});
interface PushPayload {
title?: string;
body?: string;
icon?: string;
tag?: string;
url?: string;
audioUrl?: string;
}
interface NotificationData {
url: string;
audioUrl?: string | undefined;
}
self.addEventListener('push', (event) => {
const data = event.data?.json() as PushPayload | undefined;
const title = data?.title ?? 'Companion';
const notifData: NotificationData = {
url: data?.url ?? '/',
audioUrl: data?.audioUrl,
};
const options: NotificationOptions = {
body: data?.body ?? '',
icon: data?.icon ?? '/assets/icons/icon-192.png',
badge: '/assets/icons/icon-192.png',
tag: data?.tag ?? 'companion-nag',
data: notifData,
};
event.waitUntil(self.registration.showNotification(title, options));
});
self.addEventListener('notificationclick', (event) => {
event.notification.close();
const notifData = event.notification.data as NotificationData;
const url = notifData.url ?? '/';
const audioUrl = notifData.audioUrl;
event.waitUntil(
self.clients
.matchAll({ type: 'window', includeUncontrolled: true })
.then(async (clientList) => {
// Try to find an existing PWA window to focus
let targetClient: WindowClient | null = null;
for (const client of clientList) {
if ('focus' in client) {
targetClient = client as WindowClient;
break;
}
}
if (!targetClient) {
targetClient = await self.clients.openWindow(url);
} else {
await targetClient.focus();
}
// Post audio playback message if the notification carries an audioUrl
if (audioUrl && targetClient) {
targetClient.postMessage({ type: 'play-tts', url: audioUrl });
}
}),
);
});

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2022", "WebWorker"],
"noEmit": true,
"skipLibCheck": true,
"isolatedModules": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true
},
"include": ["src/sw.ts"]
}