Copyright (C) 2014-2026 Stichting Mapcode Foundation (http://www.mapcode.com)
A TypeScript port of the Mapcode REST service
(mapcode-rest-service,
originally a Java/Guice/Jersey application). It exposes the same HTTP API —
same paths, same query parameters, the same JSON and XML byte-for-byte
serialization, and the same error semantics — implemented on
Fastify over the
mapcode-ts encoding/decoding
library.
Compared with the original Java service, this port is built for operational simplicity: it runs with a smaller memory footprint, can sustain higher request loads, and is easier to package and deploy as a Node.js service.
- Node.js ≥ 20 for the built service.
npm run devruns TypeScript directly with Node's type stripping and needs Node.js ≥ 22.6.0. - The
mapcode-tslibrary, installed from npm. - The
mapcode-ts-borderslibrary, installed from npm. It bundles the OpenStreetMap border data for the boundary/territory lookups (see below).
npm installThe /mapcode/codes/{lat},{lon} default response and the
/mapcode/codes/{lat},{lon}/territories endpoint look up OpenStreetMap
administrative boundaries (point-in-polygon + ranking). The lookup is done by
mapcode-ts-borders, which
ships the border data as a borders.mcb file (OpenStreetMap, May 2026). The
service loads it fully into memory on startup and logs its source, for example
borders: OpenStreetMap 2026-05. New border data comes with a new
mapcode-ts-borders release.
A small fixture used by the test suite lives at test/resources/borders-test.mcb.
test/resources/README.md explains how to regenerate it.
Configuration is read from environment variables (via src/config.ts):
| Variable | Default | Meaning |
|---|---|---|
PORT |
8080 |
TCP port to listen on. |
VERSION |
package.json version |
Version string returned by /mapcode/version. |
MAPCODE_DB_URL |
(unset) | Optional Postgres connection string. When unset, request recording is disabled and /mapcode/replay is not registered. Contains credentials — never logged. Do not put sslmode in the URL (see TLS note below). |
MAPCODE_REPLAY_TOKEN |
(unset) | Bearer token for GET /mapcode/replay and GET /mapcode/replay/stats. Required whenever MAPCODE_DB_URL is set — the service refuses to start without it (fail closed). |
MAPCODE_DB_CA_CERT |
(unset) | PEM of the database CA certificate, for verified TLS to Postgres. A multi-line PEM cannot be supplied via .env (the loader is line-based) — set it as a real environment variable. A PEM collapsed to one line is silently ignored by the TLS stack — keep the original line breaks. |
TLS to Postgres: set MAPCODE_DB_CA_CERT to the cluster's CA and leave
sslmode out of MAPCODE_DB_URL. With the CA set, the service connects with
full certificate verification (rejectUnauthorized: true) — equivalent to
libpq's verify-full. Adding sslmode=require to the URL breaks this: pg
8.x gives connection-string sslmode precedence over the programmatic ssl
options, discarding the configured CA and failing the handshake against a
provider-signed certificate (e.g. DigitalOcean managed Postgres) with
self-signed certificate in certificate chain. Providers whose CA is not
downloadable from a control panel expose it in the TLS handshake itself:
openssl s_client -connect <host>:<port> -starttls postgres -showcerts.
A .env file is optional. If present, it is loaded at startup before
configuration is read. Existing environment variables take precedence over
values in .env, so deployment-provided settings are not overwritten. For
example:
# .env
PORT=8080npm run dev # run src/index.ts directly (--experimental-strip-types)
npm run build # tsc → dist/ (relative .ts imports rewritten to .js)
npm start # node dist/index.js
npm test # vitest run (full suite)
npm run test:watch # vitest in watch mode
npm run typecheck # tsc --noEmitRun the built server:
npm run build
node dist/index.js
# → borders: OpenStreetMap 2026-05
# → mapcode-rest-service-ts listening on :8080 (version <package.json version>)
curl localhost:8080/mapcode/version
# {"version":"<package.json version>"}All endpoints are GET. JSON is the default; XML is selected by
Accept: application/xml. The /mapcode/xml/... and /mapcode/json/... URL
prefixes force XML / JSON regardless of the Accept header.
| Method & path | Behavior |
|---|---|
GET /mapcode |
HTML help page (<html><pre>MAPCODE API (<version>) (optimized version)…</pre></html>). |
GET /mapcode/version |
VersionDTO {version}. |
GET /mapcode/status |
Round-trip encode/decode self-check; 200 if OK, else 500. |
GET /mapcode/codes |
403 (missing path params). |
GET /mapcode/codes/{lat},{lon} |
Encode → MapcodesDTO (local, international, mapcodes[], territories[]). |
GET /mapcode/codes/{lat},{lon}/{type} |
type ∈ mapcodes|local|international (case-insensitive). local/international → MapcodeDTO; mapcodes → MapcodeListDTO (bare JSON array). |
GET /mapcode/codes/{lat},{lon}/territories |
TerritoryCandidatesDTO — ranked OSM admin-boundary territories. |
GET /mapcode/coords |
403 (missing path params). |
GET /mapcode/coords/{code} |
Decode → PointDTO (or RectangleDTO if include=rectangle). |
GET /mapcode/territories |
TerritoriesDTO {total, territories[]} with offset/count. |
GET /mapcode/territories/{territory} |
TerritoryDTO. |
GET /mapcode/alphabets |
AlphabetsDTO {total, alphabets[]} with offset/count. |
GET /mapcode/alphabets/{alphabet} |
AlphabetDTO. |
/codes/{lat},{lon}[/{type}]:precision(0–8, default 0),territory(name or alphacode),country(ISO-3166 alpha-2/alpha-3),alphabet,include(comma list ofoffset,territory,alphabet,rectangle),client,allowLog.contextmust be absent here → 400 if present./coords/{code}:context(territory name/alphacode),include(rectangleonly effective),client,allowLog.territorymust be absent → 400./territories,/alphabets:offset(default 0; negative counts from end),count(default 1000; negative → 400),client,allowLog./territories/{territory}:context(disambiguation),client,allowLog.
client and allowLog never change a response, but they do feed request
recording (see below) when MAPCODE_DB_URL is set: client is classified into
a one-byte caller class — web, android, ios, demo, anything else
other (case-insensitive; the raw string is never stored) — and
allowLog=false|0|no opts the request out of full recording: the call is
still counted, but anonymised — only its endpoint kind, status, client class
and the country of the point are stored, never the coordinates or the
mapcode (see below). Without a database configured both are accepted and
ignored. web is deprecated in
favour of demo: it is still accepted, but in practice it is sent by straight
API callers rather than by a front-end, so treat that class as API use.
The running service logs incoming requests at info level and handled warnings or errors at their corresponding levels.
The production entrypoint enables Fastify's structured JSON logger at info level.
Each REST call emits Fastify's standard incoming request and request completed
entries. Handled API errors, unknown routes, and unsupported methods are logged
at warn level. Unexpected request errors are logged at error level with the
serialized error attached.
- lat must be in
[-90, 90]→ else 400. lon is wrapped to[-180, 180]. - precision integer in
[0, 8]→ else 400. territoryandcountryboth set → 409.- At sea (no boundary territory),
/codes/{lat},{lon}/territoriesreturns an empty list: JSON{"territories":[]}, XML<?xml version="1.0" encoding="UTF-8" standalone="yes"?><territories></territories>.
When MAPCODE_DB_URL is set, the service records a bounded, best-effort log of
incoming requests to Postgres (fire-and-forget; recording never affects API
latency or availability) and registers GET /mapcode/replay to read it back.
Only successful (status < 400) calls to real API endpoints are recorded:
/mapcode/status and /mapcode/version (monitoring noise), the replay
endpoints (meta-traffic), and invalid calls (bad parameters, unknown routes,
bad methods) are not.
A call with allowLog=false|0|no is recorded anonymised: lat, lon and
mapcode are NULL, anon is true, and the country column holds the country
of the requested (encode) or decoded (decode) point — the top-ranked
borders match, lifted to its parent for a subdivision (US-CA → USA);
NULL at sea and for non-geo endpoints. Fully recorded rows leave country
NULL, since it is derivable from the coordinates, and anon false. The anon
flag is the only reliable marker: country alone cannot tell an anonymised
territories call or an at-sea encode from a fully recorded non-geo row.
Anonymised rows count towards the stats by kind (and are reported separately
as anon, see below) and are returned by /mapcode/replay with lat, lon
and mapcode null and anon: 1, so a client can stack them into a timeline;
fully recorded non-geo rows are not returned, since they cannot be plotted.
The anon column arrived after country. Rows anonymised in between are
successful geo-kind calls without a point, and the schema bootstrap sets their
flag. But a missing point does not prove a row is anonymised: from 2026-08-27
to 2026-09-03 failed calls were recorded too, and a failed geo call has no
point either. So on every start the bootstrap runs one idempotent UPDATE ... SET anon = (status::int < 40) WHERE lat IS NULL AND kind IN (10, 11, 12, 13, 20) over rows written before 2026-10-18. The status column holds 40 for a
400, so the 4xx rows stay unflagged, and the same pass clears the flag that
an earlier version of this backfill set on them. Later rows keep the flag the
recorder wrote. Only the flag changes and no row is removed, so the
append-only rule holds. No manual step.
GET /mapcode/replay is token-protected — it requires
Authorization: Bearer <MAPCODE_REPLAY_TOKEN>, else 401. It is JSON-only (no
/xml alias). Query parameters:
| Parameter | Default | Meaning |
|---|---|---|
from |
(required) | Window start, epoch seconds (inclusive). |
to |
now | Window end, epoch seconds (exclusive). |
limit |
50000 |
Max rows returned; max 200000. |
kind |
(all) | Comma-separated list of endpoint-kind numbers to filter on. |
The window (to - from) is capped at 365 days. The response is a columnar JSON
object ({from, to, count, truncated, ts, kind, lat, lon, status, client, mapcode, country, anon}) shaped for a canvas/WebGL renderer to iterate
directly. anon is 1 for an anonymised row and 0 otherwise; anonymised
rows have lat, lon and mapcode null and carry their country (null
at sea and for non-geo endpoints), fully recorded rows have country: null.
Windows
that end more than a minute in the past are cacheable
(Cache-Control: private, max-age=3600); more recent windows are no-store,
since events can take a few seconds to leave the recorder's queue and become
queryable.
GET /mapcode/replay/stats (same Bearer token, no parameters) returns usage
counters over fixed trailing windows anchored at now — non-geo rows included.
Calls to /mapcode/replay and anything below it are meta-traffic: they are
not recorded, and historical replay rows are excluded from the counts:
{
"now": 1756800000,
"totals": { "1m": 12, "1h": 341, "1d": 5121, "7d": 40100, "31d": 160002, "1y": 1900003, "all": 2400000 },
"anon": { "1m": 1, "1h": 30, "1d": 400, "7d": 3100, "31d": 12000, "1y": 150000, "all": 200000 },
"avgPerHour": { "1d": 213.4, "7d": 238.7, "31d": 215.1, "1y": 216.9 },
"byKind": [
{
"kind": 2, "name": "status",
"totals": { "1m": 10, "1h": 300, "1d": 5000, "7d": 40000, "31d": 160000, "1y": 1900000, "all": 2399000 },
"anon": { "1m": 0, "1h": 10, "1d": 300, "7d": 3000, "31d": 11998, "1y": 149997, "all": 199800 }
},
{
"kind": 10, "name": "codes",
"totals": { "1m": 2, "1h": 41, "1d": 121, "7d": 100, "31d": 2, "1y": 3, "all": 1000 },
"anon": { "1m": 1, "1h": 20, "1d": 100, "7d": 100, "31d": 2, "1y": 3, "all": 200 }
}
],
"storage": { "databaseBytes": 116000000, "tableBytes": 105600000, "rowCount": 2400000, "bytesPerRow": 44, "bytesPerDay": 225324 }
}totals counts every call, anonymised ones included; anon is the anonymised
subset over the same windows, so anon[w] <= totals[w] always. Each byKind
entry carries the same pair for its kind. avgPerHour and
storage.bytesPerDay are derived from totals (anonymised rows occupy rows
too).
Stats are served from an in-memory cache, not from a query: every recorded
event increments per-kind (and per anonymised/full) time buckets, and the
cache is rebuilt from one
full scan at startup, once more 5 minutes later (a catch-up for rows the
outgoing instance of a rolling deploy wrote after the boot scan), then every
hour (retried after 5 minutes on failure).
Until the first scan completes the endpoint answers 503. 1m, 1h and
all are exact; 1d starts at the minute boundary at or before now - 1d,
and 7d, 31d, 1y at the hour boundary at or before their start — so those
windows are never shorter than advertised and at most one bucket longer.
storage.rowCount comes from the same cache; the byte sizes are sampled on
each rebuild, so a stats request never touches the database.
The event table is append-only: it is never trimmed. Operations notes:
- API queries run with
statement_timeout = 30s; the maintenance connection (stats scan, index build) with1h. Connections also carry a 10-second connect timeout and a client-side query timeout 5 seconds above the server statement timeout. - A btree on
ts(mapcode_request_ts_btree) is builtCONCURRENTLYat startup. If a build was interrupted, Postgres leaves itINVALID; this is detected at the next start, logged, and rebuilt automatically. - The maintenance connection is a single-connection pool, so at startup the
schema statement, the warm-up scan and the btree build run one after
another in that order. The stats endpoint answers
503until the scan completes; the index build (which does not block the API) follows it.
avgPerHour is totals[w] / hours(w) rounded to one decimal, for the windows
from 1d up (shorter windows are too bursty to average; all has an unknown
span). byKind breaks the same window counts down per endpoint kind (the
KIND vocabulary in src/routes/recording.ts), sorted by all-time count
descending; kinds with no rows are omitted, and totals are the column sums
of byKind. storage reports the current footprint (pg_database_size,
pg_total_relation_size for the event table including both its indexes —
BRIN and btree) and the burn rate: bytesPerRow is the average on-disk
footprint, bytesPerDay is that times the last-day event count.
storage.rowCount is the cache's physical row count (the scan-time count
plus recorder increments since; refreshed by the hourly rebuild), including
historical meta-traffic rows, so it can exceed
totals.all. Responses carry Cache-Control: private, max-age=60.
src/
index.ts entrypoint: load config → loadBorders → buildServer → listen
config.ts env-based configuration
errors.ts ApiError hierarchy (HTTP status + message)
server.ts Fastify app: error/404/405 handlers, route registration
routes/ HTTP layer (content negotiation, /xml//json/ aliases)
resources/ framework-agnostic handler logic (ports of *ResourceImpl)
domain/ MapcodeService (wraps mapcode-ts), borders loader, geo helpers
dto/ DTO build* factories + serialization Schemas
serialization/ JSON/XML serializers (byte-parity with Jackson/JAXB), formatDouble
storage/ Postgres: pool, schema, request recorder, replay and stats queries
test/ vitest suite (per-endpoint ports + parity sweep)
Apache-2.0.
- Border lookups now come from
mapcode-ts-borders1.0.0, which bundles the OpenStreetMap data of May 2026. The responses stay the same for production data. - The service always uses the data bundled with
mapcode-ts-borders.MAPCODE_BORDERS_PATHis no longer used and is ignored when set, so it can be removed from deployments. - The FlatGeobuf files are removed, together with the
flatgeobufandflatbushdependencies. - The startup log names the border data source.
- Updated
mapcode-tsto 1.0.3. It contains the same code as 1.0.2.
- Updated
mapcode-tsto 1.0.2, which renames 5 territories: COK to "Cook Islands", FJI to "Republic of Fiji", PSE to "Palestine", CA-NL to "Newfoundland and Labrador" and RU-IN to "Republic of Ingushetia". ThefullNamefield of the territory endpoints returns the new names.
- Updated dependencies, including TypeScript 7, vitest 5 and flatgeobuf 4.
- Fixed the anonymisation backfill, which flagged failed calls as anonymised.
- A Postgres restart no longer crashes the service.
Acceptranges are weighed by specificity,q=0is honoured,qvalues are clamped to 0..1 and ties go to JSON.- Error responses keep their 4xx status, and a 405 response carries an
Allowheader. - An unknown territory context is reported as a bad
parent. - Doubles outside [1e-3, 1e7) are formatted in E notation, and distances wrap longitudes, both matching the Java service.
- Added CI that runs the tests against Postgres 16 and typechecks the tests.
- Anonymised rows are marked, and replay and replay stats expose them.
- Calls with
allowLog=falseare recorded anonymised, with only their kind and country.
- Documented that the
webclient tag is deprecated.
- Corrected the README on
clientandallowLog.
- Recognize
demoas a client tag.
- Lowered the replay row cap to 200k.
- Raised the replay row cap from 500k to 1M.
- The stats cache is rebuilt hourly, with a catch-up right after boot.
- Widened replay to a 365-day window and 500k rows.
/mapcode/replay/statsis served from an incremental cache and answers 503 while the cache warms up.- Added a btree index on
mapcode_request.tsand a statement timeout on the Postgres pool.
- Successful calls to API endpoints are recorded in Postgres.
- Added token-protected
/mapcode/replayto read recorded calls back, with CORS. - Added
/mapcode/replay/statswith usage counters per window and endpoint kind, the storage footprint and the burn rate. - Postgres connections support verified TLS.
- Added request logging.
- First official TypeScript web service release.
- The versioning scheme changed to
major.minor.patch, dropping the last digit.