Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/lib/limits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const Limit = Type.Integer({
export const LimitAll = Type.Integer({
default: 10,
minimum: 0,
maximum: 100,
maximum: 1000,
description: 'Limit the number of responses returned. Use 0 to return all results without pagination; otherwise the limit is capped at 100.'
Comment thread
ingalls marked this conversation as resolved.
Outdated
});

Expand Down
14 changes: 7 additions & 7 deletions api/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions api/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@
"hls.js": "^1.6.5",
"imask": "^6.0.0",
"jsonata": "^2.0.4",
"maplibre-gl": "5.23.0",
"maplibre-gl": "5.24.0",
"milsymbol": "^3.0.2",
"moment": "^2.29.3",
"openapi-fetch": "^0.17.0",
Expand Down Expand Up @@ -112,4 +112,4 @@
"> 1%",
"last 2 versions"
]
}
}
228 changes: 144 additions & 84 deletions api/web/public/sw.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,55 +3,86 @@ const VERSION = params.get('v') || Math.random().toString(36).substring(2, 8);
const BUILD = params.get('build') || Math.random().toString(36).substring(2, 8);

const CACHE_NAME = `cloudtak-cache-${VERSION}-${BUILD}`;
const CACHE_PREFIX = 'cloudtak-cache-';

// Vite emits one manifest entry per HTML page. Each must be precached under
// the navigable path nginx serves it from (the browser never requests the
// raw `.html` — those are 302'd away by the nginx config).
const ENTRY_HTML_TO_PATH = {
'index.html': '/',
'admin.html': '/admin',
'connection.html': '/connection',
'docs.html': '/docs',
'video.html': '/video',
};

/**
* Walk the Vite manifest and return every URL that must be precached.
* Both `imports` and `dynamicImports` are followed transitively: lazy
* chunks (icons, floating UI, per-route components) only appear under
* `dynamicImports` and must not be missed or the post-update reload
* will 404 on them.
*/
function collectAssetsFromManifest(manifest) {
const assets = new Set(['/']);
const visited = new Set();

const walk = (key) => {
if (!key || visited.has(key)) return;
visited.add(key);

const entry = manifest[key];
if (!entry) return;

if (entry.file) {
if (entry.file.endsWith('.html')) {
const navPath = ENTRY_HTML_TO_PATH[entry.file];
if (navPath) assets.add(navPath);
} else {
assets.add(entry.file);
}
}

self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const cache = await caches.open(CACHE_NAME);
for (const cssFile of entry.css || []) assets.add(cssFile);
for (const imported of entry.imports || []) walk(imported);
for (const imported of entry.dynamicImports || []) walk(imported);
};

const assets = new Set(['/']);
for (const key of Object.keys(manifest)) walk(key);

try {
const res = await fetch('./.vite/manifest.json');

if (res.ok) {
const manifest = await res.json();

Object.values(manifest).forEach((entry) => {
if (entry.file && !entry.file.endsWith('.html')) {
assets.add(entry.file);
}

for (const imported of entry.imports || []) {
const dep = manifest[imported];
if (dep?.file && !dep.file.endsWith('.html')) {
assets.add(dep.file);
}
}

for (const cssFile of entry.css || []) {
assets.add(cssFile);
}
});
}
} catch (err) {
console.warn('Failed to obtain Vite Manifest:', err);
}
return Array.from(assets);
}

const urls = Array.from(assets);
async function fetchManifest() {
try {
const res = await fetch('./.vite/manifest.json');
if (!res.ok) return null;
return await res.json();
} catch (err) {
console.warn('[SW] Failed to obtain Vite manifest:', err);
return null;
}
}

const results = await Promise.allSettled(
urls.map(url => cache.add(url))
);
self.addEventListener('install', (event) => {
event.waitUntil((async () => {
const manifest = await fetchManifest();
const urls = manifest ? collectAssetsFromManifest(manifest) : ['/'];

const failedUrls = results
.filter(result => result.status === 'rejected')
.map((result, index) => urls[index]);
const cache = await caches.open(CACHE_NAME);

if (failedUrls.length > 0) {
console.error('Failed to cache the following URLs:', failedUrls);
// Atomic precache: if ANY URL fails, install rejects and the
// browser keeps the old SW installed. Prefer cache.addAll() (the
// spec'd atomic variant); fall back to parallel cache.add() for
// test doubles that don't implement addAll. A single rejection
// aborts install in either branch.
if (typeof cache.addAll === 'function') {
await cache.addAll(urls);
} else {
console.log('All resources cached successfully.');
await Promise.all(urls.map((url) => cache.add(url)));
}

console.log(`[SW] Precached ${urls.length} assets into ${CACHE_NAME}`);
})());
});

Expand All @@ -61,64 +92,93 @@ self.addEventListener('message', (event) => {
}
});

self.addEventListener('activate', async (event) => {
self.addEventListener('activate', (event) => {
event.waitUntil((async () => {
const keys = await caches.keys();

await Promise.all(
keys.map((key) => {
if (key !== CACHE_NAME) {
return caches.delete(key);
}
})
);
})());
// Only purge old CloudTAK caches once the new cache is proven
// usable (contains the root shell). If anything is wrong, leave
// previous generations in place so clients keep a working app.
const newCache = await caches.open(CACHE_NAME);
const rootShell = await newCache.match('/');

if (rootShell) {
const keys = await caches.keys();
await Promise.all(
keys
.filter((key) => key !== CACHE_NAME && key.startsWith(CACHE_PREFIX))
.map((key) => caches.delete(key))
);
} else {
console.warn(`[SW] ${CACHE_NAME} missing root shell; keeping old caches.`);
}

await clients.claim();
await self.clients.claim();
})());
});

/**
* Opportunistic runtime caching is intentionally narrow:
* - `/assets/*` fingerprinted build output, immutable by URL.
* - `/logos/*` PWA touch-icons and favicons referenced from every
* entry HTML's `<link>` tags. Non-fingerprinted but
* rotated with the cache generation on activate.
* Everything else - HTML, `/icons/*` sprite data, plugin responses - is
* either precached or goes straight to the network, so an nginx SPA
* fallback response cannot be cached under an arbitrary path.
*/
function isRuntimeCacheable(url) {
return url.pathname.startsWith('/assets/')
|| url.pathname.startsWith('/logos/');
}

/**
* Navigation fallback when the network is unreachable. Prefers the cached
* entry shell that matches the request path, then falls back to `/`.
*/
async function navigationFallback(cache, requestUrl) {
const pathname = requestUrl.pathname;

for (const entryPath of Object.values(ENTRY_HTML_TO_PATH)) {
if (entryPath !== '/' && pathname.startsWith(entryPath)) {
const match = await cache.match(entryPath);
if (match) return match;
}
}

return cache.match('/');
}

self.addEventListener('fetch', (event) => {
if (event.request.method !== 'GET') return;

const url = new URL(event.request.url);
url.hash = '';

if (event.request.method !== 'GET') return;

// Only handle same-origin requests
if (url.origin !== self.location.origin) return;
if (event.request.url.includes('/api')) return;
if (url.pathname.startsWith('/api')) return;

event.respondWith(
(async () => {
const cache = await caches.open(CACHE_NAME);

// Cache First Strategy
const cachedResponse = await cache.match(url.toString());
if (cachedResponse) {
return cachedResponse;
}
event.respondWith((async () => {
const cache = await caches.open(CACHE_NAME);

try {
const networkResponse = await fetch(event.request);
const cachedResponse = await cache.match(url.toString());
if (cachedResponse) return cachedResponse;

// Cache valid responses
if (networkResponse && networkResponse.status === 200) {
cache.put(url.toString(), networkResponse.clone());
}
try {
const networkResponse = await fetch(event.request);

return networkResponse;
} catch (error) {
console.error(`[SW] Fetch failed for ${url.toString()}`, error);
if (networkResponse && networkResponse.status === 200 && isRuntimeCacheable(url)) {
cache.put(url.toString(), networkResponse.clone());
Comment thread
ingalls marked this conversation as resolved.
Outdated
}

// Fallback for navigation (SPA)
if (event.request.mode === 'navigate') {
const cachedIndex = await cache.match('/index.html');
if (cachedIndex) return cachedIndex;
const cachedRoot = await cache.match('/');
if (cachedRoot) return cachedRoot;
}
return networkResponse;
} catch (error) {
console.error(`[SW] Fetch failed for ${url.toString()}`, error);

throw error;
if (event.request.mode === 'navigate') {
const fallback = await navigationFallback(cache, url);
if (fallback) return fallback;
}
})()
);

throw error;
}
})());
});
8 changes: 6 additions & 2 deletions api/web/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,7 @@ import ChannelChangeModal from './components/CloudTAK/Menu/ChannelChangeModal.vu
import { WorkerMessageType } from './base/events.ts';
import type { WorkerMessage } from './base/events.ts';
import { db } from './base/database.ts';
import { getPageServiceWorkerBuildId } from './base/service-worker.ts';
import { getPageServiceWorkerBuildId, markUpdateRequestedByThisTab } from './base/service-worker.ts';
import { useMapStore } from './stores/map.ts';

const router = useRouter();
Expand All @@ -208,8 +208,12 @@ const pendingRegistration = ref<ServiceWorkerRegistration | null>(null);
const applyUpdate = () => {
const waiting = pendingRegistration.value?.waiting;
if (waiting) {
// Tell service-worker.ts that THIS tab initiated the update, so its
// controllerchange handler auto-reloads us. Other tabs will see the
// same controllerchange, not find this flag, and surface their own
// prompt instead of silently reloading.
markUpdateRequestedByThisTab();
waiting.postMessage('SKIP_WAITING');
// controllerchange handler in main.ts will reload the page
} else {
window.location.reload();
}
Expand Down
Loading
Loading