Published viewer
The published viewer is a small anonymous TanStack Start host. / serves the
existing authoring application. A named path such as /bogota asks the
admin API for the public descriptor, renders the existing web application
HTML, and places the descriptor in
<script type="application/json" id="meratyn-publication">. The browser then
boots the same map runtime with that descriptor. The host does not create a
second React root, require a login, or expose a admin session.
The descriptor is fetched before the HTML response is returned, so the document and its policy are ready when the client starts. Layer data and map rendering still load after first paint in the normal application lifecycle. A missing or invalid descriptor returns an error response and never falls back to the authoring app for a named path.
Admin owns publication snapshots, immutable documents, and prepared preview data. The viewer host owns the anonymous HTML boundary and metadata; the existing web client consumes the descriptor marker and boots the same map runtime. The JavaScript bundle is shared across publications, with capability controls loaded lazily from the publication policy rather than compiled into a per-project bundle. The host does not fetch a screenshot while building the HTML response. Preview generation may prepare and cache an exact-view image later on the admin/renderer path, using the immutable revision and view parameters in the preview URL.
Publishing and viewer URLs
Admin keeps the latest project upload separate from its public publication.
Publishing or replacing a snapshot through the protected project publication
operation assigns a stable slug and captures its feature policy; uploading a
new draft alone does not change the public page. The anonymous descriptor route
is GET /api/publications/:slug, while publication admin remains under the
authenticated project API. A successful publish is therefore visible at
/bogota without rebuilding this frontend. Replacing a snapshot keeps the old
immutable descriptor for the backend’s five-minute revision grace period so
links containing ?publication=<id>&version=<versionId> remain usable while
the host cache expires.
Bundled Explore projects are seeded under the reserved showcase account and
receive their named publication during admin startup. An unchanged
catalog version and slug is idempotent; a new repository-managed version
refreshes that bundled snapshot while retaining its policy. The Explore page
links directly to /<slug> on the configured viewer host, while the canonical
raw source document remains available at /showcase/<slug>.meratyn.json.
The viewer accepts up to 16 repeated filter parameters and one fit layer.
Each filter is JSON with a layerId, field, and one of eq, neq, lt,
lte, gt, gte, contains, or in; filters are canonicalized and applied
together (AND semantics) after the publication policy allowlist is checked. For
example, a stable URL is
/bogota?filter=%7B%22field%22%3A%22name%22%2C%22layerId%22%3A%22roads%22%2C%22op%22%3A%22contains%22%2C%22value%22%3A%22north%22%7D&fit=roads.
Vector rendering filters can be applied to tile layers when the provider
supports the corresponding expression. Full-source queryable vector data,
GeoJSON, and supported ArcGIS providers also expose feature rows for data
operations. A tile-only layer without a full query source cannot produce a
full result set, so fit returns an explicit unsupported capability instead
of fitting only the currently visible tiles. fit is
available only for a policy-allowlisted layer with bounds that the provider can
expose.
Local host
Deploy the updated admin service and admin app with the viewer host.
The service’s existing startup migration runner applies
0020_project_publications.sql and 0021_publication_revisions.sql; those files
must be included in the service image. Publishing remains unavailable until
that service update is running. Configure the gateway and persistent asset
volume below before directing public viewer traffic to the new host.
Build the web application into an isolated viewer-host directory and then build the Start server:
bun run build:viewer-host
PUBLICATION_API_ORIGIN=http://127.0.0.1:8787 \
PUBLICATION_VIEWER_ORIGIN=http://127.0.0.1:5175 \
HOST=127.0.0.1 PORT=5175 bun run start:viewer-host
PUBLICATION_API_ORIGIN is a fixed trusted server origin. PUBLICATION_VIEWER_ORIGIN
is used for canonical links and is never taken from Host or forwarded headers.
The API must expose anonymous GET /api/publications/:slug; the response is a
validated PublishedProject descriptor containing absolute document and preview
URLs. The local defaults are the values shown above.
The Docker image uses the same commands:
docker build -f docker/Dockerfile.viewer-host -t meratyn-viewer .
docker run --rm -p 5175:5175 \
-e PUBLICATION_API_ORIGIN=https://api.example.com \
-e PUBLICATION_VIEWER_ORIGIN=https://maps.example.com \
-v meratyn-viewer-assets:/var/lib/meratyn-viewer \
meratyn-viewer
For the existing browser image, keep nginx as the gateway and run this host as
the viewer-host upstream. The checked-in
docker/nginx.viewer-host.conf fragment
routes /, named pages, the copied /assets/ tree, /_start-assets/, the
service worker, and the web manifest to Start. Include it in the existing
server block after the /sidecar/, /mapillary/, and optional /ai/
locations, and replace that block’s generic static and SPA locations as the
fragment instructs. Those existing proxy locations remain in control of their
services. Keep the exact /meratyn-runtime-config.js location from
docker/nginx.conf: entrypoint.sh writes that file at container startup, so
runtime AI/embed configuration is still generated by the gateway and is served
before any upstream fallback. The Start host itself has no auth or secret
injection layer.
For a Compose deployment, publish the host on the private network and use
viewer-host:5175 as the upstream name. The gateway and host must come from the
same release so HTML and hashed assets cannot be mixed:
services:
viewer-host:
build:
context: .
dockerfile: docker/Dockerfile.viewer-host
environment:
PUBLICATION_API_ORIGIN: http://admin:8787
PUBLICATION_VIEWER_ORIGIN: https://maps.example.com
volumes:
- viewer-assets:/var/lib/meratyn-viewer
expose: ["5175"]
volumes:
viewer-assets:
Do not point the fragment at the old nginx static directory while serving Start HTML. Keep the previous immutable asset set available during the worker update window described below.
Caching and revisions
Successful descriptors are held in a bounded native Effect cache with at most
1,000 entries for 30 seconds. Concurrent requests for the same slug and
revision share one lookup. Failures retry quickly and are not retained as a
stale publication. HTML responses use public, max-age=0, s-maxage=30, and the
ETag includes the publication id, version id, normalized semantic view, and the
viewer template revision. Tracking parameters do not affect the generated
variant; filter and fit are parsed, policy-checked, and canonically sorted.
When a publication is replaced, the backend keeps the previous immutable
descriptor for its documented grace period. A URL containing
?publication=<id> requests that exact revision so an already-cached page does
not silently switch to the newest document. Canonical links omit the revision
selector, while preview URLs retain the immutable revision and the semantic view.
Publishing a new project therefore updates the descriptor and cache naturally;
it does not require rebuilding the frontend assets.
Asset rollout
The host copies the production web build into its own output and keeps its
hashed viewer assets separate from Start’s _start-assets. Missing assets return
404 and are never served by a SPA fallback. The current web service worker only
falls back to index.html for / and its query string, so named publication
routes reach the host. A tab controlled by an older worker can remain in the
previous worker’s transition until it updates and reloads.
At startup the host copies only immutable /assets/ and /_start-assets/
files into MERATYN_VIEWER_RETENTION_DIR. It keeps every release that was
superseded within the last seven days, never fewer than the three most recent
release sets and never more than twenty, because a tab stays on its release
until the user applies the in-app update and fetches lazy sections on first
use. It never archives index.html, sw.js, the web
manifest, or runtime configuration, and it never uses an archived file for a
mutable route. Mount /var/lib/meratyn-viewer in the Docker image as shown
above. If a deployment cannot retain that volume, publish each host image under
a release directory or keep the previous image behind the same gateway for at
least one worker update interval, switch the gateway atomically, and garbage
collect only old hashed assets after that interval.