How Meratyn is deployed and how the codebase is organized. Two views, same system.
Delivery
Meratyn ships three ways from one Bun workspaces monorepo. The viewer is a React app served as static files by nginx (Docker) or installed as a Tauri v2 desktop app for Windows, macOS and Linux. The admin console and the share/admin API deploy as containers, and a Jupyter anywidget embeds the viewer in notebooks. Distribution is closed-source: Docker images plus signed desktop installers, no app stores and no update servers.
Runtime topology
Runtime topology. Light-blue links cross a trust or network boundary; the admin API is the control-plane hub. Full editorial overview
Container fleet of the self-hosted profile: the viewer container runs nginx and the sidecar as one image; GeoNode deploys as its own seven-container stack.
Trust boundaries
The desktop app talks to its local sidecar over 127.0.0.1 only; heavier Python processing never leaves the machine unless a remote processing service is configured.
The viewer container reaches the sidecar same-origin at /sidecar — no CORS surface. Set MAPPVIEW_DISABLE_SIDECAR=1 to serve static files only.
The admin API fronts object storage with signed, time-limited layer URLs (LAYER_ACCESS_TTL_SECONDS) and per-tenant storage quotas.
The edge AI proxy authenticates viewers with a token, allowlists models, and rate-limits per client; provider keys never reach the browser.
Air-gapped viewer images set MAPPVIEW_NO_EXTERNAL_CDN=1 at build time. The Vite output guard rejects Meratyn-controlled executable or model CDN URLs, while VITE_PYODIDE_INDEX_URL supplies the required operator-hosted Python mirror; project-authored data and service endpoints remain deployment policy.
Deployment
The same React build ships three ways: the Tauri desktop shell with an on-demand sidecar, an nginx container that reverse-proxies its bundled sidecar same-origin, and the Jupyter wheel with the app embedded in the kernel. The desktop keeps heavy processing local, the container proxies it, and the embed leaves it out.
The root Dockerfile packages the browser version of the app. It uses a Node build stage to run the workspace build for mappview-desktop, then copies apps/mappview/dist into an nginx runtime image. The nginx config serves static assets and falls back to index.html for browser-entry URLs.
The Container build GitHub Actions workflow verifies that the image builds on
every push to main. It does not log in to a registry or publish an image.
Build and tag the image locally for development or publish it through
infrastructure owned by Meratyn.
The image also bundles the optional Python sidecar (uvicorn) and reverse-proxies it at /sidecar, so the browser reaches it same-origin with no CORS; set MAPPVIEW_DISABLE_SIDECAR=1 to run nginx alone. MAPPVIEW_CAPABILITIES is published to both the runtime web configuration and sidecar; it gates authority rather than simply hiding UI. See deployment capabilities for the exact contract. The container does not run the Tauri desktop shell, so workflows that depend on desktop filesystem access still require the installed desktop app. See Run with Docker for what the bundled sidecar does and does not back.
Security
Tauri CSP allowlists tile and style hosts (OpenFreeMap, CARTO).
File access uses dialog-selected paths only.
Native HTTP trust store and mutual TLS
The desktop app issues some remote fetches (tile/URL resolution, OGC
GetCapabilities, and similar) from the native Rust process rather than the
WebView. That path (guarded_http_client in src-tauri/src/lib.rs) trusts the
OS/system certificate store in addition to the bundled Mozilla roots, so a
server signed by an enterprise CA installed on the machine is accepted without
extra configuration.
For endpoints behind mutual TLS (mTLS), the WebView fetch path shows an
interactive OS certificate prompt; the native Rust client cannot, so point it at
a client certificate with environment variables:
Variable
Purpose
MAPPVIEW_HTTP_CA_CERT
Path to a PEM bundle of extra CA certificate(s) to trust, on top of the OS store (for a private CA not installed system-wide).
MAPPVIEW_HTTP_CLIENT_CERT
Path to the client certificate to present. A .pem file (certificate chain plus an unencrypted PKCS#8 private key) or a PKCS#12 bundle (.p12/.pfx).
MAPPVIEW_HTTP_CLIENT_CERT_PASSWORD
Passphrase for a PKCS#12 client certificate. Its presence also forces the PKCS#12 code path.
A .p12/.pfx extension (or a supplied passphrase) selects PKCS#12; any other
path is read as PEM. PEM identities use the default rustls backend; PKCS#12
identities use the platform native-tls backend (SChannel on Windows, Secure
Transport on macOS, OpenSSL on Linux), which also reads the OS trust store.
Convert a PKCS#12 export to PEM with
openssl pkcs12 -in cert.p12 -out cert.pem -nodes if you prefer the rustls path.
A set-but-empty value for any of these variables is treated as unset.
The configured client certificate is held on the shared native HTTP client and
is therefore presented to any HTTPS server that requests one during a native
fetch (tile, style, and OGC hosts a project points at), not only the endpoint the
certificate was issued for. TLS sends a client certificate only when the server
asks for one, so this is not an unconditional disclosure, but configure a client
certificate only when the hosts the app talks to are trusted.
Performance: map rendering on Linux (WebKitGTK)
The desktop app uses the system WebView. On Linux that is WebKitGTK, whose
WebGL/JavaScript pipeline is materially slower than the Chromium engine the
browser build runs in. The most visible effect is map panning at low zoom:
A blank map (no tile layer) pans at a steady 60 FPS at any zoom.
With any tile layer (vector or raster XYZ), FPS collapses to single
digits while tiles are loading, then snaps back to 60 once loading stops,
at the same zoom. Low zoom only makes it constant because panning across the
whole world loads tiles continuously and the cache never settles.
The cost is WebKitGTK processing each newly-loaded tile on the main thread: the
GPU upload of its texture (raster) or vertex buffers (vector) via synchronous
WebGL calls, plus the subsequent fade-in repaint frames. Vector tile parsing and
bucket building run in MapLibre’s Web Workers, so they are not the bottleneck
here. Each tile-integration render cycle measured ~125 ms wall-clock in
WebKitGTK versus a few ms in Chromium. The gap is in WebKitGTK’s WebGL
implementation and compositor pipeline (driver command-stream flush, TextureMapper
GL surface composition), not solely its JavaScriptCore JS engine. This is a
WebView-engine limitation, not a bug in Meratyn, and it does not affect the
browser build or (untested) the macOS/Windows WebViews.
Ruled out during diagnosis (so future investigation does not repeat them):
software rendering (the GPU is used, Intel i915 confirmed), GPU saturation (the
render engine stays ~20% idle), the Tauri IPC file read (~126 ms for a 22 MB
GeoJSON), JSON.parse (~36 ms), KWin compositor latency, renderWorldCopies,
and the globe vs. mercator projection. Preserving the WebGL drawing buffer was
not the cause of those tile-integration spikes, but it still imposed a global
GPU memory/throughput cost. It is now disabled; print and video capture perform
a synchronous redraw immediately before reading the canvas.
To reproduce the measurement: temporarily log MapLibre render events,
dataloading (tile) events, and a requestAnimationFrame counter once per
second; FPS tracks tile-load count inversely. The quickest check is this
frame-rate meter pasted into the WebView devtools console, then pan while
watching the logged FPS:
let f = 0, t = performance.now();(function loop(n) { f++; if (n - t >= 1000) { console.log("FPS", f); f = 0; t = n; } requestAnimationFrame(loop);})(t);
Mitigations (reduce how many tiles load during a pan, since per-tile cost is
fixed by the engine) are not yet implemented: a larger maxTileCacheSize,
512px raster tiles instead of 256px, and fadeDuration: 0, ideally gated to
WebKitGTK so the Chromium-based builds keep full fidelity.
Workspace layout
Workspace ranks. Every viewer package rests on @mappview/core; the console reuses the UI kit and the admin service client; the desktop starts the sidecar on demand. Edge and docs are standalone.
System overview
How the app composes at runtime: sessions own state, renderers subscribe. Annotated version
State flow
The authoring loop: every pass writes durable state into the project session, and the desktop save persists it as .mappview.json.
Engines
Vector conversion: DuckDB-WASM Spatial in-browser; GeoPandas/Shapely on the sidecar or the same vector_ops.py modules in Pyodide — one source of truth, three runtimes.
Spatial SQL: DuckDB, PostGIS (PGlite in-browser) and Apache Sedona (SedonaDB on the sidecar, CereusDB WASM in-browser), with automatic sidecar-first fallback.
Rendering: MapLibre GL JS primary; deck.gl for raster, point-cloud and 3D overlays; CesiumJS globe as a per-pane view mode behind a lazy ~4.8 MB chunk and an optional Ion token.
Contracts: Effect v4 owns programs, schemas and typed errors; TanStack Query owns async lifecycle; TanStack Store holds runtime state. The persisted artifact is the engine-agnostic .mappview.json project (format 0.2.0).
Engine selection at a glance: browser engines handle supported operations; sidecar-backed engines cover capabilities that need them, with browser WASM fallback where available. The status probes and fallback decisions run through TanStack Query; each engine itself is an Effect program. Supported Whitebox and raster operations run in the browser, with optional sidecar paths for native files and tools outside the browser engines.
Overview
Meratyn is a lightweight, cloud-native GIS platform that runs in the web browser, on the desktop, on mobile, and inside Jupyter notebooks, all from a single Bun workspaces monorepo. The UI is a React app that ships as a native desktop app hosted by Tauri v2 and as a browser-based web app, adapting responsively to mobile and small screens. Map rendering uses MapLibre GL JS in the browser webview, with deck.gl used for advanced raster, point cloud, and 3D overlays, and an optional CesiumJS 3D-globe view (see 3D globe view (CesiumJS)) offered as a split pane. @mappview/core owns an application runtime and one disposable project session. Each session separates the persisted document from runtime data, map interaction, UI, persistence, processing, and collaboration capabilities in native TanStack stores. Authored document transitions are recorded by Travels.
Workspace dependency ranks. @mappview/core carries the highest fan-in.
3D globe view (CesiumJS)
The 2D MapLibre map can be joined by a 3D globe rendered with CesiumJS, offered as a split pane rather than a whole-app engine swap. Because the project document is engine-agnostic — it holds plain MappViewLayer records and a MapViewState, not MapLibre objects — a second renderer plugs in by subscribing to the same project session, exactly as the 2D SecondaryMapCanvas panes do. There is no engine abstraction layer: MapLibre stays the default and the primary map, and Cesium is a first-party view mode (not a plugin — the plugin API is MapLibre-typed).
Enabling it. Each secondary pane in the map grid carries a 2D/3D toggle. The globe is only offered when a Cesium Ion token is configured — Cesium World Imagery and Terrain require one — so without a token the toggle is hidden and a project saved with a globe pane opens as the 2D map. The token is resolved through getCesiumIonToken() (@mappview/core), which reads VITE_CESIUM_TOKEN/CESIUM_TOKEN from the build or from a runtime override, so it can be set at build time (CESIUM_TOKEN; see Optional 3D globe credentials) or at runtime with no rebuild. Settings → Environment Variables has a dedicated masked Cesium Ion token field backed by device-local DesktopSettings (localStorage, never the shared project file); useRuntimeEnvironmentVariables projects it into VITE_CESIUM_TOKEN on the window.__MAPPVIEW_RUNTIME_ENV__ global (empty values are not projected, so they cannot blank a build-time token). MapGrid re-resolves the token on the mappview:runtime-env-change event, so the toggle appears as soon as one is entered.
Lazy loading. The whole Cesium engine (~4.8 MB) is import()-ed only when a pane switches to the globe, kept in its own build chunk (manualChunks) and off the 2D boot path. A Vite plugin (vite-plugins/copy-cesium-assets.ts) stages Cesium’s runtime Workers/Assets/Widgets into public/cesium/ and the canvas sets window.CESIUM_BASE_URL so the engine finds them.
Camera sync.packages/map/src/globe/cesium/camera.ts converts between MapLibre’s Web-Mercator MapViewState (zoom, nadir-referenced pitch, bearing) and Cesium’s camera (metric range, horizon-referenced pitch, heading), matched by ground resolution (metres per pixel) so the on-screen scale stays in step even when the panes differ in height. CesiumCanvas seeds its camera from the shared mapView, applies store changes to the globe, and writes the globe’s own moves back — bidirectional, like the 2D panes — with a tolerance check that suppresses the apply→moveEnd echo so there is no jitter loop.
Layer sync.CesiumLayerSync (packages/map/src/globe/cesium/layer-sync.ts) reconciles the store’s MappViewLayer[] onto the globe the way MapController.syncLayers does for MapLibre, reusing the same per-pane visibility overrides and group effects as SecondaryMapCanvas. It renders the kinds where Cesium is the natural fit — GeoJSON (a draped GeoJsonDataSource styled from the layer’s fill/stroke/opacity), XYZ/raster/WMTS and WMS (as ImageryLayers), and 3D Tiles (a Cesium3DTileset primitive that consumes the layer’s tileset URL, request headers, and altitude offset directly) — with live visibility/opacity, rebuild-on-source-change, and removal. Other layer kinds (PMTiles, MBTiles, Zarr, LiDAR, splats, deck.gl viz, …) are skipped on the globe and still render in the 2D panes; the exported isCesiumSupportedLayerType predicate lets the pane’s layer menu tag those “2D only”. COG/imagery-from-raster is a candidate for a later pass.
Persistence. A pane’s viewKind is part of SecondaryMapView and round-trips through the .meratyn.json project format (normalizeSecondaryMapViews), so a project saved with a globe pane reopens as the globe — provided a token is available, otherwise it falls back to the 2D map.
DuckDB-WASM
Vector file import uses DuckDB-WASM for formats that need conversion before MapLibre can render them:
INSTALL spatial;LOAD spatial;
GeoParquet is read with DuckDB’s Parquet reader after loading Spatial. Other local vector formats are passed to Spatial ST_Read when the WebAssembly extension can load the GDAL-backed reader. Zipped Shapefiles are parsed with shpjs first, then DuckDB Spatial is tried if that parser cannot read the file. KML files (and the KML inside unzipped KMZ archives) are read by an in-house parser that preserves embedded symbology, emitting simplestyle-spec properties (fill, stroke, stroke-width, and so on) so styled KML renders the way it does in Google Earth; KML the parser cannot handle falls back to the DuckDB Spatial reader, which loads the geometry without the styling. A vector layer whose features carry simplestyle properties is rendered per-feature: paint expressions read the per-feature color/width and fall back to the flat layer style.
Vector import routing: every path ends in GeoJSON delivered to a createGeoJsonLayer command.
Advanced Add Data workflows
The v1.0 Add Data surface includes native dialogs for XYZ, WMS, WFS, vector files (via the Add Vector dialog backed by maplibre-gl-vector), GeoJSON URLs, vector tile sources, delimited text, raster tile templates, COG and GeoTIFF rasters (via the Add Raster dialog backed by maplibre-gl-raster), MBTiles, ArcGIS FeatureServer and VectorTileServer layers, and rendered MapServer/ImageServer raster services (including browsed MapServer sublayers and ImageServer raster functions), and GPX waypoints, tracks, and routes. It also supports 3D Tiles layers, WFS and GeoJSON URL refresh, text marker labels, and multiple DuckDB SQL query-result layers with identify, selection, and attribute table support. The Components plugin wraps maplibre-gl-components panels for FlatGeobuf, PMTiles, Zarr, LiDAR, and Gaussian splats, then mirrors added layers into the Meratyn store so the Layer panel, project format, and layer control can reason about them. Additional data sources are available through the Planetary Computer and Earth Engine panels, the Overture Maps and Planet Open Data plugins, and the Web Services adapters (ArcGIS Hub, Socrata, CKAN, and Natural Earth). The Time Slider plugin, backed by maplibre-gl-time-slider, animates time series raster and vector data (COG, XYZ/WMTS, WMS-Time, and time-filtered GeoJSON) through a docked timeline, mirroring each source it adds into the Meratyn store as an external native layer.
The Iceberg Add Data source shares the SQL Workspace’s bounded DuckDB-WASM lifecycle but owns a separate serialized catalog attachment boundary: an Iceberg REST ATTACH is database-scoped, so discovery, load, and manual refresh cannot overlap and detach another operation’s catalog. Only credential-free catalog/table definitions are accepted; credential-bearing locations and signed URLs are rejected at decode. Layers persist the validated definition plus an embedded GeoJSON snapshot, never an access token or secret. A direct HTTP metadata Content-Length can reject an oversized metadata response, but cannot preflight aggregate manifest/data fan-out. The refresh owner publishes a new effective snapshot through the project session and never mutates MapLibre directly. Automatic refresh is deliberately unavailable for Iceberg sources.
Local MBTiles tiles are read through a custom MapLibre protocol backed by Tauri commands. Remote rasters are fetched through the desktop backend when needed. Browser builds route remote WFS, WMS, WMTS, feeds, and binary downloads through the same guarded proxy program in Vite and the edge service. nginx and the viewer host forward the five fixed routes to the edge service; static web deployments need equivalent rewrites. The proxy preserves upstream failures, validates public DNS destinations and redirects, and caps responses at 50 MiB. See web proxy deployment for routing and header requirements.
Python sidecar
The FastAPI app in services/server provides optional sidecar paths for Whitebox, format Conversion, and raster operations that need native files or exceed the browser engines. The desktop app starts the sidecar on demand, communicates over 127.0.0.1, and keeps the heavier Python processing stack outside the browser bundle.
The Vector tools (Processing → Vector) run client-side with Turf.js and need no sidecar. All of the tools can optionally run on the sidecar’s /vector endpoints, backed by GeoPandas and Shapely, for projection-aware results; the sidecar reports availability through /vector/status, and the dialog falls back to the client engine when the optional vector extra is not installed.
A third Vector engine, Python (Pyodide), runs the same GeoPandas/Shapely code in the browser via Pyodide (CPython compiled to WebAssembly), so the GeoPandas path is available on the web build with no server. The framework-free public owner, services/server/src/sidecar/vector_ops.py, composes cohesive implementations under _vector/; the sidecar and browser execute that same package. A Vite plugin (vite-plugins/copy-vector-engine.ts) copies only those canonical Python modules into the app bundle, and a classic Web Worker (public/pyodide/pyodide-worker.js) installs the package in Pyodide before calling run_vector_tool over a JSON-string boundary. One source of truth means the Sidecar and Pyodide engines return identical results. The Pyodide runtime is downloaded lazily on first use; the VITE_PYODIDE_INDEX_URL env var points it at a self-hosted mirror for offline/production deployments.
The SQL Workspace offers an Apache Sedona engine alongside DuckDB and PostGIS. It runs Sedona spatial SQL on SedonaDB, the single-node Rust (DataFusion + Arrow) engine, through the sidecar’s /sql endpoints (/sql/status, /sql/run) backed by the optional sedona extra (apache-sedona[db]). In the browser — and on desktop when the extra is not installed — the same engine runs entirely client-side on CereusDB, a WebAssembly build of SedonaDB; the workspace prefers the sidecar when reachable and falls back to CereusDB automatically. The CereusDB bundle is large, so it is dynamically imported into its own chunk and only downloaded when a Sedona query first runs.
The sidecar boundary is established for current conversion, raster, vector, SQL, and SamGeo workflows; future processing work can extend it to additional engines.
Offline support (PWA)
The standalone web build is an installable Progressive Web App. vite-plugin-pwa (configured in apps/mappview/vite.config.ts) emits a web manifest plus a Workbox service worker, and src/main.tsx registers it next to installStaleChunkReload so the two coordinate. The service worker is built only for the web build; it is disabled for the Tauri desktop build (already offline via bundled assets) and the embedded Jupyter wheel (MAPPVIEW_EMBED=1), where registerSW resolves to a no-op.
Caching is split to keep the first visit light:
Precache (app shell). The HTML and the JS/CSS chunks that boot the map are precached, so the shell loads with no network after the first visit. The heavy chunks below are excluded from the precache to avoid a large first-load download.
Runtime cache, CacheFirst. The content-hashed build assets the precache skips (everything under /assets/) are cached on first use: the MapLibre bundle, DuckDB-WASM and its spatial extension, and the MapLibre feature-plugin chunks. Hashed filenames make CacheFirst safe — a redeploy mints new URLs, so a stale entry is never served as current. This is what makes local-file workflows (DuckDB Spatial conversion) work offline after they have run online once. Self-hosting the spatial extension via VITE_DUCKDB_SPATIAL_EXTENSION_PATH keeps it same-origin so it is cached too. PGlite/PostGIS is not in this list: it is fetched from the jsDelivr CDN (cross-origin) to keep it out of the build — ~25 MB raw, and ~22 MB of otherwise-incompressible weight in the Tauri binary — so the PostGIS SQL engine needs network on first use (see the Pyodide note below).
Basemaps. Tiles and styles from the CORS-friendly default hosts (OpenFreeMap, CARTO) are runtime-cached. Other remote tiles, services, and ArcGIS/WMS/WFS sources stay network-only by design and are unavailable offline.
Offline support is layered: precached shell, CacheFirst hashed chunks, runtime-cached basemap tiles, network-only services.
The Pyodide vector engine and the PGlite/PostGIS SQL engine are not offline-capable in the default configuration: both are loaded from the jsDelivr CDN (cross-origin), which the service worker does not cache. Point VITE_PYODIDE_INDEX_URL at a same-origin mirror to make Pyodide cacheable for offline use; build with MAPPVIEW_PGLITE_CDN=0 to bundle PGlite/PostGIS back into the build (this re-adds ~22 MB to the Tauri binary). The same CDN dependency applies to the desktop build — it loads both from jsDelivr too.
An air-gapped deployment can set MAPPVIEW_NO_EXTERNAL_CDN=1 at build time.
Vite then forces PGlite/PostGIS, CereusDB, and DuckDB-WASM to their bundled
variants, disables the CDN-only GDAL export, omits external built-in vision
models, and fails the production build
if a known executable/model CDN URL remains in the final chunks or service
worker. VITE_PYODIDE_INDEX_URL must point at an operator-hosted mirror; the
Effect Config boundary rejects a missing or malformed mirror. Story-map HTML
export and browser vision/depth inference are unavailable until their assets
are supplied locally. This policy cannot rewrite project-authored basemap,
service, plugin-registry, or dataset URLs: mirror or allowlist those endpoints
as part of the deployment and CSP configuration. The output guard covers
Meratyn-owned executable and model targets; dependency bundles can retain
inert fallback URL strings that are not used as fetch targets.
A new deploy is picked up via registerType: "prompt": the new service worker installs in the background and waits, so an open tab never has its import graph mixed with a new deployment. The app checks for a new worker hourly and when the tab becomes visible again, because browsers otherwise check only on navigation. A waiting deployment adds an “Update available” entry to the status bar (features/app-update/actions.tsx), so the announcement never covers the map; choosing it asks about unsaved work, sends SKIP_WAITING, waits for the new worker to activate (clientsClaim), and reloads. Every reload after a failure uses the same activation step (platform/runtime/app-update.ts): the crash fallback, the section “update required” fallback, the pre-React startup failure screen, and installStaleChunkReload, which reloads a clean project when an orphaned lazy chunk 404s (cooldown-guarded; dirty projects keep the fallback). A plain reload would keep the waiting worker and serve the previous shell again. The viewer host retains every release superseded within seven days (at least three, at most twenty), so tabs that have not updated keep loading their lazy sections.