Browser SDK
https://uzmaps.ndc.uz/v1/uzmaps.js is the map platform as one classic <script> tag. The file carries @uzmaps/engine, @uzmaps/api, @uzmaps/cartography and MapLibre GL JS, injects its own stylesheet, and reads the API key and the server address off the tag that loaded it. It exists for pages that have no bundler: a CMS template, a landing page, a dashboard maintained as HTML.
<div id="map" style="width:100%;height:400px"></div>
<script src="https://uzmaps.ndc.uz/v1/uzmaps.js?apikey=YOUR_KEY"></script>
<script>
uzmaps.ready(function () {
var map = new uzmaps.Map({
container: 'map',
center: [69.2401, 41.2995], // [lon, lat]
zoom: 12
});
});
</script>The script tag#
Three things are read from the tag at the moment the script executes, via document.currentScript. They are read then, and not later, because document.currentScript is only non-null while the script is running.
| Taken from the tag | Becomes | Notes |
|---|---|---|
?apikey= query parameter | the default API key | ?key= is accepted as an alias, because it is the name the REST API uses. When both are present apikey wins. |
| the script URL's origin (scheme, host, port) | the default server, e.g. https://uzmaps.ndc.uz | Only the origin: the script lives under /v1/, the API does not. |
the script URL's directory, e.g. https://uzmaps.ndc.uz/v1/ | where MapLibre's worker is fetched from | maplibre-gl-worker.mjs and maplibre-gl-shared.mjs must sit beside uzmaps.js. |
Two rules follow from how this is read:
- Load it as a classic script with a
srcattribute. Inside a module scriptdocument.currentScriptisnull, and the bundle is an IIFE that assigns tothis.uzmaps, sotype="module"breaks it. Pasting the file's contents into an inline<script>also loses the key, the server and the worker location: with nosrcthere is nothing to read, and the SDK falls back to an empty server URL and no key. - Without a key the script still loads and
uzmaps.defaults.apiKeyisundefined. On a server running with--require-key, tiles and API calls are then refused. See API keys.
How the key travels matters for origin allowlists and access logs:
- API calls under
/api/send it as anX-API-Keyheader. - Tiles, glyphs and sprites are fetched by MapLibre itself, where the SDK cannot set headers, so the map appends
?key=to those URLs. It does this only for URLs that start with the configured server, so a source you point at a third party never receives your key. /v1/itself is never key-gated. The script has to load before it can present a key, and a JSON 401 where the browser expects JavaScript is a syntax error, not a diagnosis.
What is on the global#
The bundle assigns its named exports onto window.uzmaps. There is no uzmaps.default: a default export in an IIFE build would land one level deeper than everything else, and that mismatch is invisible to the type checker.
| Member | Type | What it is |
|---|---|---|
uzmaps.Map | class, new Map(options: UzMapOptions) | UzMap from @uzmaps/engine with apiKey and serverUrl filled in from the tag when you do not pass them. It is a real subclass, so map instanceof uzmaps.UzMap holds and every engine method and event works unchanged. |
uzmaps.Client | class, new Client(options?: ClientOptions) | UzMapClient from @uzmaps/api with apiKey filled in. Read the note on baseUrl below before using it. |
uzmaps.ready(fn?) | (fn?: () => void) => Promise<void> | Resolves once the DOM is ready. |
uzmaps.version | string | The bundle's version, taken from @uzmaps/engine's package.json at build time. 0.1.1 in the current build. |
uzmaps.defaults | { serverUrl: string; apiKey: string | undefined; dir: string } | What was read from the script tag. |
uzmaps.buildStyle(opts?) | (opts?: StyleOptions) => StyleSpecification | The cartography's style generator, for driving MapLibre directly. Takes serverUrl, theme, language and the other StyleOptions fields; nothing is filled in from the tag. |
uzmaps.themes | Record<'light' | 'dark', ThemeTokens> | The two colour token sets the styles are built from. |
uzmaps.UzMap, uzmaps.UzMapClient | classes | The underlying engine and API classes, without the script-tag defaults. |
Overriding the defaults per map#
Pass apiKey or serverUrl and the tag's value is ignored for that instance, so one page can talk to two deployments or use two keys:
var other = new uzmaps.Map({
container: 'map2',
apiKey: 'OTHER_KEY',
serverUrl: 'https://maps.example.uz'
});The defaults are applied only when the option is undefined; an empty string or any other value you pass is kept.
uzmaps.Client and the server address#
The key is filled in for a Client exactly as for a Map. The server is not. ClientOptions calls it baseUrl, and the SDK fills baseUrl in only when the options object already has a baseUrl property and its value is empty or undefined. new uzmaps.Client() therefore receives the key from the tag but keeps baseUrl as '', which UzMapClient treats as the page's own origin. For a page that embeds the script from uzmaps.ndc.uz, that is the wrong host: every call goes to your own site instead of the map server.
This was confirmed by loading the built bundle from a page on a different origin: new uzmaps.Client().baseUrl is '', while new uzmaps.Client({ baseUrl: undefined }).baseUrl is the script's origin.
Two ways that work:
// 1. A map already carries a client configured with its own server, key and language.
var api = map.api;
// 2. A standalone client: name the server explicitly.
var api = new uzmaps.Client({ baseUrl: uzmaps.defaults.serverUrl });map.api is the readonly api: UzMapClient the engine constructs from the map's serverUrl, apiKey and language.
uzmaps.defaults#
| Field | Value |
|---|---|
serverUrl | The script's origin, e.g. https://uzmaps.ndc.uz. '' when the script was not loaded through src. |
apiKey | The apikey (or key) parameter; undefined when absent. |
dir | The script's directory with its trailing slash, e.g. https://uzmaps.ndc.uz/v1/. |
When a key appears not to work, console.log(uzmaps.defaults) is the first check: it shows what the tag actually supplied.
ready()#
uzmaps.ready(fn?: () => void): Promise<void>If document.readyState is 'loading', it waits for DOMContentLoaded; otherwise it resolves on the next microtask. With fn it returns promise.then(fn); without, the bare promise, so await uzmaps.ready() works as well.
It exists because the tag is often placed in <head>, where the script runs before <div id="map"> exists, and new uzmaps.Map({ container: 'map' }) would then have no container to find. It is also the one stable place to put your code should the SDK ever need asynchronous initialisation.
It is DOM readiness, not map readiness. For the map itself use its events, for example map.on('load', fn).
<head>
<script src="https://uzmaps.ndc.uz/v1/uzmaps.js?apikey=YOUR_KEY"></script>
<script>
uzmaps.ready(function () {
// Without center and zoom the map opens on Tashkent ([69.2797, 41.3111]) at zoom 12.
var map = new uzmaps.Map({ container: 'map' });
map.on('load', function () { console.log('map loaded'); });
});
</script>
</head>Searching and routing#
uzmaps.ready(async function () {
var map = new uzmaps.Map({ container: 'map', center: [69.24, 41.29], zoom: 12 });
var api = map.api;
var found = await api.search('Chilonzor'); // SearchResponse
if (found.results.length) {
map.selectPlace(found.results[0], { fly: true });
}
var trip = await api.route({ // RouteResponse
points: [[69.24, 41.29], [69.28, 41.31]], // [lon, lat]
mode: 'car'
});
map.showRoutes(trip.routes);
});search(query, options?) returns { query, results: SearchResult[], took_ms, intent? }. route(request) returns { routes: Route[], language, engine, warnings? }; mode is one of 'car' | 'foot' | 'bike' | 'taxi' | 'truck' | 'motorcycle'. selectPlace(place, { fly?, geometry? }) highlights a place and flies to it unless fly: false. showRoutes(routes, waypoints?, { selectedId?, fit?, fitPadding? }) draws the routes and frames them. Both the map and the client default to language: 'uz'; the other values are 'uz-Cyrl', 'ru' and 'en'.
Versioning under /v1/#
The server mounts the built SDK directory at /v1/ with http.StripPrefix("/v1/", sdkHandler(dir)) and serves four files:
| File | Purpose |
|---|---|
/v1/uzmaps.js | The bundle. |
/v1/uzmaps.js.map | Source map; fetched by devtools only. |
/v1/maplibre-gl-worker.mjs | MapLibre's worker, fetched when the first map is created. |
/v1/maplibre-gl-shared.mjs | The worker's shared chunk, imported by the worker. |
The prefix is the compatibility promise. The URL in a page is not something its owner will revisit, so the stated policy is that a breaking change is published under a new prefix while /v1/ keeps serving what it serves today. Within /v1/, uzmaps.version says which build a page has; it tracks the @uzmaps/engine release the bundle was built from.
The worker files are there because MapLibre normally finds its worker through import.meta.url, which an IIFE does not have. The SDK therefore calls setWorkerUrl(defaults.dir + 'maplibre-gl-worker.mjs'), and the build copies the worker and everything it imports into dist beside the bundle. If they are missing, the map hangs on a blank canvas with nothing but a MIME-type complaint in the console.
Caching#
Headers are set by the Go handler (sdk.go, asserted in sdk_test.go). nginx passes them through untouched: its /v1/ block deliberately adds no Cache-Control of its own, so the no-store rule for the app shell does not end up on the same response (deploy/web/server/nginx.conf).
| Path | Cache-Control | Why |
|---|---|---|
/v1/uzmaps.js | public, max-age=3600, stale-while-revalidate=86400 | It cannot be immutable: pages that will never be edited depend on this URL, so a fix has to reach them. It should not be uncacheable either, or every visit downloads it again. An hour bounds how long a fix takes to arrive. |
/v1/*.map | public, max-age=3600 | Devtools only. |
everything else (the .mjs chunks) | public, max-age=31536000, immutable | These change only when a new bundle ships. |
Every /v1/ response also carries Access-Control-Allow-Origin: *: a CDN script is loaded cross-origin by definition, and the worker started from it inherits that.
In practice a returning visitor may run the previous uzmaps.js for up to an hour after a release, and can be served it stale for a day while the browser revalidates. No cache-busting parameter is needed on the tag.
The injected stylesheet#
The bundle inlines maplibre-gl/dist/maplibre-gl.css (MapLibre GL JS 6.7.0) and, when it runs, prepends <style id="uzmaps-styles"> to <head>. There is no <link> to add.
- Once per page. The element is looked up by id before it is created, so a page that includes the script twice (a CMS and a widget, say) gets one copy rather than stacked duplicate rules.
- Prepended, not appended, so your own stylesheets come later in the cascade and win at equal specificity. Override MapLibre's control styling with ordinary CSS.
- The id is fixed. If you place your own element with
id="uzmaps-styles"in the page, the SDK will assume its stylesheet is present and skip injection.
Size#
Measured from npm run build:sdk on the current source (@uzmaps/engine 0.1.1, MapLibre GL JS 6.7.0). The build configuration itself states no size: chunkSizeWarningLimit: 3000 in apps/sdk/vite.config.ts is only the threshold for a build warning, raised because the bundle legitimately contains MapLibre.
| File | Bytes | gzip |
|---|---|---|
uzmaps.js | 1,153,801 | 292,869 |
maplibre-gl-worker.mjs | 19,181 | 6,167 |
maplibre-gl-shared.mjs | 492,183 | 137,710 |
uzmaps.js.map | 2,609,590 | 524,682 |
So about 1.15 MB (293 kB gzipped) to load the script, and the first map then fetches the two worker files, about 0.5 MB more (144 kB gzipped). Nothing is left external: MapLibre is inside uzmaps.js and the page needs no other library. The gzip column is what a proxy that compresses JavaScript would send; the raw column is what crosses the wire otherwise.
The build targets ES2020, wider than the demo app's ES2022, because the file lands on pages nobody on this project controls.
Hosting the SDK yourself#
The bundle ships inside the server image: services/server/Dockerfile runs npm run build:sdk, copies apps/sdk/dist to /app/sdk, and starts uzmap serve with --sdk /app/sdk. --sdk names the directory. If it is empty, or does not contain uzmaps.js, the server starts anyway and /v1/ is not served, because the API and the app do not depend on it.
If you put uzmaps.js somewhere other than the API server, a CDN or your own static host, keep the four files together, and set serverUrl on every map and baseUrl on every client: the inferred server would otherwise be the static host.
When to use npm instead#
/v1/uzmaps.js | npm install @uzmaps/engine | |
|---|---|---|
| Needs a bundler | No | Yes |
| Key and server | Inferred from the tag | Passed as apiKey and serverUrl |
| MapLibre | Inlined, pinned to the bundle's version | Installed as a dependency, one copy shared with the rest of your app |
| CSS | Injected | import 'maplibre-gl/dist/maplibre-gl.css' yourself |
| Types | None | TypeScript declarations in the package |
| Published | Served by the map server; the @uzmaps/sdk-bundle workspace is private | @uzmaps/engine 0.1.1 on npm |
Both are the same code. uzmaps.Map is UzMap, so an integration can move from the tag to npm without changing a call. Use npm when you already have a build; use the tag when you do not.
Where each statement was checked#
| Section |
|---|
Script tag, key and server inference, defaults, ready(), stylesheet injection, global members |
UzMapOptions, map.api, map.on, default centre and zoom, ?key= on tile URLs |
ClientOptions, X-API-Key, search, route, response types, language values |
buildStyle, themes |
| Bundle format, version define, worker copy, ES target |
Mount at /v1/, not gated, --sdk flag |
| Cache headers, CORS header, missing-directory behaviour |
| nginx pass-through |
Image build and --sdk /app/sdk |
| Sizes |
| npm publication and version |