Build and publish docker image / Build and push image to Gitea's registry (push) Successful in 41s
Sessions silently dropped ~15-30 min after login on the deployed instance. Two compounding bugs in hooks.server.ts: - The refresh path was gated behind `if (accessToken)`, but the access cookie's maxAge (15m) equals the JWT TTL, so the browser deletes the cookie exactly when the JWT expires. Subsequent requests arrived with no access cookie and skipped refresh entirely -> logout. Rotation now keys off the `refresh` cookie (30d) instead of an expired access token. - The old code revoked the refresh token without issuing a new `refresh` cookie (it assumed /api/auth/refresh handled rotation, but nothing calls it). Now does a full one-time-use rotation: revoke + new access + new refresh cookie -> sliding 30d session. Also adopt the SvelteKit PWA proactive auto-refresh standard: the worker already does skipWaiting()+clients.claim(); add watchSwUpdates() in the root layout to call registration.update() on load/focus/reconnect/hourly and reload once on controllerchange (guarded against first-install reload). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
111 lines
3.5 KiB
Svelte
111 lines
3.5 KiB
Svelte
<script lang="ts">
|
|
import '../app.css';
|
|
import { dev } from '$app/environment';
|
|
import { onMount } from 'svelte';
|
|
import type { LayoutData } from './$types';
|
|
|
|
interface BeforeInstallPromptEvent extends Event {
|
|
prompt(): Promise<void>;
|
|
}
|
|
|
|
let { data, children }: { data: LayoutData; children: any } = $props();
|
|
|
|
let installPrompt: BeforeInstallPromptEvent | null = $state(null);
|
|
|
|
onMount(async () => {
|
|
// register service worker
|
|
if ('serviceWorker' in navigator) {
|
|
try {
|
|
await navigator.serviceWorker.register('/service-worker.js', {
|
|
type: dev ? 'module' : 'classic'
|
|
});
|
|
} catch (e) {
|
|
console.warn('SW registration failed', e);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Standard: proactive SW auto-refresh — keep installed PWAs off a stale build.
|
|
// Worker does skipWaiting()+clients.claim(); here we drive update checks
|
|
// (load/focus/reconnect/hourly) and reload once when the new worker takes over.
|
|
$effect(() => {
|
|
if (!('serviceWorker' in navigator)) return;
|
|
|
|
let refreshing = false;
|
|
// Don't reload on first install (no prior controller → claim fires controllerchange once).
|
|
const hadController = !!navigator.serviceWorker.controller;
|
|
const onControllerChange = () => {
|
|
if (refreshing || !hadController) return;
|
|
refreshing = true;
|
|
location.reload();
|
|
};
|
|
navigator.serviceWorker.addEventListener('controllerchange', onControllerChange);
|
|
|
|
let reg: ServiceWorkerRegistration | undefined;
|
|
const checkForUpdate = () => void reg?.update().catch(() => {});
|
|
const onVisible = () => {
|
|
if (document.visibilityState === 'visible') checkForUpdate();
|
|
};
|
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
|
|
navigator.serviceWorker.ready.then((r) => {
|
|
reg = r;
|
|
checkForUpdate(); // on load
|
|
timer = setInterval(checkForUpdate, 60 * 60 * 1000); // hourly
|
|
});
|
|
document.addEventListener('visibilitychange', onVisible); // on focus
|
|
window.addEventListener('online', checkForUpdate); // on reconnect
|
|
|
|
return () => {
|
|
navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange);
|
|
document.removeEventListener('visibilitychange', onVisible);
|
|
window.removeEventListener('online', checkForUpdate);
|
|
if (timer) clearInterval(timer);
|
|
};
|
|
});
|
|
|
|
// capture install prompt
|
|
$effect(() => {
|
|
const handler = (e: Event) => {
|
|
e.preventDefault();
|
|
installPrompt = e as BeforeInstallPromptEvent;
|
|
};
|
|
window.addEventListener('beforeinstallprompt', handler);
|
|
return () => window.removeEventListener('beforeinstallprompt', handler);
|
|
});
|
|
|
|
async function installApp() {
|
|
if (!installPrompt) return;
|
|
await installPrompt.prompt();
|
|
installPrompt = null;
|
|
}
|
|
</script>
|
|
|
|
<div class="min-h-screen bg-gray-950 text-gray-100">
|
|
<header class="border-b border-gray-800 bg-gray-900">
|
|
<div class="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
|
<a href="/" class="flex items-center gap-2 text-lg font-semibold text-white">
|
|
<span class="text-emerald-400">▼</span> VidDL
|
|
</a>
|
|
<nav class="flex items-center gap-4 text-sm text-gray-400">
|
|
{#if data.user}
|
|
<a href="/jobs" class="hover:text-white">Jobs</a>
|
|
<a href="/settings" class="hover:text-white">Settings</a>
|
|
{#if installPrompt}
|
|
<button onclick={installApp} class="text-emerald-400 hover:text-emerald-300">
|
|
Install app
|
|
</button>
|
|
{/if}
|
|
<form method="POST" action="/logout">
|
|
<button class="hover:text-white">Log out</button>
|
|
</form>
|
|
{/if}
|
|
</nav>
|
|
</div>
|
|
</header>
|
|
|
|
<main class="mx-auto max-w-3xl px-4 py-6">
|
|
{@render children()}
|
|
</main>
|
|
</div>
|