npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

styled-map-package-api

v6.2.0

Published

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

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).

npm install styled-map-package-api

Usage

Reading an SMP file

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

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 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 and a Reader instance. On success it returns a 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:

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:

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 package:

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:

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 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:

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

Downloading a map for offline use

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

const stream = download({
  style: '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 | | ------------------- | ------------------------------ | ----------------------------------------------------------------------------------- | | style | string \| StyleSpecification | URL of the map style to download, or a style object (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) | | beforeFinish | function? | Called with the Writer before the package is finished (see below) | | onprogress | function? | Callback receiving a DownloadProgress object (see below) |

The beforeFinish(writer, { signal, glyphRanges }) option is called once everything has downloaded, before the package is finished, so you can add resources that aren't downloaded: sprites, tiles, glyphs or metadata. writer has the Writer methods for adding resources (addTile, addSprite, addGlyphs, setMetadata, createTileWriteStream and createGlyphWriteStream), but not finish(), abort() or outputStream, which stay with download(); throw to fail the download. The style is still the source of truth for what the package contains, so reference those resources in the style you pass in with the package's own smp: URLs, which are not downloaded:

| Resource | In the style | In beforeFinish | | -------- | ---------------------------------------------- | ---------------------- | | Sprite | sprite: [{ id, url: getSpriteUri(id) }] | writer.addSprite() | | Tiles | sources: { id: { type, tiles: [TILE_URI] } } | writer.addTile() | | Glyphs | glyphs: GLYPH_URI | writer.addGlyphs() | | Metadata | — | writer.setMetadata() |

getSpriteUri, TILE_URI and GLYPH_URI are exported from styled-map-package-api. The Writer replaces any smp: URL with the resource's URL in the package, so only the sprite id matters. For example, to add icons for layers you added to a style object:

import { download, getSpriteUri } from 'styled-map-package-api'

const sprites =
  typeof style.sprite === 'string'
    ? [{ id: 'default', url: style.sprite }]
    : (style.sprite ?? [])

const stream = download({
  style: {
    ...style,
    sprite: [...sprites, { id: 'overlays', url: getSpriteUri('overlays') }],
  },
  bbox,
  maxzoom,
  async beforeFinish(writer) {
    await writer.addSprite({ id: 'overlays', pixelRatio: 1, json, png })
    await writer.addSprite({
      id: 'overlays',
      pixelRatio: 2,
      json: json2x,
      png: png2x,
    })
  },
})

Layers reference the added icons as overlays:<name>, while a string sprite's own icons keep their names because it becomes the default sprite. Add each sprite at pixel ratios 1 and 2: MapLibre requests sprite.json or [email protected] depending on screen density, with no fallback.

A few things to know when adding resources:

  • download() fails before downloading anything if the style has smp: URLs but no beforeFinish, e.g. a style read from an existing package. It fails after beforeFinish if a source with smp: tiles got no tiles, if a referenced sprite wasn't added, or if the style has glyphs: GLYPH_URI but no glyphs were added ("Missing fonts").
  • A source's smp: tiles URL must be its only one: smp: URLs mixed with other tile URLs, in a source's url, in a raster-dem source or as GeoJSON data are rejected. Include GeoJSON data in the style instead.
  • You can add tiles to a downloaded source too, e.g. at zoom levels above maxzoom, but adding a tile or glyph range that was already downloaded throws.
  • glyphRanges holds the start codepoints of the glyph ranges needed for the labels in the downloaded tiles, or is undefined if every range is needed. Use it to add only the ranges you need for fonts you provide with GLYPH_URI; such packages are not marked with smp:glyphRanges, since you choose the ranges. Tiles you add aren't scanned for labels, so every range is needed when a source with smp: tiles has labels. Labels in tiles you add to a downloaded source aren't covered either: set allGlyphRanges: true if they may use other scripts.
  • signal is aborted if the download is cancelled. Cancelling waits for beforeFinish to return, so stop any long-running work when it aborts.

The skipLocalGlyphs option skips downloading glyph ranges that MapLibre GL renders client-side via localIdeographFontFamily (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():

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 option can use to render those tiles.

Tile sources may reference either a TileJSON endpoint or a 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:

{
  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.

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

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 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:

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