From 5aad53cdfa5310ef934b510dbe4d072eccf2957f Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Fri, 20 Mar 2026 11:10:44 +0000 Subject: [PATCH 1/3] fix: migrate styles when downloading StyleDownloader now calls migrate() from @maplibre/maplibre-gl-style-spec before validating styles, allowing v7 styles to be auto-upgraded to v8. Previously, v7 styles would fail validation in StyleDownloader and never reach the Writer (which already handles migration). Includes StyleDownloader test suite covering migration, constructor, getStyle, getSprites, getGlyphs, getTiles, and source inlining. --- packages/api/lib/style-downloader.js | 20 +- packages/api/test/style-downloader.js | 490 ++++++++++++++++++++++++++ packages/api/test/utils/smp-server.js | 36 ++ 3 files changed, 537 insertions(+), 9 deletions(-) create mode 100644 packages/api/test/style-downloader.js create mode 100644 packages/api/test/utils/smp-server.js diff --git a/packages/api/lib/style-downloader.js b/packages/api/lib/style-downloader.js index 4f62f05..4758ea0 100644 --- a/packages/api/lib/style-downloader.js +++ b/packages/api/lib/style-downloader.js @@ -1,3 +1,4 @@ +import { migrate } from '@maplibre/maplibre-gl-style-spec' import { check as checkGeoJson } from '@placemarkio/check-geojson' import { includeKeys } from 'filter-obj' import ky from 'ky' @@ -59,10 +60,13 @@ export class StyleDownloader { this.#mapboxAccessToken = searchParams.get('access_token') || mapboxAccessToken this.#styleURL = normalizeStyleURL(style, this.#mapboxAccessToken) - } else if (validateStyle(style)) { - this.#inputStyle = clone(style) } else { - throw new AggregateError(validateStyle.errors, 'Invalid style') + // Migrate actually mutates the input, so we act on a clone. + const styleV8 = migrate(clone(style)) + if (!validateStyle(styleV8)) { + throw new AggregateError(validateStyle.errors, 'Invalid style') + } + this.#inputStyle = styleV8 } this.#fetchQueue = new FetchQueue(concurrency) } @@ -82,13 +86,11 @@ export class StyleDownloader { async getStyle() { if (!this.#inputStyle && this.#styleURL) { const downloadedStyle = await ky(this.#styleURL).json() - if (!validateStyle(downloadedStyle)) { - throw new AggregateError( - validateStyle.errors, - 'Invalid style: ' + this.#styleURL, - ) + const styleV8 = migrate(downloadedStyle) + if (!validateStyle(styleV8)) { + throw new AggregateError(validateStyle.errors, 'Invalid style') } - this.#inputStyle = downloadedStyle + this.#inputStyle = styleV8 } else if (!this.#inputStyle) { throw new Error('Unexpected state: no style or style URL provided') } diff --git a/packages/api/test/style-downloader.js b/packages/api/test/style-downloader.js new file mode 100644 index 0000000..649aee1 --- /dev/null +++ b/packages/api/test/style-downloader.js @@ -0,0 +1,490 @@ +import { afterAll, assert, beforeAll, describe, test } from 'vitest' + +import { createServer as createHTTPServer } from 'node:http' +import { fileURLToPath } from 'node:url' + +import { StyleDownloader } from '../lib/index.js' +import { startSMPServer } from './utils/smp-server.js' +import { streamToBuffer } from './utils/stream-consumers.js' + +/** A minimal v7 style that should be auto-migrated to v8 */ +const V7_STYLE = { + version: 7, + sources: {}, + layers: [ + { + id: 'bg', + type: 'background', + paint: { 'background-color': 'red' }, + }, + ], +} + +describe('StyleDownloader style migration', () => { + test('constructor accepts v7 style object and migrates to v8', () => { + const downloader = new StyleDownloader(/** @type {any} */ (V7_STYLE)) + assert.equal(downloader.active, 0) + }) + + test('getStyle() returns v8 after migrating v7 style object', async () => { + const downloader = new StyleDownloader(/** @type {any} */ (V7_STYLE)) + const style = await downloader.getStyle() + assert.equal(style.version, 8) + }) + + test('v8 style passes through unchanged', async () => { + const v8Style = { + version: /** @type {const} */ (8), + sources: {}, + layers: /** @type {any[]} */ ([ + { id: 'bg', type: 'background', paint: { 'background-color': 'blue' } }, + ]), + } + const downloader = new StyleDownloader(v8Style) + const style = await downloader.getStyle() + assert.equal(style.version, 8) + }) + + test('constructor does not mutate the original style object', () => { + const original = JSON.parse(JSON.stringify(V7_STYLE)) + new StyleDownloader(/** @type {any} */ (original)) + assert.equal(original.version, 7, 'original should still be v7') + }) +}) + +describe('StyleDownloader v7 style from URL', () => { + /** @type {import('node:http').Server} */ + let jsonServer + /** @type {string} */ + let baseUrl + + beforeAll(async () => { + jsonServer = createHTTPServer((req, res) => { + if (req.url === '/v7-style.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(V7_STYLE)) + } else { + res.writeHead(404) + res.end() + } + }) + await /** @type {Promise} */ ( + new Promise((resolve) => jsonServer.listen(0, resolve)) + ) + const { port } = /** @type {import('node:net').AddressInfo} */ ( + jsonServer.address() + ) + baseUrl = `http://localhost:${port}/` + }) + + afterAll(async () => { + if (jsonServer) await new Promise((resolve) => jsonServer.close(resolve)) + }) + + test('getStyle() migrates v7 style downloaded from URL', async () => { + const downloader = new StyleDownloader(baseUrl + 'v7-style.json') + const style = await downloader.getStyle() + assert.equal(style.version, 8) + }) +}) + +describe('StyleDownloader with demotiles-z2', () => { + /** @type {{ baseUrl: string, close: () => Promise }} */ + let server + + beforeAll(async () => { + const fixturePath = fileURLToPath( + new URL('./fixtures/demotiles-z2.smp', import.meta.url), + ) + server = await startSMPServer(fixturePath) + }) + + afterAll(async () => { + if (server) await server.close() + }) + + test('constructor accepts a URL string', () => { + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + assert.equal(downloader.active, 0) + }) + + test('constructor accepts a StyleSpecification object', async () => { + const res = await fetch(server.baseUrl + 'style.json') + const style = await res.json() + const downloader = new StyleDownloader(style) + assert.equal(downloader.active, 0) + }) + + test('constructor throws for invalid style object', () => { + assert.throws( + () => + new StyleDownloader( + /** @type {any} */ ({ version: 8, sources: {}, layers: 'invalid' }), + ), + ) + }) + + test('getStyle() returns style with inlined sources', async () => { + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + const style = await downloader.getStyle() + + assert.equal(style.version, 8) + assert('maplibre' in style.sources, 'has maplibre source') + assert('crimea' in style.sources, 'has crimea source') + + // Vector source has inlined tiles array + const vectorSource = /** @type {any} */ (style.sources.maplibre) + assert.equal(vectorSource.type, 'vector') + assert(Array.isArray(vectorSource.tiles), 'vector source has tiles array') + assert(typeof vectorSource.tiles[0] === 'string') + + // GeoJSON source has inlined data object + const geojsonSource = /** @type {any} */ (style.sources.crimea) + assert.equal(geojsonSource.type, 'geojson') + assert.equal(typeof geojsonSource.data, 'object', 'geojson data is object') + }) + + test('getStyle() can be called multiple times', async () => { + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + const style1 = await downloader.getStyle() + const style2 = await downloader.getStyle() + assert.equal(style1.version, style2.version) + assert.deepEqual(Object.keys(style1.sources), Object.keys(style2.sources)) + }) + + test('getStyle() with style object returns inlined sources', async () => { + const res = await fetch(server.baseUrl + 'style.json') + const style = await res.json() + const downloader = new StyleDownloader(style) + const result = await downloader.getStyle() + + assert.equal(result.version, 8) + assert(Array.isArray(result.layers)) + }) + + test('getSprites() yields nothing for style without sprites', async () => { + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + const sprites = [] + for await (const sprite of downloader.getSprites()) { + sprites.push(sprite) + } + assert.equal(sprites.length, 0) + }) + + test('getGlyphs() yields glyph data for each font range', async () => { + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + const glyphs = [] + for await (const [stream, glyphInfo] of downloader.getGlyphs()) { + await streamToBuffer(stream) + glyphs.push(glyphInfo) + } + + assert(glyphs.length > 0, 'at least some glyphs downloaded') + // demotiles has 1 font: "Open Sans Semibold" + assert.equal(glyphs[0].font, 'Open Sans Semibold') + assert(typeof glyphs[0].range === 'string') + assert(glyphs[0].range.includes('-'), 'range has format "N-N"') + }) + + test('getGlyphs() reports progress', async () => { + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + /** @type {import('../lib/style-downloader.js').GlyphDownloadStats[]} */ + const progressUpdates = [] + const glyphs = downloader.getGlyphs({ + onprogress: (stats) => progressUpdates.push({ ...stats }), + }) + + for await (const [stream] of glyphs) { + await streamToBuffer(stream) + } + + assert(progressUpdates.length > 0, 'onprogress was called') + const last = progressUpdates[progressUpdates.length - 1] + assert.equal(last.total, 256, '256 glyph ranges for 1 font') + assert(last.downloaded > 0, 'some glyphs downloaded') + assert(last.totalBytes > 0, 'totalBytes > 0') + }) + + test('getGlyphs() yields nothing for style without glyphs', async () => { + const style = { + version: /** @type {const} */ (8), + sources: {}, + layers: /** @type {any[]} */ ([]), + } + const downloader = new StyleDownloader(style) + const glyphs = [] + for await (const entry of downloader.getGlyphs()) { + glyphs.push(entry) + } + assert.equal(glyphs.length, 0) + }) + + test('getTiles() yields tile data', async () => { + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + const tiles = downloader.getTiles({ + bounds: /** @type {const} */ ([-180, -85, 180, 85]), + maxzoom: 1, + }) + + const collected = [] + for await (const [stream, tileInfo] of tiles) { + const buf = await streamToBuffer(stream) + assert(buf.length > 0, 'tile is non-empty') + collected.push(tileInfo) + } + + assert(collected.length > 0, 'at least one tile') + assert(typeof collected[0].z === 'number') + assert(typeof collected[0].x === 'number') + assert(typeof collected[0].y === 'number') + assert(typeof collected[0].sourceId === 'string') + }) + + test('getTiles() exposes stats and skipped', async () => { + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + const tiles = downloader.getTiles({ + bounds: /** @type {const} */ ([-180, -85, 180, 85]), + maxzoom: 1, + }) + + for await (const [stream] of tiles) { + await streamToBuffer(stream) + } + + assert(tiles.stats.total > 0, 'total > 0') + assert(tiles.stats.downloaded > 0, 'downloaded > 0') + assert(tiles.stats.totalBytes > 0, 'totalBytes > 0') + assert.equal(tiles.skipped.length, 0, 'no skipped tiles') + }) + + test('getTiles() reports progress', async () => { + /** @type {import('../lib/tile-downloader.js').TileDownloadStats[]} */ + const progressUpdates = [] + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + const tiles = downloader.getTiles({ + bounds: /** @type {const} */ ([-180, -85, 180, 85]), + maxzoom: 1, + onprogress: (stats) => progressUpdates.push({ ...stats }), + }) + + for await (const [stream] of tiles) { + await streamToBuffer(stream) + } + + assert(progressUpdates.length > 0, 'onprogress was called') + const last = progressUpdates[progressUpdates.length - 1] + assert(last.downloaded > 0) + }) + + test('getTiles() skips non-tile sources like geojson', async () => { + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + const tiles = downloader.getTiles({ + bounds: /** @type {const} */ ([-180, -85, 180, 85]), + maxzoom: 1, + }) + + const sourceIds = new Set() + for await (const [stream, tileInfo] of tiles) { + await streamToBuffer(stream) + sourceIds.add(tileInfo.sourceId) + } + + assert(!sourceIds.has('crimea'), 'geojson source not in tile output') + assert(sourceIds.has('maplibre'), 'vector source is in tile output') + }) + + test('getTiles() clamps to source maxzoom', async () => { + // demotiles has maxzoom=2, so requesting maxzoom=10 should not yield z>2 + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + const tiles = downloader.getTiles({ + bounds: /** @type {const} */ ([-180, -85, 180, 85]), + maxzoom: 10, + }) + + let maxZSeen = 0 + for await (const [stream, tileInfo] of tiles) { + await streamToBuffer(stream) + if (tileInfo.z > maxZSeen) maxZSeen = tileInfo.z + } + + assert(maxZSeen <= 2, `max z seen was ${maxZSeen}, expected <= 2`) + }) +}) + +describe('StyleDownloader with osm-bright-z6 (sprites)', () => { + /** @type {{ baseUrl: string, close: () => Promise }} */ + let server + + beforeAll(async () => { + const fixturePath = fileURLToPath( + new URL('./fixtures/osm-bright-z6.smp', import.meta.url), + ) + server = await startSMPServer(fixturePath) + }) + + afterAll(async () => { + if (server) await server.close() + }) + + test('getSprites() yields sprite data at 1x and 2x', async () => { + const downloader = new StyleDownloader(server.baseUrl + 'style.json') + const sprites = [] + for await (const sprite of downloader.getSprites()) { + const jsonBuf = await streamToBuffer(sprite.json) + const pngBuf = await streamToBuffer(sprite.png) + assert(jsonBuf.length > 0, 'json buffer is non-empty') + assert(pngBuf.length > 0, 'png buffer is non-empty') + sprites.push({ id: sprite.id, pixelRatio: sprite.pixelRatio }) + } + + assert.equal(sprites.length, 2, '1x and 2x') + assert.equal(sprites[0].id, 'default') + assert.equal(sprites[0].pixelRatio, 1) + assert.equal(sprites[1].id, 'default') + assert.equal(sprites[1].pixelRatio, 2) + }) +}) + +describe('StyleDownloader with un-inlined sources', () => { + /** @type {{ baseUrl: string, close: () => Promise }} */ + let smpServer + /** @type {import('node:http').Server} */ + let jsonServer + /** @type {string} */ + let jsonBaseUrl + + const TILEJSON = { + tilejson: '3.0.0', + tiles: /** @type {string[]} */ (['PLACEHOLDER']), + bounds: [-180, -85, 180, 85], + maxzoom: 2, + minzoom: 0, + vector_layers: [{ id: 'test', fields: {} }], + } + + const GEOJSON = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [0, 0] }, + properties: { name: 'test' }, + }, + ], + } + + beforeAll(async () => { + // Start the SMP server to get real tile URLs + const fixturePath = fileURLToPath( + new URL('./fixtures/demotiles-z2.smp', import.meta.url), + ) + smpServer = await startSMPServer(fixturePath) + + // Fetch the real style to get the tile URL pattern + const res = await fetch(smpServer.baseUrl + 'style.json') + const style = await res.json() + const vectorSource = /** @type {any} */ ( + Object.values(style.sources).find( + (/** @type {any} */ s) => s.type === 'vector', + ) + ) + TILEJSON.tiles = vectorSource.tiles + + // Start a simple JSON server for TileJSON and GeoJSON endpoints + jsonServer = createHTTPServer((req, res) => { + if (req.url === '/tilejson.json') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(TILEJSON)) + } else if (req.url === '/data.geojson') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify(GEOJSON)) + } else { + res.writeHead(404) + res.end() + } + }) + await /** @type {Promise} */ ( + new Promise((resolve) => jsonServer.listen(0, resolve)) + ) + const { port } = /** @type {import('node:net').AddressInfo} */ ( + jsonServer.address() + ) + jsonBaseUrl = `http://localhost:${port}/` + }) + + afterAll(async () => { + if (smpServer) await smpServer.close() + if (jsonServer) await new Promise((resolve) => jsonServer.close(resolve)) + }) + + test('getStyle() inlines vector source with url (TileJSON)', async () => { + const style = { + version: /** @type {const} */ (8), + sources: { + myVector: { + type: /** @type {const} */ ('vector'), + url: jsonBaseUrl + 'tilejson.json', + }, + }, + layers: /** @type {any[]} */ ([]), + } + const downloader = new StyleDownloader(style) + const result = await downloader.getStyle() + + const source = /** @type {any} */ (result.sources.myVector) + assert.equal(source.type, 'vector') + assert(Array.isArray(source.tiles), 'has tiles array from TileJSON') + assert(source.tiles.length > 0, 'tiles array is non-empty') + assert.deepEqual(source.bounds, [-180, -85, 180, 85]) + assert.equal(source.maxzoom, 2) + assert.deepEqual(source.vector_layers, [{ id: 'test', fields: {} }]) + }) + + test('getStyle() inlines geojson source with string data URL', async () => { + const style = { + version: /** @type {const} */ (8), + sources: { + myGeojson: { + type: /** @type {const} */ ('geojson'), + data: jsonBaseUrl + 'data.geojson', + }, + }, + layers: /** @type {any[]} */ ([]), + } + const downloader = new StyleDownloader(style) + const result = await downloader.getStyle() + + const source = /** @type {any} */ (result.sources.myGeojson) + assert.equal(source.type, 'geojson') + assert.equal(typeof source.data, 'object', 'data is now an object') + assert.equal(source.data.type, 'FeatureCollection') + assert.equal(source.data.features.length, 1) + assert.equal(source.data.features[0].properties.name, 'test') + }) + + test('getTiles() works with un-inlined vector source', async () => { + const style = { + version: /** @type {const} */ (8), + sources: { + myVector: { + type: /** @type {const} */ ('vector'), + url: jsonBaseUrl + 'tilejson.json', + }, + }, + layers: /** @type {any[]} */ ([]), + } + const downloader = new StyleDownloader(style) + const tiles = downloader.getTiles({ + bounds: /** @type {const} */ ([-180, -85, 180, 85]), + maxzoom: 0, + }) + + let count = 0 + for await (const [stream, tileInfo] of tiles) { + await streamToBuffer(stream) + assert.equal(tileInfo.sourceId, 'myVector') + count++ + } + assert(count > 0, 'downloaded at least one tile') + }) +}) diff --git a/packages/api/test/utils/smp-server.js b/packages/api/test/utils/smp-server.js new file mode 100644 index 0000000..1d6046f --- /dev/null +++ b/packages/api/test/utils/smp-server.js @@ -0,0 +1,36 @@ +import { createServerAdapter } from '@whatwg-node/server' +import { error } from 'itty-router/error' + +import { createServer as createHTTPServer } from 'node:http' + +import { createServer, Reader } from '../../lib/index.js' + +/** + * Start a local HTTP server that serves an SMP fixture. + * + * @param {string} fixturePath - Absolute path to an .smp file + * @returns {Promise<{ baseUrl: string, close: () => Promise }>} + */ +export async function startSMPServer(fixturePath) { + const reader = new Reader(fixturePath) + const smpServer = createServer() + const httpServer = createHTTPServer( + createServerAdapter((request) => + smpServer.fetch(request, reader).catch(error), + ), + ) + await /** @type {Promise} */ ( + new Promise((resolve) => httpServer.listen(0, resolve)) + ) + const { port } = /** @type {import('node:net').AddressInfo} */ ( + httpServer.address() + ) + return { + baseUrl: `http://localhost:${port}/`, + close: () => + new Promise((resolve, reject) => { + httpServer.close((err) => (err ? reject(err) : resolve())) + reader.close() + }), + } +} From f39e6660e7ec1430a7e98709f7cee4d8794d56df Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Fri, 20 Mar 2026 22:28:57 +0000 Subject: [PATCH 2/3] fix imports in tests --- packages/api/test/style-downloader.js | 2 +- packages/api/test/utils/smp-server.js | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/api/test/style-downloader.js b/packages/api/test/style-downloader.js index 649aee1..e97004d 100644 --- a/packages/api/test/style-downloader.js +++ b/packages/api/test/style-downloader.js @@ -3,7 +3,7 @@ import { afterAll, assert, beforeAll, describe, test } from 'vitest' import { createServer as createHTTPServer } from 'node:http' import { fileURLToPath } from 'node:url' -import { StyleDownloader } from '../lib/index.js' +import { StyleDownloader } from '../lib/style-downloader.js' import { startSMPServer } from './utils/smp-server.js' import { streamToBuffer } from './utils/stream-consumers.js' diff --git a/packages/api/test/utils/smp-server.js b/packages/api/test/utils/smp-server.js index 1d6046f..6ba4588 100644 --- a/packages/api/test/utils/smp-server.js +++ b/packages/api/test/utils/smp-server.js @@ -3,7 +3,8 @@ import { error } from 'itty-router/error' import { createServer as createHTTPServer } from 'node:http' -import { createServer, Reader } from '../../lib/index.js' +import { Reader } from '../../lib/reader.js' +import { createServer } from '../../lib/server.js' /** * Start a local HTTP server that serves an SMP fixture. From 52d7a2dca5700c5a9dd117ce42902cfe1fba2638 Mon Sep 17 00:00:00 2001 From: Gregor MacLennan Date: Fri, 20 Mar 2026 22:30:50 +0000 Subject: [PATCH 3/3] changeset --- .changeset/migrate-downloaded-styles.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/migrate-downloaded-styles.md diff --git a/.changeset/migrate-downloaded-styles.md b/.changeset/migrate-downloaded-styles.md new file mode 100644 index 0000000..3d8ac2f --- /dev/null +++ b/.changeset/migrate-downloaded-styles.md @@ -0,0 +1,5 @@ +--- +'styled-map-package-api': patch +--- + +Fix: automatically migrate v7 styles to v8 when downloading or constructing a StyleDownloader, instead of rejecting them as invalid.