- Go 96.4%
- HTML 1.8%
- Shell 1%
- Python 0.6%
- Makefile 0.2%
Searching "can biel" on a Catalonia index never returned the masia near Llinars del Vallès that Nominatim finds: OSM maps it as a bare place=isolated_dwelling node and pogo.Classify only accepted the eight settlement types from locality to city, so the node was dropped at import. place=isolated_dwelling and place=farm are now settlements with importance 1, equal to localities. The existing importance numbers are kept because they are stored per document: shifting them would rank sources imported before and after this change inconsistently. The two new values are checked after the POI families, so a farm tagged tourism=guest_house stays a guest house and is still found by category and exact-type searches. place=square and administrative places stay out. Reimport every source to pick up the new places (docs/releases.md). Liechtenstein fixture: 5417 -> 5428 features. Both tests fail on the unfixed code: "indexed 2, want 4" and Classify rejecting both tag values. Mutations, each caught by the named assertion: dropping isolated_dwelling or farm from dwellingTypes fails "indexed 3, want 4" and the matching Classify case; giving dwellings importance 5 flips the exact-name order in TestRuralDwellingsAreSettlements/can_biel; gating dwellings before the POI families fails /mas_agro, /guest_house and the "POI family wins" Classify case; deleting the unnamed-place guard fails "indexed 5, want 4"; shifting hamlet to 7 fails "hamlet keeps its importance". Kata: zq3p |
||
|---|---|---|
| assets | ||
| cmd/oms | ||
| docs | ||
| internal | ||
| pkg/pogo | ||
| scripts | ||
| testdata | ||
| .gitattributes | ||
| .gitignore | ||
| .kata.toml | ||
| AGENTS.md | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| Makefile | ||
| Nakedfile | ||
| README.md | ||
oms
Offline mirror and server for maplibre-gl vector maps
and broom routing data. oms downloads
what a mapping application needs and serves it from a single small binary, so
the app renders maps and calculates routes with no internet access.
- Maps from OpenFreeMap: style, TileJSON, sprites, glyphs, tiles for a region and the maplibre-gl client library.
- Routing for broom: Geofabrik OSM extracts with checksums and Skadi elevation tiles, exposed exactly the way broom's provider options expect.
- Elevation for map clients: the Terrarium terrain-RGB tile pyramid used for elevation profiles, slope colouring and hillshading.
- Place and POI search: offline cities, towns, restaurants, fuel stations, shops, parks and other amenities, with category and nearby search.
It is built to run on low-memory, low-power boxes: objects are served straight
from the filesystem with sendfile, tiles stay gzip-compressed on disk and on
the wire, and the server sits at about 12 MB of RSS idle and under 25 MB
under load.
Install
go install code.rbel.co/rubiojr/oms/cmd/oms@latest
Or make cross for static linux/arm64, linux/arm/7 and linux/riscv64
binaries.
Quick start
# Maps: everything for Madrid up to zoom 14, plus the client library.
oms maps all --style liberty --bbox -3.9,40.3,-3.5,40.55 --maxzoom 14
# Routing: the Madrid extract, its checksum and the elevation tiles it needs.
oms routing all --region madrid
# Serve it. --offline never touches the network.
oms serve --listen 0.0.0.0:8080 --offline
Open http://<host>:8080/ for a demo map; ?style=NAME picks one of the
mirrored styles (liberty by default). In your app:
const map = new maplibregl.Map({
container: 'map',
style: 'http://<host>:8080/maps/styles/liberty',
});
manager, err := routing.New(routing.Options{
Provider: routing.ProviderOptions{
IndexURL: "http://<host>:8080/routing/osm/index-v1.json",
MetadataIndexURL: "http://<host>:8080/routing/osm/index-v1-nogeom.json",
PBFBaseURL: "http://<host>:8080/routing/osm",
DEMBaseURL: "http://<host>:8080/routing/skadi",
},
})
GET /api/routing returns that provider block ready to use, and GET /api/maps
returns the style URL template and the library entry point, so an app only
needs the server address.
Place search
Import an existing Geofabrik PBF, then search it locally or through the server:
oms geocoding import --source madrid \
~/.local/share/oms/routing/osm/europe/spain/madrid-latest.osm.pbf
oms geocoding search cerce
oms geocoding search --lat 40.74 --lon -4.06 --radius 25000 pizza
curl 'http://localhost:8080/api/geocoding/search?q=cerce&limit=5'
Use the actual PBF path under your --data-dir (or --cache-dir for an
on-demand download). Import reads local files and does not download anything.
The Pogo index is stored in geocoding.pogo/ under --state-dir; use the same
state directory for import, search and serve. Upgrading from geocoding.db
requires reimporting the PBFs. The old database format is no longer used.
The index covers settlements, amenities, shops, food and drink, fuel/EV charging,
parks, tourism, historic sites, healthcare, transport and more. It includes OSM
nodes, ways and relations. Search by name, brand, cuisine or category: pizza,
gas station, gasolinera, parks, pharmacy. Matching ignores case and Latin
accents, with prefix matching on the final word. CLI search prints a JSON array;
--limit defaults to 10 and accepts 1–50. Add --lat and --lon to prefer nearby
POIs, and --radius (meters, up to 200000) to restrict the search area.
Rerun import after refreshing a PBF. Keep the same --source identity to replace
that extract's entries; when omitted, the absolute file path is the identity.
Pogo stores only the geometry that indexed places depend on, scans each snapshot
in relation, way and node passes, and reuses unchanged geometry/documents. Pre-commit
failures preserve the previous snapshot; a post-commit publication error is
resumed with oms geocoding recover. Running servers see completed imports
without restarting. Overlapping extracts share objects by OSM type and ID, with
version/timestamp precedence. Inspect them with oms geocoding sources and remove
one with oms geocoding remove SOURCE; empty imports are rejected. Use
--decode-workers, --decode-buffer and other import controls to tune indexing,
and --json to inspect incremental work counters.
Imports are serialized by SQLite's writer lock; run them one at a time.
Existing settlement indexes upgrade automatically; reimport your PBFs after deploying to add POIs. The PBF reader targets Geofabrik extracts. POI imports take additional time and temporary disk space to resolve mapped areas. The retained index is a small fraction of the PBF's objects; see docs/pogo.md for sizes.
The public search endpoint works with --offline and --no-api, and uses the
server's normal rate limit. Before any import it returns an empty array. See
the API reference for the response format.
The demo map at / has a top-center place search box. Type at least two
characters, then click a suggestion or use the arrow keys and Enter to move
the map to that place. Searches use the map center to order nearby POIs; check
Within 25 km of map center to limit the area. Clear removes the marker.
See Place and POI search for coverage and examples,
and release notes for upgrade considerations.
Layout
Everything is served under three prefixes, each a verbatim mirror of one upstream's URL tree:
| Prefix | Upstream | Holds |
|---|---|---|
/maps/ |
https://tiles.openfreemap.org |
styles/<name>, planet (TileJSON), tiles, sprites/, fonts/, lib/ |
/routing/osm/ |
https://download.geofabrik.de |
index-v1.json, index-v1-nogeom.json, europe/spain/madrid-latest.osm.pbf and .md5 |
/routing/skadi/ |
https://s3.amazonaws.com/elevation-tiles-prod/skadi |
N40/N40W004.hgt.gz one-degree elevation tiles |
/elevation/terrarium/ |
https://s3.amazonaws.com/elevation-tiles-prod/terrarium |
{z}/{x}/{y}.png terrain-RGB tiles, zoom 0 to 15 |
The served style has every upstream URL rewritten to <public base>/maps, so
sprites, glyphs and tiles all resolve locally. The Geofabrik index is served
untouched because broom rewrites extract URLs itself through PBFBaseURL.
The client library lives at /maps/lib/maplibre-gl.mjs,
/maps/lib/maplibre-gl.css and /maps/lib/mapbox-gl-rtl-text.js (maplibre-gl
5 and older mirror the UMD maplibre-gl.js instead).
Mirroring
Maps
| Command | Mirrors |
|---|---|
oms maps all |
one shot: all of the below |
oms maps style [name...] |
style JSON, its TileJSON, sprites and fonts (no tiles) |
oms maps sprites |
.json, .png and @2x variants of the style's sprite |
oms maps fonts |
256 glyph ranges for every text-font in the style |
oms maps tiles |
tiles covering --bbox between --minzoom and --maxzoom for every tile source |
oms maps lib |
maplibre-gl js/css and the RTL plugin from jsdelivr |
oms maps estimate |
tile counts per source and zoom, --json for machines |
OpenFreeMap styles: liberty, bright, positron, fiord, dark.
| Flag | Meaning |
|---|---|
--style, -s NAME |
style name, path or upstream URL; repeatable (default liberty). maps style takes names as arguments instead |
--bbox, -b minLon,minLat,maxLon,maxLat |
region; repeatable, world is accepted |
--minzoom N, --maxzoom, -z N |
zoom range for tiles (default 0 to 14, capped by each source's own range) |
--source ID |
only tiles of this style source; repeatable |
--glyph-ranges RANGES |
all (default, 256 files per font), latin (six ranges) or a list like 0-255,256-511 |
--maplibre-version, --rtl-version |
library versions (default 6.11.2 and 0.3.0) |
--max-tiles N |
refuse plans above this many tiles (default 2,000,000) |
--yes, -y |
skip the confirmation asked for more than 10,000 tiles |
--no-tiles, --no-lib |
maps all only |
Routing
| Command | Mirrors |
|---|---|
oms routing all |
one shot: index, extracts with checksums, elevation tiles |
oms routing index |
the two Geofabrik index documents |
oms routing pbf |
<region>-latest.osm.pbf and its .md5 for each --region |
oms routing dem |
Skadi tiles covering the regions' extents and any --bbox |
oms routing estimate |
resolved regions, extract sizes (from a HEAD request) and tile counts |
oms routing regions [query] |
search the index: oms routing regions spain |
| Flag | Meaning |
|---|---|
--region, -r REF |
Geofabrik id (madrid), path (europe/spain/madrid) or extract path; repeatable |
--bbox, -b … |
extra elevation coverage beyond the regions' extents |
--no-dem |
routing all only |
--yes, -y |
skip the confirmation asked above 1 GiB of extracts |
Elevation tiles are one degree square and about 10 MB each; Spain needs 112
of them. Extracts are -latest files that Geofabrik rebuilds daily; rerun
with --refresh to pick up a new build together with its checksum.
Elevation
| Command | Mirrors |
|---|---|
oms elevation tiles |
Terrarium terrain tiles covering --bbox between --minzoom and --maxzoom (default 0 to 12) |
oms elevation estimate |
tile counts per zoom and an approximate size |
Terrain tiles average about 50 KB, thirty times a vector tile, and the
pyramid ends at zoom 15. Prefetch the zooms a client needs for overviews and
let on-demand caching fill in the rest: a tile requested through
/elevation/terrarium/{z}/{x}/{y}.png that is not mirrored is fetched once
and kept. GET /api/elevation returns the tile URL template for clients.
Shared by both: --refresh re-downloads existing objects, --concurrency, -c N
sets parallel downloads (default 8), --quiet, -q silences progress.
Everything is idempotent: rerunning skips what is present.
Directories
| Flag | Default | Holds |
|---|---|---|
--data-dir |
$XDG_DATA_HOME/oms (~/.local/share/oms) |
objects you explicitly mirrored |
--cache-dir |
$XDG_CACHE_HOME/oms (~/.cache/oms) |
objects fetched on demand while serving |
Both hold the same tree as the served paths, so /maps/fonts/Noto Sans Regular/0-255.pbf
lives at exactly that path. Extension-less documents such as /maps/planet
are stored as maps/planet/.index. oms cache clear empties the cache;
oms cache promote moves cached objects into the data dir. oms status
shows dirs, styles, extracts, tile counts and disk usage (--json,
--no-usage to skip the walk).
Global flags and their environment variables:
| Flag | Env | Default |
|---|---|---|
--data-dir, --cache-dir |
OMS_DATA_DIR, OMS_CACHE_DIR |
XDG dirs above |
--state-dir |
OMS_STATE_DIR |
$XDG_STATE_HOME/oms (~/.local/state/oms), holds accounting.db |
--upstream, -u |
OMS_UPSTREAM |
https://tiles.openfreemap.org |
--osm-upstream |
OMS_OSM_UPSTREAM |
https://download.geofabrik.de; empty disables routing |
--dem-upstream |
OMS_DEM_UPSTREAM |
https://s3.amazonaws.com/elevation-tiles-prod/skadi |
--terrarium-upstream |
OMS_TERRARIUM_UPSTREAM |
https://s3.amazonaws.com/elevation-tiles-prod/terrarium; empty disables elevation |
--port |
OMS_PORT |
unset; when given, serve listens on 127.0.0.1:<port> instead of --listen, for process managers that dictate the port |
--log-level |
OMS_LOG_LEVEL |
info |
Serving
oms serve [--listen 127.0.0.1:8080] [--offline] [--public-url URL]
[--no-api] [--api-token TOKEN] [--max-inflight 4] [--no-accounting]
[--rate-limit 1200] [--fetch-budget-objects 200000]
[--fetch-budget-bytes 21474836480] [--cache-max-bytes 42949672960]
Lookup order is data dir, cache dir, then the prefix's upstream (unless
--offline). Objects fetched from upstream land in the cache dir. Upstream
404s are remembered for five minutes, identical concurrent misses are
coalesced, and at most --max-inflight upstream fetches run at once.
Routing objects are large, so a cache miss under /routing/ streams the
upstream body to the client while it is written to the cache, with the
upstream Content-Length forwarded. The download finishes even if the client
gives up, and the next request is served from disk with range support.
Behind a reverse proxy set --public-url https://maps.example.org or forward
X-Forwarded-Proto and X-Forwarded-Host; they decide the base URL written
into styles and reported by the API. OMS_LISTEN, OMS_PUBLIC_URL,
OMS_OFFLINE and OMS_API_TOKEN set the corresponding flags.
Empty tiles are answered with 204. A comma-joined font stack such as
Noto Sans Regular,Noto Sans Bold falls back to the first member that is
mirrored. .hgt.gz elevation tiles are files, not a transfer encoding, and
are served byte for byte.
Set GOMEMLIMIT (for example GOMEMLIMIT=48MiB) to cap the Go heap on tiny
boxes.
Request and cache limits
serve enables these limits by default. Pass 0 to disable an individual
limit. Byte flags take integer byte counts, not size suffixes.
| Flag | Default | Meaning |
|---|---|---|
--rate-limit |
1200 |
requests per client IP per minute, sliding window; excess requests receive 429 with Retry-After: 60 |
--fetch-budget-objects |
200000 |
completed on-demand downloads per UTC day |
--fetch-budget-bytes |
21474836480 (20 GiB) |
bytes cached by on-demand downloads per UTC day |
--cache-max-bytes |
42949672960 (40 GiB) |
stop starting on-demand downloads once the cache reaches this size |
The rate limit covers cached responses and the API too; /healthz is exempt.
Forwarded client IPs are trusted only from a loopback proxy such as Caddy.
Clients sharing a public IP share its rate limit.
When either fetch budget or the cache threshold is reached, new cache misses
receive 503 with Retry-After; mirrored and cached objects remain available
within the client's rate limit. Downloads already in flight finish, so these
are admission thresholds, not hard byte or object ceilings. Budgets count
successful downloads, reset at midnight UTC, and are held in memory: a server
restart resets usage too. Explicit CLI mirrors and API mirror jobs write to
the data dir and are outside these budgets and the cache cap.
Nothing is evicted. Cache size is measured in the background at startup and
hourly, with completed downloads added between measurements. oms cache clear
or oms cache promote frees space; the next measurement notices the change.
Budget/cache 503 responses currently advertise seconds until midnight UTC,
but a full cache still needs space freed. Inspect /api/stats → limits for
usage, thresholds, reset time and rejections; requests.limited and accounting's
limited field count 429 and budget/cache 503 responses separately from
server errors.
HTTP API
The server exposes the same information and mirror plans over HTTP: read-only
status for maps and routing clients (GET /api/maps, GET /api/routing),
estimates, mirror jobs (POST /api/maps/mirror, POST /api/routing/mirror,
/api/jobs) and server stats (GET /api/stats: memory, disk, request and
upstream counters). See docs/api.md for every endpoint, the
plan schemas and the job lifecycle.
Benchmarks
make bench runs the Go microbenchmarks. scripts/loadgen replays random
tiles from a data dir against a running server:
go run ./scripts/loadgen -data ~/.local/share/oms -url http://127.0.0.1:8080 -c 32 -n 50000
Measured on an x86 workstation against a Madrid mirror (456 tiles, 31 MB,
zoom 0 to 14), loadgen requesting random tiles with 32 clients:
| Server constraints | Throughput | p50 / p99 latency | Peak RSS |
|---|---|---|---|
| 16 cores, no limit | 31.9k tiles/s, 1.97 GB/s | 0.83 ms / 2.98 ms | 22 MB |
2 cores, GOMEMLIMIT=48MiB |
32.4k tiles/s, 2.01 GB/s | 0.84 ms / 3.18 ms | 19 MB |
1 core (taskset), GOMEMLIMIT=32MiB |
22.8k tiles/s, 1.43 GB/s | 1.39 ms / 3.18 ms | 18 MB |
Single-URL ab -k -c 32 on the same box: 22.5k req/s for a 100 KB gzip tile,
60k req/s for the rewritten style document, 155k req/s for offline 404s.
In the handler a gzip tile hit costs about 6 µs and 3.6 KB of allocations;
inflating a tile for a client without gzip support costs 25 µs and 80 KB.
A Raspberry-class ARM board will be slower, but the work per request is
tiny and the memory ceiling holds.
Deployment
oms --port <port> serve --public-url https://<domain> is the shape a process
manager wants: bind loopback on the port it hands out and let the reverse proxy
terminate TLS. A naked deploy needs nothing
else; see scripts/deploy (untracked, it carries host and token) for the one
used for oms.rbel.co.
Geofabrik republishes extracts daily. scripts/refresh-pbf re-downloads the
extracts an instance already mirrors over ssh on the naked host (NAKED_HOST
and NAKED_DOMAIN from the environment or naked vars), or locally with
--local. Each extract is replaced atomically, so the server never serves a
partial file and a failed download keeps the old one; extracts whose upstream
.md5 is unchanged are skipped unless --force is given, and an optional
pattern such as madrid limits the run. Reimport geocoding afterwards.
That deployment explicitly sets the same limits as the CLI defaults: 1,200 requests/client/minute, 200,000 objects and 20 GiB fetched per UTC day, and a 40 GiB cache threshold.
Agent skill
oms install-skill writes an embedded agent skill
to ~/.agents/skills/oms-user/ (--print to stdout, --dest DIR, --force
to overwrite local edits) so an AI agent can operate oms, its API and a
deployed instance without network access. The bundled reference/api.md is a
copy of docs/api.md; go generate ./internal/skill refreshes it.
Development
Install Go 1.27.1, Git LFS, Make and a C compiler for the race detector.
make test # fetch PBF fixtures, then run all tests once with race detection and coverage
make lint # gofmt, vet, staticcheck
make vuln # govulncheck
make test downloads missing Git LFS PBF fixtures and includes the real-country
integration tests. Fixture-fetch errors stop the run before testing. The country
imports take several minutes under the race detector; the default timeout is
15 minutes per package. Use make test TEST_TIMEOUT=30m on slower machines.
make test-fixtures fetches just the required PBFs without running tests.
Style and routing JSON fixtures are already in Git; synthetic fixtures are
generated by the tests. Benchmark source archives are not needed by this target.
License
MIT, see LICENSE.