Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/happy-tests-grow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'styled-map-package-api': patch
---

Fix uncaught error in `download()` when the style URL is unreachable — errors now propagate through the returned stream instead of becoming unhandled rejections. Accept `Readonly<BBox>` in public API type signatures.
24 changes: 15 additions & 9 deletions packages/api/lib/download.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { Writer } from './writer.js'
* containing all the resources needed to serve the style offline.
*
* @param {object} opts
* @param {import("./utils/geo.js").BBox} opts.bbox Bounding box to download tiles for
* @param {Readonly<import("./utils/geo.js").BBox>} opts.bbox Bounding box to download tiles for
* @param {number} opts.maxzoom Max zoom level to download tiles for
* @param {string} opts.styleUrl URL of the style to download
* @param { (progress: DownloadProgress) => void } [opts.onprogress] Optional callback for reporting progress
Expand Down Expand Up @@ -74,14 +74,16 @@ export function download({
})

;(async () => {
const style = await downloader.getStyle()
const writer = new Writer(style, { dedupe: !!dedupe })
handleProgress({ style: { done: true } })
// Pipe the output stream through the size counter (fire-and-forget;
// errors propagate via writer.abort())
writer.outputStream.pipeTo(sizeCounter.writable).catch(() => {})

/** @type {Writer | undefined} */
let writer
try {
const style = await downloader.getStyle()
writer = new Writer(style, { dedupe: !!dedupe })
handleProgress({ style: { done: true } })
// Pipe the output stream through the size counter (fire-and-forget;
// errors propagate via writer.abort())
writer.outputStream.pipeTo(sizeCounter.writable).catch(() => {})

for await (const spriteInfo of downloader.getSprites()) {
await writer.addSprite(spriteInfo)
handleProgress({
Expand Down Expand Up @@ -111,7 +113,11 @@ export function download({

writer.finish()
} catch (err) {
writer.abort(/** @type {Error} */ (err))
if (writer) {
writer.abort(/** @type {Error} */ (err))
} else {
sizeCounter.writable.abort(/** @type {Error} */ (err))
}
}
})()

Expand Down
2 changes: 1 addition & 1 deletion packages/api/lib/style-downloader.js
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ export class StyleDownloader {
* bytes downloaded.
*
* @param {object} opts
* @param {import('./utils/geo.js').BBox} opts.bounds
* @param {Readonly<import('./utils/geo.js').BBox>} opts.bounds
* @param {number} opts.maxzoom
* @param {(progress: TileDownloadStats) => void} [opts.onprogress]
* @param {boolean} [opts.trackErrors=false] Include errors in the returned array of skipped tiles - this has memory overhead so should only be used for debugging.
Expand Down
8 changes: 4 additions & 4 deletions packages/api/lib/tile-downloader.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,11 @@ import { noop } from './utils/misc.js'
*
* @param {object} opts
* @param {string[]} opts.tileUrls Array of tile URL templates. Use `{x}`, `{y}`, `{z}` placeholders, and optional `{scheme}` placeholder which can be `xyz` or `tms`.
* @param {import('./utils/geo.js').BBox} opts.bounds Bounding box of the area to download
* @param {Readonly<import('./utils/geo.js').BBox>} opts.bounds Bounding box of the area to download
* @param {number} opts.maxzoom Maximum zoom level to download
* @param {(progress: TileDownloadStats) => void} [opts.onprogress] Callback to report download progress
* @param {boolean} [opts.trackErrors=false] Include errors in the returned array of skipped tiles - this has memory overhead so should only be used for debugging.
* @param {import('./utils/geo.js').BBox} [opts.sourceBounds=MAX_BOUNDS] Bounding box of source data.
* @param {Readonly<import('./utils/geo.js').BBox>} [opts.sourceBounds=MAX_BOUNDS] Bounding box of source data.
* @param {boolean} [opts.boundsBuffer=false] Buffer the bounds by one tile at each zoom level to ensure no tiles are missed at the edges. With this set to false, in most instances the map will appear incomplete when viewed because the downloaded tiles at lower zoom levels will not cover the map view area.
* @param {number} [opts.minzoom=0] Minimum zoom level to download (for most cases this should be left as `0` - the size overhead is minimal, because each zoom level has 4x as many tiles)
* @param {number} [opts.concurrency=8] Number of concurrent downloads (ignored if `fetchQueue` is provided)
Expand Down Expand Up @@ -155,8 +155,8 @@ export function downloadTiles({
/**
*
* @param {object} opts
* @param {import('./utils/geo.js').BBox} [opts.bounds]
* @param {import('./utils/geo.js').BBox} [opts.sourceBounds]
* @param {Readonly<import('./utils/geo.js').BBox>} [opts.bounds]
* @param {Readonly<import('./utils/geo.js').BBox>} [opts.sourceBounds]
* @param {boolean} [opts.boundsBuffer]
* @param {number} [opts.minzoom]
* @param {number} opts.maxzoom
Expand Down
2 changes: 1 addition & 1 deletion packages/api/lib/utils/geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export function getTileUrl(urls, { x, y, z, scheme = 'xyz' }) {
/**
* Returns a bbox that is the smallest bounding box that contains all the input bboxes.
*
* @param {[BBox, ...BBox[]]} bboxes
* @param {[Readonly<BBox>, ...Readonly<BBox>[]]} bboxes
* @returns {BBox} Bounding Box [w, s, e, n]
*/
export function unionBBox(bboxes) {
Expand Down
220 changes: 220 additions & 0 deletions packages/api/test/download.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
import { ZipReader } from '@gmaclennan/zip-reader'
import { BufferSource } from '@gmaclennan/zip-reader/buffer-source'
import { afterAll, assert, beforeAll, describe, expect, test } from 'vitest'

import { fileURLToPath } from 'node:url'

import { download } from '../lib/download.js'
import { Reader } from '../lib/reader.js'
import { startSMPServer } from './utils/smp-server.js'
import { streamToBuffer } from './utils/stream-consumers.js'

describe('download with demotiles-z2 (glyphs, no sprites)', () => {
/** @type {{ baseUrl: string, close: () => Promise<void> }} */
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('download produces a valid SMP file', async () => {
const smpStream = download({
styleUrl: server.baseUrl + 'style.json',
bbox: [-180, -85, 180, 85],
maxzoom: 1,
})

const smp = await streamToBuffer(smpStream)
assert(smp.length > 0, 'output is non-empty')

const reader = new Reader(await ZipReader.from(new BufferSource(smp)))
const style = await reader.getStyle()

assert.equal(style.version, 8)
assert(Array.isArray(style.layers), 'has layers')
assert(style.metadata, 'has metadata')
assert(style.metadata['smp:bounds'], 'has smp:bounds')
assert(typeof style.metadata['smp:maxzoom'] === 'number', 'has smp:maxzoom')
assert(Object.keys(style.sources).length > 0, 'has sources')

await reader.close()
})

test('download output contains readable tiles', async () => {
const smpStream = download({
styleUrl: server.baseUrl + 'style.json',
bbox: [-180, -85, 180, 85],
maxzoom: 1,
})

const smp = await streamToBuffer(smpStream)
const reader = new Reader(await ZipReader.from(new BufferSource(smp)))
const style = await reader.getStyle()

// Find the vector source and its tile path pattern
const vectorSource = /** @type {any} */ (
Object.values(style.sources).find(
(/** @type {any} */ s) => s.type === 'vector',
)
)
assert(vectorSource, 'has vector source')
assert(vectorSource.tiles, 'vector source has tiles')

// Read a z0 tile via the SMP URI pattern
const tilePath = vectorSource.tiles[0]
.replace('smp://maps.v1/', '')
.replace('{z}', '0')
.replace('{x}', '0')
.replace('{y}', '0')
const resource = await reader.getResource(tilePath)
assert(resource.contentLength > 0, 'tile has content')
await streamToBuffer(resource.stream)

await reader.close()
})

test('download output contains readable glyphs', async () => {
const smpStream = download({
styleUrl: server.baseUrl + 'style.json',
bbox: [-180, -85, 180, 85],
maxzoom: 0,
})

const smp = await streamToBuffer(smpStream)
const reader = new Reader(await ZipReader.from(new BufferSource(smp)))
const style = await reader.getStyle()

assert(typeof style.glyphs === 'string', 'has glyphs URI')

// Read a glyph resource
const resource = await reader.getResource(
'fonts/Open Sans Semibold/0-255.pbf.gz',
)
assert(resource.contentLength > 0, 'glyph has content')
await streamToBuffer(resource.stream)

await reader.close()
})

test('download calls onprogress with expected fields', async () => {
/** @type {import('../lib/download.js').DownloadProgress[]} */
const progressUpdates = []
const smpStream = download({
styleUrl: server.baseUrl + 'style.json',
bbox: [-180, -85, 180, 85],
maxzoom: 0,
onprogress: (p) => progressUpdates.push(structuredClone(p)),
})

await streamToBuffer(smpStream)

assert(progressUpdates.length > 0, 'onprogress was called')

const last = progressUpdates[progressUpdates.length - 1]
assert.equal(last.style.done, true, 'style done')
assert.equal(last.tiles.done, true, 'tiles done')
assert.equal(last.glyphs.done, true, 'glyphs done')
assert.equal(last.sprites.done, true, 'sprites done')
assert.equal(last.output.done, true, 'output done')
assert(last.output.totalBytes > 0, 'output has bytes')
assert(last.elapsedMs > 0, 'elapsedMs > 0')
})

test(
'download stream emits error for unreachable URL',
{ timeout: 15_000 },
async () => {
const smpStream = download({
styleUrl: 'http://127.0.0.1:1/nonexistent/style.json',
bbox: [-1, -1, 1, 1],
maxzoom: 0,
})

await expect(() => streamToBuffer(smpStream)).rejects.toThrow()
},
)
})

describe('download with osm-bright-z6 (sprites)', () => {
/** @type {{ baseUrl: string, close: () => Promise<void> }} */
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('download with sprites produces valid SMP with sprite resources', async () => {
// Use a tight bbox and low maxzoom to keep this fast
const smpStream = download({
styleUrl: server.baseUrl + 'style.json',
bbox: [10, 47, 11, 48],
maxzoom: 0,
})

const smp = await streamToBuffer(smpStream)
const reader = new Reader(await ZipReader.from(new BufferSource(smp)))
const style = await reader.getStyle()

// Verify sprites are present in the output
assert(style.sprite, 'output style has sprite')

// Read sprite resources
const spriteJsonResource = await reader.getResource(
'sprites/default/sprite.json',
)
assert(spriteJsonResource.contentLength > 0, 'sprite json has content')
await streamToBuffer(spriteJsonResource.stream)

const spritePngResource = await reader.getResource(
'sprites/default/sprite.png',
)
assert(spritePngResource.contentLength > 0, 'sprite png has content')
await streamToBuffer(spritePngResource.stream)

// Verify @2x sprites too
const sprite2xJsonResource = await reader.getResource(
'sprites/default/sprite@2x.json',
)
assert(
sprite2xJsonResource.contentLength > 0,
'sprite @2x json has content',
)
await streamToBuffer(sprite2xJsonResource.stream)

await reader.close()
})

test('download with sprites tracks sprite progress', async () => {
/** @type {import('../lib/download.js').DownloadProgress[]} */
const progressUpdates = []
const smpStream = download({
styleUrl: server.baseUrl + 'style.json',
bbox: [10, 47, 11, 48],
maxzoom: 0,
onprogress: (p) => progressUpdates.push(structuredClone(p)),
})

await streamToBuffer(smpStream)

const last = progressUpdates[progressUpdates.length - 1]
assert.equal(last.sprites.done, true, 'sprites done')
assert(
last.sprites.downloaded > 0,
`sprites downloaded: ${last.sprites.downloaded}`,
)
})
})
Loading