Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
60 changes: 59 additions & 1 deletion api/web/src/base/overlay-class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,34 @@ import { bbox } from '@turf/bbox'
import type { LngLatBoundsLike, LayerSpecification, VectorTileSource, RasterTileSource, GeoJSONSource } from 'maplibre-gl'
import cotStyles from './utils/styles.ts'
import { std, stdurl } from '../std.js';
import KV from './kv.ts';
import { db, type DBOverlay } from '../database.ts';
import { useMapStore } from '../stores/map.js';
import ProfileConfig from './profile.ts';
import Subscription from './subscription.ts';
import { FeatureVisibility } from '../stores/modules/feature-visibility.ts';

/**
* Remove the auth token query parameter from TileJSON tile URLs so a cached
* document never embeds a session token. String surgery instead of `new URL()`
* because the URLs contain literal `{z}/{x}/{y}` template placeholders.
*/
function stripTilesToken(tiles: string[]): string[] {
return tiles.map((t) => t
.replace(/([?&])token=[^&]*&/, '$1')
.replace(/[?&]token=[^&]*$/, ''));
}

/**
* Append the current session token to cached TileJSON tile URLs, mirroring
* what the server does when the /tiles endpoint is called with ?token=.
*/
function applyTilesToken(tiles: string[], token: string | null): string[] {
if (!token) return tiles;

return tiles.map((t) => `${t}${t.includes('?') ? '&' : '?'}token=${encodeURIComponent(token)}`);
}

/**
* @class
*/
Expand Down Expand Up @@ -242,6 +264,10 @@ export default class Overlay {
}

for (const l of this.styles) {
// A previous partially-failed initOverlays() attempt may have
// already registered this layer; addLayer throws on duplicate ids.
if (mapStore.map.getLayer(l.id)) continue;

if (before) {
mapStore.map.addLayer(l, before);
} else {
Expand Down Expand Up @@ -337,13 +363,45 @@ export default class Overlay {
const url = stdurl(this.url);
if (token) url.searchParams.set('token', token);

// The TileJSON is derived server-side (potentially from upstream
// ESRI/TileJSON metadata) so it can't be constructed locally, but
// it is stable per source URL β€” cache it in KV so warm boots and
// offline resumes skip the network entirely.
const cacheKey = `tilejson::${this.id}::${this.url}`;

let tileJSON: TileJSON | undefined;
try {
const cached = await KV.value(cacheKey);
if (cached) {
tileJSON = JSON.parse(cached) as TileJSON;
tileJSON.tiles = applyTilesToken(tileJSON.tiles, token);
}
} catch (err) {
console.warn(`Failed to read cached TileJSON for overlay ${this.id}:`, err);
}

// A failed /tiles lookup (network blip, expired token, deleted
// basemap upstream, etc.) must NOT abort map initialization or
// every other overlay disappears with it. Capture the error on
// the overlay so MenuOverlays.vue surfaces an "Issue" badge,
// and skip addSource so addLayers later no-ops cleanly.
try {
const tileJSON = await std(url.toString()) as TileJSON
if (!tileJSON) {
tileJSON = await std(url.toString()) as TileJSON;

try {
// Tile URLs carry the session token as a query param;
// strip it before caching (tokens rotate between
// sessions) β€” applyTilesToken() restores the current
// one on read.
await KV.update(cacheKey, JSON.stringify({
...tileJSON,
tiles: stripTilesToken(tileJSON.tiles)
}));
} catch (err) {
console.warn(`Failed to cache TileJSON for overlay ${this.id}:`, err);
}
}

if (!mapStore.map.getSource(String(this.id))) {
mapStore.map.addSource(String(this.id), {
Expand Down
32 changes: 23 additions & 9 deletions api/web/src/components/CloudTAK/Map.vue
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,7 @@ import { useAppStore } from '../../stores/app.ts';
import { DrawToolMode } from '../../stores/modules/draw.ts';
import { useFloatStore } from '../../stores/float.ts';
import { liveQuery } from 'dexie';
import { isTransientDbError } from '../../database.ts';
import Upload from '../util/Upload.vue';
import { stdurl } from '../../std.ts';
import ProfileConfig from '../../base/profile.ts';
Expand Down Expand Up @@ -637,18 +638,29 @@ const mapSideOffset = computed(() => {
return Math.max(mapStore.toastOffset.x - 10, 0);
});

function onWindowError(evt: ErrorEvent) {
console.error(evt);
evt.preventDefault();

// Prefer the original error so its name survives; transient
// IndexedDB failures (WebKit invalidating the connection during an
// iOS suspend) are recovered by withDbRetry and must not modal.
const err = evt.error instanceof Error ? evt.error : new Error(evt.message);
if (isTransientDbError(err)) return;

emit('err', err);
}

function onWindowResize() {
height.value = window.innerHeight;
width.value = window.innerWidth;
}

onMounted(async () => {
// ensure uncaught errors in the stack are captured into vue context
window.addEventListener('error', (evt) => {
console.error(evt);
evt.preventDefault();
emit('err', new Error(evt.message));
});
window.addEventListener('error', onWindowError);

window.addEventListener('resize', () => {
height.value = window.innerHeight;
width.value = window.innerWidth;
});
window.addEventListener('resize', onWindowResize);

if (!mapRef.value) throw new Error('Map Element could not be found - Please refresh the page and try again');
await mapStore.init(mapRef.value);
Expand Down Expand Up @@ -711,6 +723,8 @@ onMounted(async () => {
});

onBeforeUnmount(() => {
window.removeEventListener('error', onWindowError);
window.removeEventListener('resize', onWindowResize);
inviteChannel?.close();
void mapStore.destroy();
});
Expand Down
61 changes: 59 additions & 2 deletions api/web/src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,11 @@ db.version(2).stores({

let reopenPromise: Promise<void> | null = null;

// Increments on every successful (re)open so callers can tell whether the
// connection they observed failing has already been replaced.
let connectionGeneration = 0;
let forceReopenPromise: Promise<void> | null = null;

// An IndexedDB open issued while a page is unloading leaves WKWebView's
// database process holding an orphaned request that deadlocks the next
// page's first IndexedDB operation until the app is fully restarted. Close
Expand Down Expand Up @@ -366,6 +371,7 @@ export async function ensureDatabase(): Promise<void> {

try {
await db.open();
connectionGeneration++;
return;
} catch (err) {
if (db.isOpen()) return;
Expand All @@ -383,6 +389,41 @@ export async function ensureDatabase(): Promise<void> {
return reopenPromise;
}

/**
* Close and reopen the IndexedDB connection.
*
* WebKit on iOS can invalidate the underlying connection while the app is
* suspended without firing a close event, leaving Dexie reporting an open β€”
* but dead β€” connection whose every request fails with UnknownError
* ("attempt to get records from the database without an in-progress
* transaction"). ensureDatabase() is a no-op in that state, so recovery
* requires an explicit close first.
*
* Pass the generation observed before the failing operation so a connection
* another caller already replaced is not needlessly closed again.
*/
export async function forceReopenDatabase(sinceGeneration = connectionGeneration): Promise<void> {
if (shuttingDown) return;

if (!forceReopenPromise) {
if (sinceGeneration !== connectionGeneration) return ensureDatabase();

forceReopenPromise = (async () => {
try {
db.close();
} catch (err) {
console.warn('Failed to close database before reopen', err);
}

await ensureDatabase();
})().finally(() => {
forceReopenPromise = null;
});
}

return forceReopenPromise;
}

const TRANSIENT_DB_ERROR_NAMES = new Set([
'AbortError',
'DatabaseClosedError',
Expand All @@ -399,7 +440,11 @@ const TRANSIENT_DB_ERROR_MESSAGES = [
'transaction aborted',
'objectstore',
'connection is closing',
'premature commit'
'premature commit',
// WebKit UnknownError after iOS suspends the app mid-transaction; matched
// on message because wrappers (window.onerror, String(reason)) lose the
// DOMException name.
'in-progress transaction'
];

db.on('close', () => {
Expand All @@ -418,14 +463,26 @@ export async function withDbRetry<T>(fn: () => Promise<T>, attempts = 4): Promis
let lastError: unknown;

for (let attempt = 0; attempt < attempts; attempt++) {
let generation = connectionGeneration;

try {
await ensureDatabase();
generation = connectionGeneration;
return await fn();
} catch (err) {
lastError = err;
if (!isTransientDbError(err)) throw err;

await ensureDatabase();
// Dexie can still report the connection open after WebKit has
// invalidated it (iOS suspend fires no close event), in which
// case ensureDatabase() alone is a no-op and every retry would
// fail identically β€” force a real close+reopen.
try {
await forceReopenDatabase(generation);
} catch (reopenErr) {
console.warn('Failed to reopen database during retry', reopenErr);
}

await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
}
}
Expand Down
Loading
Loading