# styled-map-package-api

> JavaScript API for reading, writing, and serving Styled Map Package (.smp) files

Latest version **6.1.0** (published 2026-09-16) · MIT license · 0 weekly downloads

## Install

```sh
npm install styled-map-package-api
pnpm add styled-map-package-api
yarn add styled-map-package-api
bun add styled-map-package-api
```

## Health

**Score 75/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; has provenance; recently updated; high maintenance score; high quality score.

Warnings: low downloads.

## Facts

| | |
|---|---|
| Version | 6.1.0 |
| Published | 2026-09-16 |
| First published | 2026-03-17 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM + CommonJS |
| Node | >=22 |
| Dependencies | 16 |
| Unpacked size | 255 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Provenance | attested (GitHub Actions) |
| GitHub stars | 4 |
| Maintainers | digidem-admin |

## Links

- npm: https://www.npmjs.com/package/styled-map-package-api
- Repository: https://github.com/digidem/styled-map-package
- Homepage: https://github.com/digidem/styled-map-package#readme
- Issues: https://github.com/digidem/styled-map-package/issues
- npm.io page: https://npm.io/package/styled-map-package-api

## Dependencies (16)

- [ky](https://npm.io/package/ky.md) ^2.1.0
- [map-obj](https://npm.io/package/map-obj.md) ^6.0.0
- [p-limit](https://npm.io/package/p-limit.md) ^7.3.2
- [pmtiles](https://npm.io/package/pmtiles.md) ^4.5.0
- [@turf/bbox](https://npm.io/package/@turf/bbox.md) ^7.4.0
- [filter-obj](https://npm.io/package/filter-obj.md) ^6.1.0
- [zip-writer](https://npm.io/package/zip-writer.md) ^2.2.0
- [itty-router](https://npm.io/package/itty-router.md) ^5.0.24
- [yocto-queue](https://npm.io/package/yocto-queue.md) ^1.1.1
- [@turf/helpers](https://npm.io/package/@turf/helpers.md) ^7.4.0
- [mbtiles-reader](https://npm.io/package/mbtiles-reader.md) ^2.0.1
- [readable-stream](https://npm.io/package/readable-stream.md) ^4.7.0
- [@gmaclennan/zip-reader](https://npm.io/package/@gmaclennan/zip-reader.md) ^1.0.0
- [@mapbox/sphericalmercator](https://npm.io/package/@mapbox/sphericalmercator.md) ^2.0.2
- [@placemarkio/check-geojson](https://npm.io/package/@placemarkio/check-geojson.md) ^0.1.12
- [@maplibre/maplibre-gl-style-spec](https://npm.io/package/@maplibre/maplibre-gl-style-spec.md) ^26.4.1

## Recent versions

- 6.1.0 (latest) — 2026-09-16
- 5.0.0-pre.0 (pre) — 2026-03-17
- 6.0.1 — 2026-09-07
- 6.0.0 — 2026-09-03
- 5.0.0 — 2026-09-01
- 5.0.0-pre.5 — 2026-06-04
- 5.0.0-pre.4 — 2026-03-23
- 5.0.0-pre.3 — 2026-03-18
- 5.0.0-pre.2 — 2026-03-18
- 5.0.0-pre.1 — 2026-03-18

## README

# styled-map-package-api

JavaScript API for reading, writing, and serving Styled Map Package (`.smp`) files. Works in both Node.js and browsers.

An `.smp` file is a ZIP archive containing all the resources needed to serve a MapLibre vector styled map offline: style JSON, vector and raster tiles, glyphs (fonts), sprites, and metadata.

## Installation

Requires Node.js >= 22 (or a modern browser).

```sh
npm install styled-map-package-api
```

## Usage

### Reading an SMP file

```js
import { Reader } from 'styled-map-package-api/reader'

const reader = new Reader('path/to/map.smp')
const style = await reader.getStyle()
// Close the underlying file descriptor when done to free system resources
await reader.close()
```

The `Reader` constructor accepts a file path (Node.js) or a `ZipReader` instance (browser), and an optional options object:

- **`maxEntries`** — maximum number of ZIP entries to process (default: 500,000). Exceeding this limit throws an error to avoid DoS attacks with maliciously crafted ZIP files containing excessive entries.
- **`maxResourceSize`** — maximum uncompressed size in bytes for a single resource (default: 20 MiB). Exceeding this limit throws an error to prevent excessive memory usage.

If you pass a file path to the `Reader` constructor, it will keep the file open until you call `reader.close()`.

### Writing an SMP file

```js
import { Writer } from 'styled-map-package-api/writer'

const writer = new Writer(style, { dedupe: true })
const stream = writer.outputStream
// Pipe stream to a file or other writable destination

await writer.addTile(tileData, { z: 0, x: 0, y: 0, sourceId: 'my-source' })
await writer.addSprite({ json: spriteJson, png: spritePng })
await writer.addGlyphs(glyphData, { font: 'Noto Sans', range: '0-255' })

await writer.finish()
```

The `Writer` constructor takes a [MapLibre style](https://maplibre.org/maplibre-style-spec/) object and an optional options object:

- **`dedupe`** — when `true`, duplicate tiles (with identical content) are stored only once, reducing file size for tilesets with many repeated tiles (e.g. ocean tiles).

  > **Warning:** This deduplication technique causes a mismatch between the filename stored in the local file header and the filename in the aliased central directory entries. While the ZIP specification does not forbid this, many general-purpose ZIP tools do not handle it correctly. macOS Finder fails to expand such archives, Info-ZIP `unzip` emits warnings, and strict readers such as `yauzl` (Node.js) and Go's `archive/zip` may reject the entries. Writers that need the resulting archive to be compatible with general-purpose ZIP tools SHOULD NOT use this technique.

Sources are added implicitly when tiles are added via `addTile()`. Use `createTileWriteStream()` and `createGlyphWriteStream()` for concurrent writes. Call `setMetadata(key, value)` before `finish()` to set a property of the output style's `metadata`.

### Serving over HTTP

`createServer()` returns a `{ fetch }` object, where `fetch(request, reader)` is a handler that takes a [WHATWG `Request`](https://developer.mozilla.org/en-US/docs/Web/API/Request) and a `Reader` instance. On success it returns a [`Response`](https://developer.mozilla.org/en-US/docs/Web/API/Response). On failure it **throws** a `StatusError` (from `itty-router`) rather than returning an error response — this lets you decide how to respond to errors in your application.

A `StatusError` has a numeric `status` property (e.g. `404`) and a `message` string, so you can serialize it however you like:

```js
import { createServerAdapter } from '@whatwg-node/server'
import { Reader } from 'styled-map-package-api/reader'
import { createServer } from 'styled-map-package-api/server'

import { createServer as createHTTPServer } from 'node:http'

const reader = new Reader('path/to/map.smp')
const smpServer = createServer()

const httpServer = createHTTPServer(
  createServerAdapter(async (request) => {
    try {
      return await smpServer.fetch(request, reader)
    } catch (err) {
      const status = err.status || 500
      return new Response(JSON.stringify({ error: err.message, status }), {
        status,
        headers: { 'Content-Type': 'application/json' },
      })
    }
  }),
)

httpServer.listen(3000)
```

### Fallback tiles and glyphs

When an SMP file doesn't contain every tile or glyph range that the style references, `createServer` calls fallback handlers instead of returning a 404. This is useful for previewing incomplete packages or packages that only cover a partial area/zoom range.

By default `fallbackTile` is `emptyTileFallback` and `fallbackGlyph` is `emptyGlyphFallback` (both built in), so a plain `createServer()` serves empty tiles and glyphs for anything missing. Pass `null` to either option to return a 404 instead:

```js
import { createServer } from 'styled-map-package-api/server'

const server = createServer({
  fallbackTile: null,
  fallbackGlyph: null,
})
```

- **`emptyTileFallback(tileId, sourceInfo)`** — Returns an appropriate empty tile based on the source's tile format: empty gzipped MVT for vector sources, 1×1 transparent PNG/WebP for raster sources.
- **`emptyGlyphFallback(fontstack, range)`** — Returns an empty gzipped PBF (valid protobuf with no glyph entries), causing MapLibre to render missing characters as blank space instead of erroring on a 404.

Both are exported from `styled-map-package-api/fallbacks` if you want to wrap them.

For real glyph rendering with Noto Sans (80+ scripts), use the [`smp-noto-glyphs`](../glyphs/) package:

```js
import { notoGlyphFallback } from 'smp-noto-glyphs'

// fallbackTile keeps its default (empty tiles); only the glyph handler is overridden
const server = createServer({
  fallbackGlyph: notoGlyphFallback,
})
```

You can also provide custom fallback handlers, for example to proxy missing tiles from an online source:

```js
const server = createServer({
  fallbackTile: async (tileId, { sourceId, source }) => {
    const url = `https://tiles.example.com/${tileId.z}/${tileId.x}/${tileId.y}.mvt`
    return fetch(url)
  },
  fallbackGlyph: async (fontstack, range) => {
    return fetch(`https://fonts.example.com/${fontstack}/${range}.pbf`)
  },
})
```

`fallbackGlyph` also receives `{ style }`, the package's stored style, as a third argument. Packages written by `download()` only contain the glyph ranges their labels use, and are marked with `metadata['smp:glyphRanges']: 'used'`, so text added at runtime (e.g. a language switcher) can need ranges the package lacks. With the default fallback those characters render blank. Serve real glyphs for them (e.g. with `notoGlyphFallback`), or, if all your clients run MapLibre GL JS 5.11 or later, return a 404 so that MapLibre draws them with a local font. Older MapLibre GL JS versions and MapLibre Native fail the whole tile when a glyph range fails to load.

#### Packages with no glyphs

A style with no labels is packaged without a `glyphs` property, so nothing in it would ever request a glyph. When `fallbackGlyph` is set (the default), the server still serves glyph requests at the standard SMP glyph path and adds that path as `glyphs` to the served `style.json`. A client that adds its own symbol layer to such a map therefore gets fallback glyphs — empty ones by default, or real ones with `notoGlyphFallback` — instead of a 404. The stored `style.json` is left unchanged, and with `fallbackGlyph: null` no `glyphs` property is added.

### Rendering buffer tiles (`expandBounds`)

When `download()` is given a [`bufferTiles`](#downloading-a-map-for-offline-use) value, it downloads extra tile rings around the requested area at every zoom level below maxzoom and records the count as `smp:bufferTiles` in the style metadata. `smp:bounds` is derived from the tile extent at the maximum zoom level (which carries no buffer), so at lower zoom levels these buffer tiles extend geographically beyond the source `bounds` and MapLibre will not request them by default.

By default (`expandBounds: true`) the server widens each tile source's `bounds` to the whole world in the served `style.json`, so the lower-zoom buffer tiles are requested and rendered. The transform only applies when the package has `smp:bufferTiles` metadata, and the stored `style.json` is left unchanged. By default the server also pairs this with the built-in empty-tile fallback, so requests for tiles outside the downloaded area resolve to empty tiles rather than 404s. Pass `expandBounds: false` to disable the widening:

```js
// expandBounds and the empty-tile/glyph fallbacks are on by default
const server = createServer()
```

### Downloading a map for offline use

```js
import { download } from 'styled-map-package-api/download'

const stream = download({
  styleUrl: 'https://demotiles.maplibre.org/style.json',
  bbox: [-180, -80, 180, 80],
  maxzoom: 5,
  skipLocalGlyphs: true,
  onprogress: (progress) => console.log(progress),
})
// Pipe the ReadableStream to a file
```

**Options:**

| Option              | Type        | Description                                                                         |
| ------------------- | ----------- | ----------------------------------------------------------------------------------- |
| `styleUrl`          | `string`    | URL of the map style to download (required)                                         |
| `bbox`              | `BBox`      | Bounding box `[west, south, east, north]` for tile download (required)              |
| `maxzoom`           | `number`    | Maximum zoom level to download (required)                                           |
| `mapboxAccessToken` | `string?`   | Mapbox access token (required for Mapbox styles)                                    |
| `skipLocalGlyphs`   | `boolean?`  | Skip CJK/Hangul/Kana glyph ranges rendered client-side by MapLibre GL               |
| `allGlyphRanges`    | `boolean?`  | Download every glyph range, not only those used by labels in the downloaded tiles   |
| `dedupe`            | `boolean?`  | Store duplicate tiles only once to reduce file size                                 |
| `bufferTiles`       | `number?`   | Extra tile rings to download around `bbox` at each zoom below maxzoom (default `0`) |
| `onprogress`        | `function?` | Callback receiving a `DownloadProgress` object (see below)                          |

The `skipLocalGlyphs` option skips downloading glyph ranges that MapLibre GL renders client-side via [`localIdeographFontFamily`](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/MapOptions/) (CJK, Hangul, Kana, Yi, and Halfwidth/Fullwidth Forms — 163 of 256 ranges). This significantly reduces download size for styles that use these scripts.

By default only the glyph ranges needed for labels are downloaded, and the package is marked with `metadata['smp:glyphRanges']: 'used'`. While tiles download, the text in each vector tile is scanned for the properties used by the style's `text-field` expressions, and only the Unicode ranges that appear are fetched for each font. Range 0-255 is always included, along with the ranges MapLibre needs when it transforms text: upper- and lower-case forms (`text-transform`, `upcase`, `downcase`), Arabic presentation forms, vertical punctuation, and the digits and separators `number-format` produces. If the needed ranges can't be determined (e.g. a tile can't be parsed, a source is not MVT, or `number-format` shows a currency), every range is downloaded. Set `allGlyphRanges: true` to always download every range, e.g. if the package's style may later be edited to show other properties.

To do the same with the lower-level APIs, scan tiles with a `GlyphRangeCollector` (exported from the main entry) and pass its ranges to `getGlyphs()`:

```js
import { GlyphRangeCollector, StyleDownloader } from 'styled-map-package-api'

const downloader = new StyleDownloader(styleUrl)
const collector = new GlyphRangeCollector(await downloader.getStyle())
const tiles = downloader.getTiles({
  bounds,
  maxzoom,
  // Called with each uncompressed vector tile once it has been read
  onTileData: (data, sourceId) => collector.addTile(data, sourceId),
})
// ...read every tile stream to the end, then:
const glyphs = downloader.getGlyphs({
  // `null` means every range is needed
  ranges: collector.getRanges() ?? undefined,
})
```

`downloadTiles()` also accepts `onTileData` (called with the tile data only). `getRanges()` returns the start codepoints of the needed ranges (multiples of 256).

The `bufferTiles` option downloads extra tile rings around `bbox` at every zoom level below maxzoom so the map is not clipped at the edges of the downloaded area when zooming out (a single source `bounds` rectangle cannot describe a per-zoom buffer). The buffer is not added at maxzoom. When non-zero it is recorded in the package as `metadata['smp:bufferTiles']`, which `createServer`'s [`expandBounds`](#rendering-buffer-tiles-expandbounds) option can use to render those tiles.

Tile sources may reference either a [TileJSON](https://github.com/mapbox/tilejson-spec) endpoint or a [PMTiles](https://docs.protomaps.com/pmtiles/) archive (`url: "pmtiles://https://…/map.pmtiles"`). PMTiles archives are read over HTTP range requests, and only the tiles within `bbox`/`maxzoom` are downloaded.

The `onprogress` callback receives a `DownloadProgress` object:

```js
{
  tiles:   { downloaded, totalBytes, total, skipped, done },
  style:   { done },
  sprites: { downloaded, done },
  glyphs:  { downloaded, total, totalBytes, done },
  output:  { totalBytes, done },
  elapsedMs: number,
}
```

### Converting from MBTiles

> **Note:** Only raster MBTiles are currently supported — vector MBTiles will throw an error.

In Node, MBTiles reading goes through `mbtiles-reader`, which uses the native
`better-sqlite3`. It is declared here as an optional dependency accepting either
v12 or v13, so this package does not conflict with whichever major the rest of
your dependency tree has settled on.

```js
import { fromMBTiles } from 'styled-map-package-api/from-mbtiles'

// From a file path (Node.js)
const stream = fromMBTiles('path/to/tiles.mbtiles')

// From an ArrayBuffer or Uint8Array (Node.js and browsers)
const stream = fromMBTiles(buffer)

// Pipe the ReadableStream to an .smp file
```

## API

### Exports

| Export path                               | Description                                                                              |
| ----------------------------------------- | ---------------------------------------------------------------------------------------- |
| `styled-map-package-api`                  | Main entry — `Reader`, `Writer`, `createServer`, `download`, `GlyphRangeCollector`, etc. |
| `styled-map-package-api/reader`           | `Reader` class for reading `.smp` files                                                  |
| `styled-map-package-api/writer`           | `Writer` class for creating `.smp` files                                                 |
| `styled-map-package-api/server`           | `createServer()` — HTTP handler using WHATWG Request/Response                            |
| `styled-map-package-api/fallbacks`        | `emptyTileFallback`, `emptyGlyphFallback` — built-in fallbacks                           |
| `styled-map-package-api/download`         | `download()` — download an online map style for offline use                              |
| `styled-map-package-api/style-downloader` | `StyleDownloader` — downloads styles, sprites, and glyphs                                |
| `styled-map-package-api/tile-downloader`  | `downloadTiles()` — downloads tile data                                                  |
| `styled-map-package-api/from-mbtiles`     | `fromMBTiles()` — convert MBTiles to SMP stream                                          |
| `styled-map-package-api/validator`        | `validate()` — validate `.smp` files against the spec                                    |
| `styled-map-package-api/utils/mapbox`     | Mapbox URL detection and API utilities                                                   |

### Validating an SMP file

```js
import { validate } from 'styled-map-package-api/validator'

const result = await validate('path/to/map.smp')

if (!result.usable) {
  console.error('File cannot be opened')
} else if (!result.valid) {
  console.warn('File has issues but is usable')
}

for (const issue of result.issues) {
  console.log(`[${issue.severity}] ${issue.message}`)
}
```

The validator checks an `.smp` file against the [SMP specification](../../spec/1.0/) and returns structured issues. Each issue has:

- **`kind`** — `'error'` (spec MUST violation) or `'warning'` (SHOULD/RECOMMENDED)
- **`severity`** — practical impact on the reader/renderer:
  - `'fatal'` — the reader will fail to open the file
  - `'rendering'` — the map opens but content will be visibly broken (missing tiles, glyphs, sprites)
  - `'spec'` — non-compliance that doesn't affect practical use
- **`type`** — stable identifier for programmatic filtering (e.g. `'missing_tiles'`, `'incomplete_font_glyphs'`)
- **`message`** — human-readable description
- **`path`** — location context (e.g. `'sources.test.tiles'`, `'glyphs'`)

The result includes two convenience booleans:

- **`valid`** — `true` when there are no errors (spec-compliant)
- **`usable`** — `true` when there are no fatal issues (the file can be opened)

Accepts a file path (Node.js) or a `ZipReader` instance (browser). Options:

```js
const result = await validate('map.smp', {
  maxEntries: 500_000, // max ZIP entries before aborting (default: 500,000)
  glyphCoverage: true, // read tiles to check glyph ranges used by labels (default: true)
})
```

To check glyph coverage, the validator reads every vector tile (and GeoJSON file) that a labelled layer uses, and warns (`incomplete_font_glyphs`) when a font is missing a glyph range needed by that text, using the same rules as `download()`. Ranges MapLibre renders locally (CJK, Hangul, etc.) are never required. Reading tiles can take a while for large packages; set `glyphCoverage: false` to only require range 0-255.

### Browser support

All stream APIs use WHATWG `ReadableStream`, making the library compatible with both Node.js and browser environments. The `Reader` class accepts either a file path (Node.js) or a `ZipReader` instance (browser).

## License

MIT

---
_Source: https://npm.io/package/styled-map-package-api · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
