ekke 486480eda6 Carto base maps: send the CARTO API key
Since about 28 August 2026 CARTO stamps an "API KEY REQUIRED" watermark into
every basemaps.cartocdn.com tile requested without a key. The request still
returns HTTP 200, so nothing fails — Carto Light and Carto Dark simply started
drawing the watermark over the map.

Both layers now build their tile URL through cartoTileUrl(), which appends
?key=. The parameter name matters: api_key= is silently ignored and returns the
same watermarked tile as no key at all, so a wrong guess looks right until the
map is looked at. Verified by checksum that the URLs the map generates return
the clean tiles for both styles.

The key comes from VITE_CARTO_API_KEY in .env.local, which is git-ignored, so it
is not in the source and rotating it is a configuration change; .env.example
documents the variable. If it is missing, a console warning says so rather than
leaving only a watermark to explain it.

The key is inlined into the built bundle in dist/ and sent with every tile
request, so it is visible to anyone using the application. That is expected for
a tile key; the exposure is quota, not access.

Cached watermarked tiles are not a concern: the Service Worker keys tiles by
full URL including the query string, so keyed URLs are fetched fresh and the old
entries age out. No Service Worker bump — HTML is network-first, so the new
bundle is picked up without one, and a bump would delete every offline tile
cache (flagged separately).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 14:39:26 +02:00
2026-03-04 12:59:40 +01:00
2026-03-04 12:59:40 +01:00
2026-03-04 12:59:40 +01:00

LUPMIS PWA with Offline SQLite and Maps

A Progressive Web App with:

  • OpenLayers map with ol-ext LayerSwitcher for base map selection
  • SQLocal for SQLite database in the browser (via OPFS)
  • BroadcastChannel for cross-tab synchronization
  • Service Worker for asset caching and offline support
  • Vite for development and building

Features

  • 🗺️ Interactive map with 5 base layers (OSM, Satellite, Topo, Carto Light/Dark)
  • 📍 Click map to set coordinates, markers colored by category
  • 💾 Offline SQLite database (data persists in browser)
  • 🔄 Cross-tab sync via BroadcastChannel
  • 📴 Works offline (cached assets + up to 500 map tiles)
  • 📱 Installable as PWA on mobile and desktop

Architecture

┌─────────────────────────────────────────────────────────────────┐
│                         Browser                                  │
├─────────────────────────────────────────────────────────────────┤
│                                                                  │
│  ┌──────────┐    ┌──────────┐    ┌──────────┐                  │
│  │  Tab 1   │    │  Tab 2   │    │  Tab 3   │                  │
│  │ main.js  │    │ main.js  │    │ main.js  │                  │
│  │ SQLocal  │    │ SQLocal  │    │ SQLocal  │                  │
│  └────┬─────┘    └────┬─────┘    └────┬─────┘                  │
│       │               │               │                         │
│       └───────────────┼───────────────┘                         │
│                       │                                         │
│              BroadcastChannel                                   │
│         (notifies other tabs of changes)                        │
│                       │                                         │
│                       ▼                                         │
│              ┌────────────────┐                                 │
│              │     OPFS       │  ← Single database file         │
│              │ (lupmis.db)    │    shared by all tabs           │
│              └────────────────┘                                 │
│                                                                  │
│  ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─   │
│                                                                  │
│              ┌────────────────┐                                 │
│              │ Service Worker │  ← Caches assets for offline    │
│              │    (sw.js)     │                                 │
│              └────────────────┘                                 │
│                                                                  │
└─────────────────────────────────────────────────────────────────┘

Why This Architecture?

Initially we considered using a SharedWorker to manage a single database connection. However:

  1. SQLocal already uses its own internal worker - It handles OPFS access internally
  2. OPFS handles file coordination - Multiple SQLocal instances can access the same database file
  3. Simpler is better - BroadcastChannel provides easy cross-tab notification without the complexity of SharedWorker bundling issues in Vite

The result is simpler code that works reliably with Vite's build system.

File Structure

project/
├── index.html              # Entry HTML with map container
├── main.js                 # App entry point
├── vite.config.js          # Vite configuration
├── package.json
│
├── src/
│   ├── components/
│   │   └── MapView.js      # OpenLayers map with ol-ext LayerSwitcher
│   ├── database.js         # SQLocal + BroadcastChannel
│   └── pwa.js              # PWA utilities (install, offline)
│
└── public/
    ├── sw.js               # Service Worker (caching)
    ├── manifest.json       # PWA manifest
    ├── offline.html        # Offline fallback page
    └── icons/              # PWA icons

Setup

# Install dependencies
npm install

# Start development server
npm run dev

# Build for production
npm run build

# Preview production build
npm run preview

Usage

Basic Database Operations

import { sql, dbReady, addLocation, getLocations } from './src/database.js';

// Wait for database to be ready
await dbReady;

// Add a location
await addLocation('Water Point', -1.5234, 7.4567, {
  description: 'Main village well',
  category: 'water'
});

// Get all locations
const locations = await getLocations();

// Direct SQL queries using tagged templates
const results = await sql`SELECT * FROM locations WHERE category = ${'water'}`;

MapView Component

import { MapView } from './src/components/MapView.js';

// Create map centered on Ghana
const map = new MapView('map-container', {
  center: [-1.5, 7.5],    // [longitude, latitude]
  zoom: 7,
  basemap: 'osm'          // 'osm' | 'satellite' | 'topo' | 'carto-light' | 'carto-dark'
});

// Set category-based marker colors
map.setCategoryStyles({
  'water': { color: '#3b82f6' },
  'school': { color: '#f59e0b' },
  'health': { color: '#ef4444' },
});

// Add markers from database
const locations = await getLocations();
map.addMarkers(locations);

// Handle map clicks
map.onClick((lon, lat, feature) => {
  if (feature) {
    // Clicked on existing marker
    console.log('Selected:', feature.get('name'));
  } else {
    // Clicked on empty space - use coordinates
    document.getElementById('longitude').value = lon.toFixed(6);
    document.getElementById('latitude').value = lat.toFixed(6);
  }
});

// Zoom to a location
map.zoomTo(-1.5, 7.5, 14);

// Fit view to show all markers
map.fitToMarkers();

// Select a marker by ID
map.selectMarker(locationId);

Available Base Maps

Name Key Source
OpenStreetMap osm OpenStreetMap
Satellite satellite Esri World Imagery
Topographic topo OpenTopoMap
Carto Light carto-light CARTO
Carto Dark carto-dark CARTO

Cross-Tab Synchronization

import { onDatabaseChange } from './src/database.js';

// Listen for changes from other tabs
onDatabaseChange((change) => {
  console.log('Database changed:', change);
  // { table: 'locations', action: 'INSERT', id: 5, timestamp: 1234567890 }
  
  if (change.table === 'locations') {
    refreshLocationsList();
  }
});

PWA Features

import { initPWA, isOnline, onOfflineChange } from './src/pwa.js';

// Initialize PWA
await initPWA();

// Check online status
if (isOnline()) {
  syncWithServer();
}

// React to offline/online changes
onOfflineChange((offline) => {
  if (offline) {
    showOfflineBanner();
  } else {
    hideOfflineBanner();
    syncWithServer();
  }
});

Deployment

Required Headers

Your web server must send these headers for OPFS to work:

Cross-Origin-Opener-Policy: same-origin
Cross-Origin-Embedder-Policy: require-corp

Nginx Configuration

server {
    listen 443 ssl http2;
    server_name your-domain.com;
    
    root /var/www/dist;
    index index.html;
    
    # Required for OPFS/SQLite
    add_header Cross-Origin-Opener-Policy "same-origin" always;
    add_header Cross-Origin-Embedder-Policy "require-corp" always;
    
    # Cache static assets
    location ~* \.(js|css|wasm|png|jpg|ico|svg)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        add_header Cross-Origin-Opener-Policy "same-origin" always;
        add_header Cross-Origin-Embedder-Policy "require-corp" always;
    }
    
    # SPA fallback
    location / {
        try_files $uri $uri/ /index.html;
    }
}

Apache (.htaccess)

<IfModule mod_headers.c>
    Header always set Cross-Origin-Opener-Policy "same-origin"
    Header always set Cross-Origin-Embedder-Policy "require-corp"
</IfModule>

OpenResty (Docker)

add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always;

Browser Support

  • Chrome/Edge 89+ - Full support
  • Firefox 111+ - Full support
  • Safari 15.2+ - OPFS supported
  • Mobile - Chrome Android, Safari iOS 15.2+

Troubleshooting

"SecurityError" or "NotAllowedError"

The COOP/COEP headers are missing. Check your server configuration.

Database not persisting

  1. Check that you're using HTTPS (or localhost)
  2. Verify COOP/COEP headers are present (DevTools → Network → check response headers)
  3. Check browser DevTools → Application → Storage → OPFS

Changes not syncing between tabs

The BroadcastChannel should handle this automatically. Check the browser console for any errors.

Vite HMR WebSocket errors

The cross-origin isolation can break Vite's hot reload. Options:

  1. Use vite-plugin-cross-origin-isolation (add to vite.config.js)
  2. Or manually refresh the browser after changes

License

MIT

Description
progressive web app with sqlocal as a mobile app
Readme 96 MiB
Languages
JavaScript 75.2%
HTML 10.6%
CSS 8.5%
PHP 2.7%
Python 1.1%
Other 1.9%