diff --git a/CHANGELOG.md b/CHANGELOG.md index 32973ddd339..211005ec4a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Add Etag unmodified support to optimize vector tile reloading ([#7074](https://github.com/maplibre/maplibre-gl-js/pull/7074)) (by [@rivkamatan](https://github.com/rivkamatan and [@wayofthefuture](https://github.com/wayofthefuture)) - Add `boxZoom.boxZoomEnd` option to customize the action after Shift-drag box selection ([#6397](https://github.com/maplibre/maplibre-gl-js/issues/6397)) - Add updateable support for GeoJSON-VT ([#7172](https://github.com/maplibre/maplibre-gl-js/issues/7172)) (by [@wayofthefuture](https://github.com/wayofthefuture) and [HarelM](https://github.com/HarelM)) +- Make `setTransformRequest` accept an async function in addition to a sync function. ([#7184](https://github.com/maplibre/maplibre-gl-js/issues/7184)) (by [@kikuomax +](https://github.com/kikuomax)) - _...Add new stuff here..._ ### 🐞 Bug fixes diff --git a/src/source/geojson_source.test.ts b/src/source/geojson_source.test.ts index 92f43f2a46e..fb6d62af5d4 100644 --- a/src/source/geojson_source.test.ts +++ b/src/source/geojson_source.test.ts @@ -140,7 +140,7 @@ describe('GeoJSONSource.setData', () => { await expect(promise).resolves.toBeDefined(); }); - test('respects collectResourceTiming parameter on source', () => { + test('respects collectResourceTiming parameter on source', async () => { const source = createSource({collectResourceTiming: true}); source.map = { _requestManager: { @@ -149,16 +149,40 @@ describe('GeoJSONSource.setData', () => { } as any; const spy = vi.fn(); source.actor.sendAsync = (message: ActorMessage) => { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { if (message.type === MessageType.loadData) { - expect((message.data as any).request.collectResourceTiming).toBeTruthy(); setTimeout(() => resolve({} as any), 0); - spy(); + spy(message); + } else { + reject(new Error(`MessageType.loadData is expected but got ${message.type}`)); } }); }; source.setData('http://localhost/nonexistent'); - expect(spy).toHaveBeenCalled(); + await sleep(0); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].data.request.collectResourceTiming).toBeTruthy(); + }); + + test('respects collectResourceTiming parameter on source (async transformRequest)', async () => { + const source = createSource({collectResourceTiming: true}); + source.map = { + _requestManager: { + transformRequest: async (url) => { return {url}; } + } as any as RequestManager + } as any; + const spy = vi.fn(); + source.actor.sendAsync = (message: ActorMessage) => { + return new Promise((resolve) => { + if (message.type === MessageType.loadData) { + spy(message); + resolve({} as any); + } + }); + }; + await source.setData('http://localhost/nonexistent', true); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].data.request.collectResourceTiming).toBeTruthy(); }); test('only marks source as loaded when there are no pending loads', async () => { @@ -221,14 +245,14 @@ describe('GeoJSONSource.onRemove', () => { const spy = vi.fn(); const source = new GeoJSONSource('id', {data: {}} as GeoJSONSourceOptions, wrapDispatcher({ sendAsync(message: ActorMessage) { - expect(message.type).toBe(MessageType.removeSource); - expect(message.data).toEqual({type: 'geojson', source: 'id'}); - spy(); + spy(message); return Promise.resolve({}); } }), undefined); source.onRemove(); - expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].type).toBe(MessageType.removeSource); + expect(spy.mock.calls[0][0].data).toEqual({type: 'geojson', source: 'id'}); }); }); @@ -240,34 +264,26 @@ describe('GeoJSONSource.update', () => { transform.setZoom(15); transform.setLocationAtPoint(lngLat, point); - test('sends initial loadData request to dispatcher', () => { + test('sends initial loadData request to dispatcher', async () => { const spy = vi.fn(); const mockDispatcher = wrapDispatcher({ sendAsync(message: ActorMessage) { - expect(message.type).toBe(MessageType.loadData); - spy(); + spy(message); return Promise.resolve({}); } }); new GeoJSONSource('id', {data: {}} as GeoJSONSourceOptions, mockDispatcher, undefined).load(); - expect(spy).toHaveBeenCalled(); + await sleep(0); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].type).toBe(MessageType.loadData); }); - test('forwards geojson-vt options with worker request', () => { + test('forwards geojson-vt options with worker request', async () => { const spy = vi.fn(); const mockDispatcher = wrapDispatcher({ sendAsync(message: ActorMessage) { - expect(message.type).toBe(MessageType.loadData); - expect(message.data.geojsonVtOptions).toEqual({ - extent: EXTENT, - maxZoom: 10, - tolerance: 4, - buffer: 256, - lineMetrics: false, - generateId: true - }); - spy(); + spy(message); return Promise.resolve({}); } }); @@ -279,10 +295,20 @@ describe('GeoJSONSource.update', () => { buffer: 16, generateId: true } as GeoJSONSourceOptions, mockDispatcher, undefined).load(); - expect(spy).toHaveBeenCalled(); + await sleep(0); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].type).toBe(MessageType.loadData); + expect(spy.mock.calls[0][0].data.geojsonVtOptions).toEqual({ + extent: EXTENT, + maxZoom: 10, + tolerance: 4, + buffer: 256, + lineMetrics: false, + generateId: true + }); }); - test('forwards Supercluster options with worker request', () => { + test('forwards Supercluster options with worker request', async () => { const spy = vi.fn(); const mockDispatcher = wrapDispatcher({ sendAsync(message) { @@ -300,7 +326,8 @@ describe('GeoJSONSource.update', () => { generateId: true } as GeoJSONSourceOptions, mockDispatcher, undefined); source.load(); - expect(spy).toHaveBeenCalled(); + await sleep(0); + expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0].type).toBe(MessageType.loadData); expect(spy.mock.calls[0][0].data.superclusterOptions).toEqual({ maxZoom: 12, @@ -338,6 +365,7 @@ describe('GeoJSONSource.update', () => { spy.mockClear(); source.setClusterOptions({cluster: true, clusterRadius: 80, clusterMaxZoom: 16}); + await sleep(0); expect(spy).toHaveBeenCalledTimes(1); expect(spy.mock.calls[0][0].type).toBe(MessageType.loadData); @@ -379,9 +407,9 @@ describe('GeoJSONSource.update', () => { source.setData(sourceData2); source.setClusterOptions({cluster: true, clusterRadius: 80, clusterMaxZoom: 16}); - await waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); - expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledTimes(3); expect(spy.mock.calls[0][0].type).toBe(MessageType.loadData); expect(spy.mock.calls[0][0].data.cluster).toBe(false); expect(spy.mock.calls[0][0].data.data).toBe(sourceData1); @@ -391,6 +419,11 @@ describe('GeoJSONSource.update', () => { expect(spy.mock.calls[1][0].data.superclusterOptions.maxZoom).toBe(16); expect(spy.mock.calls[1][0].data.data).toBe(sourceData2); expect(spy.mock.calls[1][0].data.dataDiff).toBeUndefined(); + expect(spy.mock.calls[2][0].data.cluster).toBe(true); + expect(spy.mock.calls[2][0].data.superclusterOptions.radius).toBe(80 * EXTENT / source.tileSize); + expect(spy.mock.calls[2][0].data.superclusterOptions.maxZoom).toBe(16); + expect(spy.mock.calls[2][0].data.data).toBeUndefined(); + expect(spy.mock.calls[2][0].data.dataDiff).toBeUndefined(); }); test('modifying cluster properties after sending a diff', async () => { @@ -436,7 +469,7 @@ describe('GeoJSONSource.update', () => { source.updateData(diff); source.setClusterOptions({cluster: true, clusterRadius: 80, clusterMaxZoom: 16}); - await waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); expect(spy).toHaveBeenCalledTimes(2); expect(spy.mock.calls[0][0].data.cluster).toBe(false); @@ -446,7 +479,7 @@ describe('GeoJSONSource.update', () => { expect(spy.mock.calls[1][0].data.dataDiff).not.toBeDefined(); }); - test('forwards Supercluster options with worker request, ignore max zoom of source', () => { + test('forwards Supercluster options with worker request, ignore max zoom of source', async () => { const spy = vi.fn(); vi.spyOn(console, 'warn').mockImplementation(() => {}); const mockDispatcher = wrapDispatcher({ @@ -466,6 +499,7 @@ describe('GeoJSONSource.update', () => { generateId: true } as GeoJSONSourceOptions, mockDispatcher, undefined); source.load(); + await sleep(0); expect(spy).toHaveBeenCalled(); expect(spy.mock.calls[0][0].type).toBe(MessageType.loadData); expect(spy.mock.calls[0][0].data.superclusterOptions).toEqual({ @@ -504,6 +538,30 @@ describe('GeoJSONSource.update', () => { expect(source.serialize()).toStrictEqual({type: 'geojson', data: hawkHill}); }); + test('can asynchronously transform request', async () => { + const spy = vi.fn(); + const source = new GeoJSONSource('id', {data: 'https://example.com/data.geojson'} as GeoJSONSourceOptions, wrapDispatcher({ + sendAsync(message) { + spy(message); + return Promise.resolve({}); + } + }), undefined); + source.map = { + _requestManager: { + transformRequest: async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + }) + } + } as any; + await source.load(); + expect(spy).toHaveBeenCalled(); + expect(spy.mock.calls[0][0].data.request).toEqual({ + url: 'https://example.com/data.geojson', + headers: {Authorization: 'Bearer token'} + }); + }); + test('fires event when metadata loads', async () => { const mockDispatcher = wrapDispatcher({ sendAsync(_message: ActorMessage) { diff --git a/src/source/geojson_source.ts b/src/source/geojson_source.ts index 6964306a73c..ca2cc40a7e2 100644 --- a/src/source/geojson_source.ts +++ b/src/source/geojson_source.ts @@ -402,6 +402,7 @@ export class GeoJSONSource extends Evented implements Source { } const {data, diff, updateCluster} = this._pendingWorkerUpdate; + // delay awaiting params until _isUpdatingWorker is set, otherwise, a race condition could happen const params = this._getLoadGeoJSONParameters(data, diff, updateCluster); if (data !== undefined) { @@ -418,12 +419,12 @@ export class GeoJSONSource extends Evented implements Source { /** * Create the parameters object that will be sent to the worker and used to load GeoJSON. */ - private _getLoadGeoJSONParameters(data: string | GeoJSON.GeoJSON, diff: GeoJSONSourceDiff, updateCluster: boolean): LoadGeoJSONParameters | undefined { + private async _getLoadGeoJSONParameters(data: string | GeoJSON.GeoJSON, diff: GeoJSONSourceDiff, updateCluster: boolean): Promise { const params: LoadGeoJSONParameters = extend({type: this.type}, this.workerOptions); // Data comes from a remote url if (typeof data === 'string') { - params.request = this.map._requestManager.transformRequest(browser.resolveURL(data as string), ResourceType.Source); + params.request = await this.map._requestManager.transformRequest(browser.resolveURL(data as string), ResourceType.Source); params.request.collectResourceTiming = this._collectResourceTiming; return params; } @@ -450,11 +451,12 @@ export class GeoJSONSource extends Evented implements Source { /** * Send the worker update data from the main thread to the worker */ - private async _dispatchWorkerUpdate(options: LoadGeoJSONParameters) { + private async _dispatchWorkerUpdate(optionsPromise: Promise) { this._isUpdatingWorker = true; this.fire(new Event('dataloading', {dataType: 'source'})); try { + const options = await optionsPromise; const result = await this.actor.sendAsync({type: MessageType.loadData, data: options}); this._isUpdatingWorker = false; diff --git a/src/source/image_source.test.ts b/src/source/image_source.test.ts index 6ba29915b69..1280c570065 100644 --- a/src/source/image_source.test.ts +++ b/src/source/image_source.test.ts @@ -67,6 +67,7 @@ describe('ImageSource', () => { expect(e.dataType).toBe('source'); }); source.onAdd(new StubMap() as any); + await sleep(0); server.respond(); await sleep(0); expect(source.image).toBeTruthy(); @@ -83,6 +84,24 @@ describe('ImageSource', () => { expect(spy.mock.calls[0][1]).toBe('Image'); }); + test('can asynchronously transform request', async () => { + const source = createSource({url: '/image.png'}); + const map = new StubMap() as any; + map._requestManager = { + transformRequest: async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + }) + }; + const promise = source.once('data'); + source.onAdd(map); + await sleep(0); + server.respond(); + await promise; + expect(server.requests[0].url).toBe('/image.png'); + expect(server.requests[0].requestHeaders['Authorization']).toBe('Bearer token'); + }); + test('updates url from updateImage', () => { const source = createSource({url: '/image.png'}); const map = new StubMap() as any; @@ -122,6 +141,7 @@ describe('ImageSource', () => { url: '/image2.png', coordinates: [[0, 0], [-1, 0], [-1, -1], [0, -1]] }); + await sleep(0); server.respond(); await sleep(0); const afterSerialized = source.serialize(); @@ -132,6 +152,7 @@ describe('ImageSource', () => { const source = createSource({url: '/image.png'}); const promise = waitForEvent(source, 'data', (e) => e.dataType === 'source' && e.sourceDataType === 'content'); source.onAdd(new StubMap() as any); + await sleep(0); server.respond(); await promise; expect(typeof source.tileID == 'object').toBeTruthy(); @@ -141,6 +162,7 @@ describe('ImageSource', () => { const source = createSource({url: '/image.png'}); const promise = waitForEvent(source, 'data', (e) => e.dataType === 'source' && e.sourceDataType === 'metadata'); source.onAdd(new StubMap() as any); + await sleep(0); server.respond(); await expect(promise).resolves.toBeDefined(); }); @@ -179,19 +201,21 @@ describe('ImageSource', () => { source.onAdd(map); expect(source.image).toBeUndefined(); source.updateImage({url: '/image2.png'}); + await sleep(0); server.respond(); await sleep(10); expect(source.image).toBeTruthy(); }); - test('cancels request if updateImage is used', () => { + test('cancels request if updateImage is used', async () => { const map = new StubMap() as any; const source = createSource({url: '/image.png', eventedParent: map}); // Suppress errors because we're aborting. map.on('error', () => {}); source.onAdd(map); + await sleep(0); const spy = vi.spyOn(server.requests[0] as any, 'abort'); @@ -205,6 +229,7 @@ describe('ImageSource', () => { expect(source.loaded()).toBe(false); source.onAdd(map); + await sleep(0); server.respond(); await sleep(0); expect(source.loaded()).toBe(true); @@ -216,6 +241,7 @@ describe('ImageSource', () => { expect(missingImagesource.loaded()).toBe(false); missingImagesource.onAdd(map); + await sleep(0); server.respond(); await sleep(0); diff --git a/src/source/image_source.ts b/src/source/image_source.ts index e7f55dbb59b..0f37025d38a 100644 --- a/src/source/image_source.ts +++ b/src/source/image_source.ts @@ -151,7 +151,7 @@ export class ImageSource extends Evented implements Source { this._request = new AbortController(); try { - const image = await ImageRequest.getImage(this.map._requestManager.transformRequest(this.url, ResourceType.Image), this._request); + const image = await ImageRequest.getImage(await this.map._requestManager.transformRequest(this.url, ResourceType.Image), this._request); this._request = null; this._loaded = true; diff --git a/src/source/load_tilejson.test.ts b/src/source/load_tilejson.test.ts index d9cd584124d..04ba139f6a0 100644 --- a/src/source/load_tilejson.test.ts +++ b/src/source/load_tilejson.test.ts @@ -3,6 +3,7 @@ import {fakeServer, type FakeServer} from 'nise'; import {loadTileJson} from './load_tilejson'; import {RequestManager} from '../util/request_manager'; import {ABORT_ERROR} from '../util/abort_error'; +import {sleep} from '../util/test/util'; import {type RasterSourceSpecification} from '@maplibre/maplibre-gl-style-spec'; @@ -39,12 +40,47 @@ describe('loadTileJson', () => { }); const promise = loadTileJson(options, requestManager, new AbortController()); + await sleep(0); server.respond(); const result = await promise; expect(result).toEqual(mockTileJSON); }); + test('fetches and returns TileJSON (async transformRequest)', async () => { + const options = { + type: 'raster', + url: 'http://example.com/test.json', + } satisfies RasterSourceSpecification; + + const mockTileJSON = { + tiles: ['http://example.com/tile/{z}/{x}/{y}.png'], + minzoom: 0, + maxzoom: 14, + attribution: 'Test Attribution', + bounds: [-180, -85, 180, 85], + scheme: 'xyz', + tileSize: 256, + }; + + server.respondWith(request => { + request.respond(200, {'Content-Type': 'application/json'}, JSON.stringify(mockTileJSON)); + }); + + const requestManager = new RequestManager(async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + const promise = loadTileJson(options, requestManager, new AbortController()); + await sleep(0); + server.respond(); + const result = await promise; + + expect(result).toEqual(mockTileJSON); + expect(server.requests[0].url).toBe('http://example.com/test.json'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); + }); + test('combines input and TileJSON', async () => { const options = { type: 'raster', @@ -68,6 +104,7 @@ describe('loadTileJson', () => { }); const promise = loadTileJson(options, requestManager, new AbortController()); + await sleep(0); server.respond(); const result = await promise; @@ -101,6 +138,7 @@ describe('loadTileJson', () => { }); const promise = loadTileJson(options, requestManager, new AbortController()); + await sleep(0); server.respond(); const result: any = await promise; @@ -130,6 +168,7 @@ describe('loadTileJson', () => { }); const promise = loadTileJson(options, requestManager, new AbortController()); + await sleep(0); server.respond(); const result = await promise; @@ -158,6 +197,7 @@ describe('loadTileJson', () => { const abortController = new AbortController(); const promise = loadTileJson(options, requestManager, abortController); + await sleep(0); abortController.abort(); server.respond(); @@ -175,6 +215,7 @@ describe('loadTileJson', () => { }); const promise = loadTileJson(options, requestManager, new AbortController()); + await sleep(0); server.respond(); await expect(promise).rejects.toThrow('AJAXError: Not Found (404): http://example.com/test.json'); diff --git a/src/source/load_tilejson.ts b/src/source/load_tilejson.ts index 3639627db5b..1bb61db1863 100644 --- a/src/source/load_tilejson.ts +++ b/src/source/load_tilejson.ts @@ -26,7 +26,7 @@ export async function loadTileJson( ): Promise { let tileJSON: TileJSON | typeof options = options; if (options.url) { - const response = await getJSON(requestManager.transformRequest(options.url, ResourceType.Source), abortController); + const response = await getJSON(await requestManager.transformRequest(options.url, ResourceType.Source), abortController); tileJSON = response.data; } else { await browser.frameAsync(abortController, targetWindow); diff --git a/src/source/raster_dem_tile_source.test.ts b/src/source/raster_dem_tile_source.test.ts index 200fa5aff50..9f05ec43284 100644 --- a/src/source/raster_dem_tile_source.test.ts +++ b/src/source/raster_dem_tile_source.test.ts @@ -4,7 +4,7 @@ import {RasterDEMTileSource} from './raster_dem_tile_source'; import {OverscaledTileID} from '../tile/tile_id'; import {RequestManager} from '../util/request_manager'; import {type Tile} from '../tile/tile'; -import {waitForEvent, waitForMetadataEvent} from '../util/test/util'; +import {sleep, waitForEvent, waitForMetadataEvent} from '../util/test/util'; import type {MapSourceDataEvent} from '../ui/events'; function createSource(options, transformCallback?) { @@ -53,6 +53,26 @@ describe('RasterDEMTileSource', () => { expect(transformSpy.mock.calls[0][1]).toBe('Source'); }); + test('can asynchronously transform request for TileJSON URL', async () => { + server.respondWith('/source.json', JSON.stringify({ + minzoom: 0, + maxzoom: 22, + attribution: 'MapLibre', + tiles: ['http://example.com/{z}/{x}/{y}.pngraw'], + bounds: [-47, -7, -45, -5] + })); + const source = createSource({url: '/source.json'}, async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + const promise = waitForMetadataEvent(source); + await sleep(0); + server.respond(); + await promise; + expect(server.requests[0].url).toBe('/source.json'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); + }); + test('transforms tile urls before requesting', async () => { server.respondWith('/source.json', JSON.stringify({ minzoom: 0, @@ -64,6 +84,7 @@ describe('RasterDEMTileSource', () => { const source = createSource({url: '/source.json'}); const transformSpy = vi.spyOn(source.map._requestManager, 'transformRequest'); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; const tile = { @@ -79,6 +100,34 @@ describe('RasterDEMTileSource', () => { expect(transformSpy.mock.calls[0][1]).toBe('Tile'); }); + test('can asynchronously transform tile request', async () => { + server.respondWith('http://example.com/10/5/5.png', + [200, {'Content-Type': 'image/png', 'Content-Length': 1, 'Cache-Control': 'max-age=100'}, '0'] + ); + + const source = createSource({ + tiles: ['http://example.com/{z}/{x}/{y}.png'] + }, async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + source.map.painter = {context: {}, getTileTexture: () => { return {update: () => {}}; }} as any; + await waitForMetadataEvent(source); + + const tile = { + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'loading', + setExpiryData() {}, + actor: 1 + } as any as Tile; + const promise = source.loadTile(tile); + await sleep(0); + server.respond(); + await promise; + expect(server.requests[0].url).toBe('http://example.com/10/5/5.png'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); + }); + test('populates neighboringTiles', async () => { server.respondWith('/source.json', JSON.stringify({ minzoom: 0, @@ -88,6 +137,7 @@ describe('RasterDEMTileSource', () => { })); const source = createSource({url: '/source.json'}); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; const tile = { @@ -97,6 +147,7 @@ describe('RasterDEMTileSource', () => { setExpiryData() {} } as any as Tile; source.loadTile(tile); + await sleep(0); expect(Object.keys(tile.neighboringTiles)).toEqual([ new OverscaledTileID(10, 0, 10, 4, 5).key, @@ -121,6 +172,7 @@ describe('RasterDEMTileSource', () => { const source = createSource({url: '/source.json'}); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; @@ -131,6 +183,7 @@ describe('RasterDEMTileSource', () => { setExpiryData() {} } as any as Tile; source.loadTile(tile); + await sleep(0); expect(Object.keys(tile.neighboringTiles)).toEqual([ new OverscaledTileID(5, 0, 5, 30, 6).key, @@ -174,6 +227,7 @@ describe('RasterDEMTileSource', () => { source.map._refreshExpiredTiles = true; const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); server.respond(); await promise; const tile = { @@ -184,6 +238,7 @@ describe('RasterDEMTileSource', () => { } as any as Tile; const expiryDataSpy = vi.spyOn(tile, 'setExpiryData'); const tilePromise = source.loadTile(tile); + await sleep(0); server.respond(); await tilePromise; expect(expiryDataSpy).toHaveBeenCalledTimes(1); @@ -205,6 +260,7 @@ describe('RasterDEMTileSource', () => { source.map._refreshExpiredTiles = true; const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); server.respond(); await promise; const tile = { @@ -215,6 +271,7 @@ describe('RasterDEMTileSource', () => { } as any as Tile; const expiryDataSpy = vi.spyOn(tile, 'setExpiryData'); const tilePromise = source.loadTile(tile); + await sleep(0); server.respond(); await tilePromise; expect(expiryDataSpy).toHaveBeenCalledTimes(1); @@ -236,6 +293,7 @@ describe('RasterDEMTileSource', () => { source.map._refreshExpiredTiles = true; const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); server.respond(); await promise; const tile = { @@ -246,6 +304,7 @@ describe('RasterDEMTileSource', () => { } as any as Tile; const expiryDataSpy = vi.spyOn(tile, 'setExpiryData'); const tilePromise = source.loadTile(tile); + await sleep(0); server.respond(); await tilePromise; expect(expiryDataSpy).toHaveBeenCalledTimes(1); @@ -262,6 +321,7 @@ describe('RasterDEMTileSource', () => { const source = createSource({url: '/source.json'}); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; @@ -272,6 +332,7 @@ describe('RasterDEMTileSource', () => { setExpiryData() {} } as any as Tile; const loadPromise = source.loadTile(tile); + await sleep(0); tile.abortController.abort(); tile.aborted = true; diff --git a/src/source/raster_dem_tile_source.ts b/src/source/raster_dem_tile_source.ts index 42d82e64d1e..106555f7930 100644 --- a/src/source/raster_dem_tile_source.ts +++ b/src/source/raster_dem_tile_source.ts @@ -55,7 +55,7 @@ export class RasterDEMTileSource extends RasterTileSource implements Source { override async loadTile(tile: Tile): Promise { const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme); - const request = this.map._requestManager.transformRequest(url, ResourceType.Tile); + const request = await this.map._requestManager.transformRequest(url, ResourceType.Tile); tile.neighboringTiles = this._getNeighboringTiles(tile.tileID); tile.abortController = new AbortController(); try { diff --git a/src/source/raster_tile_source.test.ts b/src/source/raster_tile_source.test.ts index e1a9d51054c..0f86ee9f860 100644 --- a/src/source/raster_tile_source.test.ts +++ b/src/source/raster_tile_source.test.ts @@ -5,7 +5,7 @@ import {RequestManager} from '../util/request_manager'; import {type Dispatcher} from '../util/dispatcher'; import {fakeServer, type FakeServer} from 'nise'; import {type Tile} from '../tile/tile'; -import {stubAjaxGetImage, waitForEvent} from '../util/test/util'; +import {sleep, stubAjaxGetImage, waitForEvent} from '../util/test/util'; import {type MapSourceDataEvent} from '../ui/events'; function createSource(options, transformCallback?) { @@ -52,11 +52,32 @@ describe('RasterTileSource', () => { expect(transformSpy.mock.calls[0][1]).toBe('Source'); }); + test('can asynchronously transform request for TileJSON URL', async () => { + server.respondWith('/source.json', JSON.stringify({ + minzoom: 0, + maxzoom: 22, + attribution: 'MapLibre', + tiles: ['http://example.com/{z}/{x}/{y}.png'], + bounds: [-47, -7, -45, -5] + })); + const source = createSource({url: '/source.json'}, async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); + server.respond(); + await promise; + expect(server.requests[0].url).toBe('/source.json'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); + }); + test('fires "error" event if TileJSON request fails', async () => { server.respondWith('/source.json', [404, {}, '']); const source = createSource({url: '/source.json'}); const errorEvent = waitForEvent(source, 'error', (e) => e.error.status === 404); + await sleep(0); server.respond(); await expect(errorEvent).resolves.toBeDefined(); @@ -103,6 +124,7 @@ describe('RasterTileSource', () => { const source = createSource({url: '/source.json'}); const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); server.respond(); await promise; @@ -121,6 +143,7 @@ describe('RasterTileSource', () => { const source = createSource({url: '/source.json'}); const transformSpy = vi.spyOn(source.map._requestManager, 'transformRequest'); const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); server.respond(); await promise; const tile = { @@ -135,6 +158,35 @@ describe('RasterTileSource', () => { expect(transformSpy.mock.calls[0][1]).toBe('Tile'); }); + test('can asynchronously transform tile request', async () => { + server.respondWith('http://example.com/10/5/5.png', + [200, {'Content-Type': 'image/png', 'Content-Length': 1, 'Cache-Control': 'max-age=100'}, '0'] + ); + + const source = createSource({ + tiles: ['http://example.com/{z}/{x}/{y}.png'] + }, async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + source.map.painter = {context: {}, getTileTexture: () => { return {update: () => {}}; }} as any; + await waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + + const tile = { + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'loading', + loadVectorData () {}, + setExpiryData() {} + } as any as Tile; + const promise = source.loadTile(tile); + await sleep(0); + server.respond(); + await promise; + expect(server.requests[0].url).toBe('http://example.com/10/5/5.png'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); + expect(tile.state).toBe('loaded'); + }); + test('HttpImageElement used to get image when refreshExpiredTiles is false', async () => { stubAjaxGetImage(undefined); server.respondWith('/source.json', JSON.stringify({ @@ -150,6 +202,7 @@ describe('RasterTileSource', () => { const imageConstructorSpy = vi.spyOn(global, 'Image'); const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); server.respond(); await promise; const tile = { @@ -172,8 +225,9 @@ describe('RasterTileSource', () => { }); }); - test('cancels TileJSON request if removed', () => { + test('cancels TileJSON request if removed', async () => { const source = createSource({url: '/source.json'}); + await sleep(0); source.onRemove(); expect((server.lastRequest as any).aborted).toBe(true); }); @@ -190,10 +244,12 @@ describe('RasterTileSource', () => { const source = createSource({ url: 'http://localhost:2900/source.json' }); + await sleep(0); const errorHandler = vi.fn(); source.on('error', errorHandler); source.setUrl('http://localhost:2900/source2.json'); + await sleep(0); server.respond(); await waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); @@ -237,6 +293,7 @@ describe('RasterTileSource', () => { source.map._refreshExpiredTiles = true; const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); server.respond(); await promise; const tile = { @@ -246,6 +303,7 @@ describe('RasterTileSource', () => { } as any as Tile; const expiryDataSpy = vi.spyOn(tile, 'setExpiryData'); const tilePromise = source.loadTile(tile); + await sleep(0); server.respond(); await tilePromise; expect(tile.state).toBe('loaded'); @@ -268,6 +326,7 @@ describe('RasterTileSource', () => { source.map._refreshExpiredTiles = true; const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); server.respond(); await promise; const tile = { @@ -277,6 +336,7 @@ describe('RasterTileSource', () => { } as any as Tile; const expiryDataSpy = vi.spyOn(tile, 'setExpiryData'); const tilePromise = source.loadTile(tile); + await sleep(0); server.respond(); await tilePromise; expect(tile.state).toBe('loaded'); @@ -299,6 +359,7 @@ describe('RasterTileSource', () => { source.map._refreshExpiredTiles = true; const promise = waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); + await sleep(0); server.respond(); await promise; const tile = { @@ -308,6 +369,7 @@ describe('RasterTileSource', () => { } as any as Tile; const expiryDataSpy = vi.spyOn(tile, 'setExpiryData'); const tilePromise = source.loadTile(tile); + await sleep(0); server.respond(); await tilePromise; expect(tile.state).toBe('loaded'); @@ -332,6 +394,7 @@ describe('RasterTileSource', () => { setExpiryData() {} } as any as Tile; const loadPromise = source.loadTile(tile); + await sleep(0); tile.abortController.abort(); tile.aborted = true; diff --git a/src/source/raster_tile_source.ts b/src/source/raster_tile_source.ts index b9e7397cbc2..26ae784d9b9 100644 --- a/src/source/raster_tile_source.ts +++ b/src/source/raster_tile_source.ts @@ -183,7 +183,7 @@ export class RasterTileSource extends Evented implements Source { const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme); tile.abortController = new AbortController(); try { - const response = await ImageRequest.getImage(this.map._requestManager.transformRequest(url, ResourceType.Tile), tile.abortController, this.map._refreshExpiredTiles); + const response = await ImageRequest.getImage(await this.map._requestManager.transformRequest(url, ResourceType.Tile), tile.abortController, this.map._refreshExpiredTiles); delete tile.abortController; if (tile.aborted) { tile.state = 'unloaded'; diff --git a/src/source/vector_tile_source.test.ts b/src/source/vector_tile_source.test.ts index 3c7aa38727f..8701997d4aa 100644 --- a/src/source/vector_tile_source.test.ts +++ b/src/source/vector_tile_source.test.ts @@ -69,6 +69,7 @@ describe('VectorTileSource', () => { const source = createSource({url: '/source.json'}); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; @@ -89,10 +90,24 @@ describe('VectorTileSource', () => { expect(transformSpy).toHaveBeenCalledWith('/source.json', 'Source'); }); + test('can asynchronously transform the request for TileJSON URL', async () => { + server.respondWith('/source.json', JSON.stringify(fixturesSource)); + const source = createSource({url: '/source.json'}, async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + await sleep(0); + server.respond(); + await waitForMetadataEvent(source); + expect(server.requests[0].url).toBe('/source.json'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); + }); + test('fires event with metadata property', async () => { server.respondWith('/source.json', JSON.stringify(fixturesSource)); const source = createSource({url: '/source.json'}); const dataEvent = waitForEvent(source, 'data', (e) => e.sourceDataType === 'content'); + await sleep(0); server.respond(); await expect(dataEvent).resolves.toBeDefined(); }); @@ -106,6 +121,7 @@ describe('VectorTileSource', () => { }); const source = createSource({url: '/source.json', eventedParent: evented}); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; @@ -117,6 +133,7 @@ describe('VectorTileSource', () => { const source = createSource({url: '/source.json'}); const errorEvent = waitForEvent(source, 'error', (e) => e.error.status === 404); + await sleep(0); server.respond(); await expect(errorEvent).resolves.toBeDefined(); @@ -188,6 +205,7 @@ describe('VectorTileSource', () => { const source = createSource({url: '/source.json'}); const transformSpy = vi.spyOn(source.map._requestManager, 'transformRequest'); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; @@ -202,6 +220,34 @@ describe('VectorTileSource', () => { expect(transformSpy).toHaveBeenCalledWith('http://example.com/10/5/5.png', 'Tile'); }); + test('can asynchronously transform tile request', async () => { + const source = createSource({ + tiles: ['http://example.com/{z}/{x}/{y}.png'], + scheme: 'xyz' + }, async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + let receivedMessage: ActorMessage = null; + source.dispatcher = getWrapDispatcher()({ + sendAsync(message) { + receivedMessage = message; + return Promise.resolve({}); + } + }); + await waitForMetadataEvent(source); + + const tile = { + tileID: new OverscaledTileID(10, 0, 10, 5, 5), + state: 'loading', + loadVectorData() {}, + setExpiryData() {} + } as any as Tile; + await source.loadTile(tile); + expect((receivedMessage.data as WorkerTileParameters).request.url).toBe('http://example.com/10/5/5.png'); + expect((receivedMessage.data as WorkerTileParameters).request.headers.Authorization).toBe('Bearer token'); + }); + test('loads a tile even in case of 404', async () => { server.respondWith('/source.json', JSON.stringify(fixturesSource)); @@ -214,6 +260,7 @@ describe('VectorTileSource', () => { } }); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; const tile = { @@ -236,6 +283,7 @@ describe('VectorTileSource', () => { } }); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; const tile = { @@ -259,6 +307,7 @@ describe('VectorTileSource', () => { }); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; @@ -340,6 +389,7 @@ describe('VectorTileSource', () => { const source = createSource({url: '/source.json'}); const promise = waitForMetadataEvent(source); + await sleep(0); server.respond(); await promise; @@ -376,8 +426,9 @@ describe('VectorTileSource', () => { expect((receivedMessage.data as WorkerTileParameters).request.collectResourceTiming).toBeTruthy(); }); - test('cancels TileJSON request if removed', () => { + test('cancels TileJSON request if removed', async () => { const source = createSource({url: '/source.json'}); + await sleep(0); source.onRemove(); expect((server.lastRequest as any).aborted).toBe(true); }); @@ -393,10 +444,12 @@ describe('VectorTileSource', () => { const source = createSource({ url: 'http://localhost:2900/source.json' }); + await sleep(0); const errorHandler = vi.fn(); source.on('error', errorHandler); source.setUrl('http://localhost:2900/source2.json'); + await sleep(0); server.respond(); await waitForEvent(source, 'data', (e: MapSourceDataEvent) => e.sourceDataType === 'metadata'); diff --git a/src/source/vector_tile_source.ts b/src/source/vector_tile_source.ts index 14f9678a43e..ea187f6d26e 100644 --- a/src/source/vector_tile_source.ts +++ b/src/source/vector_tile_source.ts @@ -205,7 +205,7 @@ export class VectorTileSource extends Evented implements Source { async loadTile(tile: Tile): Promise { const url = tile.tileID.canonical.url(this.tiles, this.map.getPixelRatio(), this.scheme); const params: WorkerTileParameters = { - request: this.map._requestManager.transformRequest(url, ResourceType.Tile), + request: await this.map._requestManager.transformRequest(url, ResourceType.Tile), uid: tile.uid, tileID: tile.tileID, zoom: tile.tileID.overscaledZ, @@ -217,7 +217,7 @@ export class VectorTileSource extends Evented implements Source { promoteId: this.promoteId, subdivisionGranularity: this.map.style.projection.subdivisionGranularity, encoding: this.encoding, - overzoomParameters: this._getOverzoomParameters(tile), + overzoomParameters: await this._getOverzoomParameters(tile), etag: tile.etag }; params.request.collectResourceTiming = this._collectResourceTiming; @@ -260,7 +260,7 @@ export class VectorTileSource extends Evented implements Source { * When the requested tile has a higher canonical Z than source maxzoom, pass overzoom parameters so worker can load the * deepest tile at source max zoom to generate sub tiles using geojsonvt for highest performance on vector overscaling */ - private _getOverzoomParameters(tile: Tile): OverzoomParameters | undefined { + private async _getOverzoomParameters(tile: Tile): Promise { if (tile.tileID.canonical.z <= this.maxzoom) { return undefined; } @@ -272,7 +272,7 @@ export class VectorTileSource extends Evented implements Source { return { maxZoomTileID, - overzoomRequest: this.map._requestManager.transformRequest(maxZoomTileUrl, ResourceType.Tile) + overzoomRequest: await this.map._requestManager.transformRequest(maxZoomTileUrl, ResourceType.Tile) }; } diff --git a/src/source/video_source.ts b/src/source/video_source.ts index 24259660210..fdd91339094 100644 --- a/src/source/video_source.ts +++ b/src/source/video_source.ts @@ -70,7 +70,7 @@ export class VideoSource extends ImageSource { this.urls = []; for (const url of options.urls) { - this.urls.push(this.map._requestManager.transformRequest(url, ResourceType.Source).url); + this.urls.push((await this.map._requestManager.transformRequest(url, ResourceType.Source)).url); } try { const video = await getVideo(this.urls); diff --git a/src/style/load_glyph_range.test.ts b/src/style/load_glyph_range.test.ts index 9718120fb95..4ddce21de10 100644 --- a/src/style/load_glyph_range.test.ts +++ b/src/style/load_glyph_range.test.ts @@ -4,7 +4,7 @@ import path from 'path'; import {RequestManager} from '../util/request_manager'; import {loadGlyphRange} from './load_glyph_range'; import {fakeServer} from 'nise'; -import {bufferToArrayBuffer} from '../util/test/util'; +import {bufferToArrayBuffer, sleep} from '../util/test/util'; test('loadGlyphRange', async () => { global.fetch = null; @@ -19,6 +19,7 @@ test('loadGlyphRange', async () => { server.respondWith(bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/0-255.pbf')))); const promise = loadGlyphRange('Arial Unicode MS', 0, 'https://localhost/fonts/v1/{fontstack}/{range}.pbf', manager); + await sleep(0); server.respond(); const result = await promise; @@ -39,3 +40,35 @@ test('loadGlyphRange', async () => { } expect(server.requests[0].url).toBe('https://localhost/fonts/v1/Arial Unicode MS/0-255.pbf'); }); + +test('loadGlyphRange with async transformRequest', async () => { + global.fetch = null; + + const manager = new RequestManager(async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + + const server = fakeServer.create(); + server.respondWith(bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/0-255.pbf')))); + + const promise = loadGlyphRange('Arial Unicode MS', 0, 'https://localhost/fonts/v1/{fontstack}/{range}.pbf', manager); + await sleep(0); + server.respond(); + const result = await promise; + + expect(Object.keys(result)).toHaveLength(223); + for (const key in result) { + const id = Number(key); + const glyph = result[id]; + + expect(glyph.id).toBe(Number(id)); + expect(glyph.metrics).toBeTruthy(); + expect(typeof glyph.metrics.width).toBe('number'); + expect(typeof glyph.metrics.height).toBe('number'); + expect(typeof glyph.metrics.top).toBe('number'); + expect(typeof glyph.metrics.advance).toBe('number'); + } + expect(server.requests[0].url).toBe('https://localhost/fonts/v1/Arial Unicode MS/0-255.pbf'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); +}); diff --git a/src/style/load_glyph_range.ts b/src/style/load_glyph_range.ts index ba63c10a4be..8702deb914c 100644 --- a/src/style/load_glyph_range.ts +++ b/src/style/load_glyph_range.ts @@ -13,7 +13,7 @@ export async function loadGlyphRange(fontstack: string, const begin = range * 256; const end = begin + 255; - const request = requestManager.transformRequest( + const request = await requestManager.transformRequest( urlTemplate.replace('{fontstack}', fontstack).replace('{range}', `${begin}-${end}`), ResourceType.Glyphs ); diff --git a/src/style/load_sprite.test.ts b/src/style/load_sprite.test.ts index 402a4d53a07..64df59d4c68 100644 --- a/src/style/load_sprite.test.ts +++ b/src/style/load_sprite.test.ts @@ -4,7 +4,7 @@ import path from 'path'; import {RequestManager} from '../util/request_manager'; import {loadSprite, normalizeSpriteURL} from './load_sprite'; import {type FakeServer, fakeServer} from 'nise'; -import {bufferToArrayBuffer} from '../util/test/util'; +import {bufferToArrayBuffer, sleep} from '../util/test/util'; import {ABORT_ERROR} from '../util/abort_error'; import * as util from '../util/util'; @@ -69,6 +69,7 @@ describe('loadSprite', () => { const promise = loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 1, new AbortController()); + await sleep(0); server.respond(); const result = await promise; @@ -89,6 +90,34 @@ describe('loadSprite', () => { expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png'); }); + test('backwards compatibility: single string is treated as a URL for the default sprite (async transformRequest)', async () => { + const manager = new RequestManager((url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + + server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.json')).toString()); + server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.png')))); + + const promise = loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 1, new AbortController()); + await sleep(0); + server.respond(); + const result = await promise; + + expect(Object.keys(result)).toHaveLength(1); + expect(Object.keys(result)[0]).toBe('default'); + + Object.values(result['default']).forEach(styleImage => { + expect(styleImage.spriteData).toBeTruthy(); + expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); + }); + + expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); + expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png'); + expect(server.requests[1].requestHeaders.Authorization).toBe('Bearer token'); + }); + test('array of objects support', async () => { const transform = vi.fn().mockImplementation((url, type) => { return {url, type}; @@ -103,6 +132,7 @@ describe('loadSprite', () => { const promise = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}, {id: 'sprite2', url: 'http://localhost:9966/test/unit/assets/sprite2'}], manager, 1, new AbortController()); + await sleep(0); server.respond(); const result = await promise; @@ -132,6 +162,46 @@ describe('loadSprite', () => { expect(server.requests[3].url).toBe('http://localhost:9966/test/unit/assets/sprite2.png'); }); + test('array of objects support (async tranformRequest)', async () => { + const manager = new RequestManager(async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + + server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.json')).toString()); + server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.png')))); + server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite2.json', fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite2.json')).toString()); + server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite2.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite2.png')))); + + const promise = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}, {id: 'sprite2', url: 'http://localhost:9966/test/unit/assets/sprite2'}], manager, 1, new AbortController()); + await sleep(0); + server.respond(); + const result = await promise; + + expect(Object.keys(result)).toHaveLength(2); + expect(Object.keys(result)[0]).toBe('sprite1'); + expect(Object.keys(result)[1]).toBe('sprite2'); + + Object.values(result['sprite1']).forEach(styleImage => { + expect(styleImage.spriteData).toBeTruthy(); + expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); + }); + + Object.values(result['sprite2']).forEach(styleImage => { + expect(styleImage.spriteData).toBeTruthy(); + expect(styleImage.spriteData.context).toBeInstanceOf(CanvasRenderingContext2D); + }); + + expect(server.requests[0].url).toBe('http://localhost:9966/test/unit/assets/sprite1.json'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); + expect(server.requests[1].url).toBe('http://localhost:9966/test/unit/assets/sprite1.png'); + expect(server.requests[1].requestHeaders.Authorization).toBe('Bearer token'); + expect(server.requests[2].url).toBe('http://localhost:9966/test/unit/assets/sprite2.json'); + expect(server.requests[2].requestHeaders.Authorization).toBe('Bearer token'); + expect(server.requests[3].url).toBe('http://localhost:9966/test/unit/assets/sprite2.png'); + expect(server.requests[3].requestHeaders.Authorization).toBe('Bearer token'); + }); + test('server returns error', async () => { const transform = vi.fn().mockImplementation((url, type) => { return {url, type}; @@ -141,6 +211,7 @@ describe('loadSprite', () => { server.respondWith((xhr) => xhr.respond(500)); const promise = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}], manager, 1, new AbortController()); + await sleep(0); server.respond(); await expect(promise).rejects.toThrow(/AJAXError.*500.*/); @@ -159,6 +230,7 @@ describe('loadSprite', () => { const abortController = new AbortController(); const promise = loadSprite([{id: 'sprite1', url: 'http://localhost:9966/test/unit/assets/sprite1'}], manager, 1, abortController); + await sleep(0); abortController.abort(); expect((server.requests[0] as any).aborted).toBeTruthy(); @@ -181,6 +253,7 @@ describe('loadSprite', () => { server.respondWith('GET', 'http://localhost:9966/test/unit/assets/sprite1@2x.png', bufferToArrayBuffer(fs.readFileSync(path.join(__dirname, '../../test/unit/assets/sprite1.png')))); const promise = loadSprite('http://localhost:9966/test/unit/assets/sprite1', manager, 2, new AbortController()); + await sleep(0); server.respond(); const result = await promise; diff --git a/src/style/load_sprite.ts b/src/style/load_sprite.ts index 288e1239338..551c5690a61 100644 --- a/src/style/load_sprite.ts +++ b/src/style/load_sprite.ts @@ -39,10 +39,10 @@ export async function loadSprite( const imagesMap: {[id: string]: Promise>} = {}; for (const {id, url} of spriteArray) { - const jsonRequestParameters = requestManager.transformRequest(normalizeSpriteURL(url, format, '.json'), ResourceType.SpriteJSON); + const jsonRequestParameters = await requestManager.transformRequest(normalizeSpriteURL(url, format, '.json'), ResourceType.SpriteJSON); jsonsMap[id] = getJSON(jsonRequestParameters, abortController); - const imageRequestParameters = requestManager.transformRequest(normalizeSpriteURL(url, format, '.png'), ResourceType.SpriteImage); + const imageRequestParameters = await requestManager.transformRequest(normalizeSpriteURL(url, format, '.png'), ResourceType.SpriteImage); imagesMap[id] = ImageRequest.getImage(imageRequestParameters, abortController); } diff --git a/src/style/style.test.ts b/src/style/style.test.ts index 0d82f1f0aa0..70a72988b4f 100644 --- a/src/style/style.test.ts +++ b/src/style/style.test.ts @@ -133,6 +133,26 @@ describe('Style.loadURL', () => { expect(spy.mock.calls[0][1]).toBe('Style'); }); + test('can asynchronously transform style request', async () => { + server.respondWith('style.json', JSON.stringify(createStyleJSON())); + + const map = getStubMap(); + map._requestManager.transformRequest = async (url, type) => ({ + url, + type, + headers: {Authorization: 'Bearer token'} + }); + + const style = new Style(map); + style.loadURL('style.json'); + await sleep(0); + server.respond(); + await waitForEvent(style, 'data', (event) => event.dataType === 'style'); + + expect(server.requests[0].url).toBe('style.json'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); + }); + test('validates the style', async () => { const style = new Style(getStubMap()); @@ -140,6 +160,7 @@ describe('Style.loadURL', () => { style.loadURL('style.json'); server.respondWith(JSON.stringify(createStyleJSON({version: 'invalid'}))); + await sleep(0); server.respond(); const {error} = await errorPromise; @@ -147,9 +168,10 @@ describe('Style.loadURL', () => { expect(error.message).toMatch(/version/); }); - test('cancels pending requests if removed', () => { + test('cancels pending requests if removed', async () => { const style = new Style(getStubMap()); style.loadURL('style.json'); + await sleep(0); style._remove(); expect((server.lastRequest as any).aborted).toBe(true); }); @@ -173,6 +195,7 @@ describe('Style.loadURL', () => { const promise = style.once('error'); style.loadURL('style.json'); server.respondWith(request => request.respond(errorStatus)); + await sleep(0); server.respond(); const {error} = await promise; @@ -253,6 +276,7 @@ describe('Style.loadJSON', () => { expect(e.dataType).toBe('style'); const promise = style.once('data'); + await sleep(0); server.respond(); await promise; @@ -286,6 +310,7 @@ describe('Style.loadJSON', () => { const secondDataPromise = style.once('data'); + await sleep(0); server.respond(); const secondDateEvent = await secondDataPromise; @@ -1401,6 +1426,7 @@ describe('Style.addSprite', () => { style.on('error', errorHandler); style.addSprite('test', 'https://example.com/sprite'); + await sleep(0); style._remove(); await waitForEvent(style, 'data', (event) => event.dataType === 'style'); @@ -1502,6 +1528,7 @@ describe('Style.setSprite', () => { const errorPromise = style.once('error'); style.setSprite('https://example.com/sprite'); + await sleep(0); server.respond(); const {error} = await errorPromise; diff --git a/src/style/style.ts b/src/style/style.ts index 552f06dfccc..b0e6a8a972f 100644 --- a/src/style/style.ts +++ b/src/style/style.ts @@ -420,24 +420,25 @@ export class Style extends Evented { } } - loadURL(url: string, options: StyleSwapOptions & StyleSetterOptions = {}, previousStyle?: StyleSpecification) { + async loadURL(url: string, options: StyleSwapOptions & StyleSetterOptions = {}, previousStyle?: StyleSpecification) { this.fire(new Event('dataloading', {dataType: 'style'})); options.validate = typeof options.validate === 'boolean' ? options.validate : true; - const request = this.map._requestManager.transformRequest(url, ResourceType.Style); + const request = await this.map._requestManager.transformRequest(url, ResourceType.Style); this._loadStyleRequest = new AbortController(); const abortController = this._loadStyleRequest; - getJSON(request, this._loadStyleRequest).then((response) => { + try { + const response = await getJSON(request, this._loadStyleRequest); this._loadStyleRequest = null; this._load(response.data, options, previousStyle); - }).catch((error) => { + } catch (error) { this._loadStyleRequest = null; if (error && !abortController.signal.aborted) { // ignore abort this.fire(new ErrorEvent(error)); } - }); + } } loadJSON(json: StyleSpecification, options: StyleSetterOptions & StyleSwapOptions = {}, previousStyle?: StyleSpecification) { diff --git a/src/ui/control/attribution_control.test.ts b/src/ui/control/attribution_control.test.ts index 8852da48203..88d3eef6568 100644 --- a/src/ui/control/attribution_control.test.ts +++ b/src/ui/control/attribution_control.test.ts @@ -294,6 +294,7 @@ describe('AttributionControl', () => { map.on('data', spy); await map.once('load'); map.addSource('1', {type: 'raster-dem', url: '/source.json'}); + await sleep(0); server.respond(); await sleep(100); @@ -326,6 +327,7 @@ describe('AttributionControl', () => { map.on('data', spy); await map.once('load'); map.addSource('1', {type: 'raster-dem', url: '/source.json'}); + await sleep(0); server.respond(); map.setTerrain({source: '1'}); await sleep(100); diff --git a/src/ui/map.ts b/src/ui/map.ts index 95f5da37973..378742cb12f 100644 --- a/src/ui/map.ts +++ b/src/ui/map.ts @@ -2152,17 +2152,18 @@ export class Map extends Camera { } } - _diffStyle(style: StyleSpecification | string, options?: StyleSwapOptions & StyleOptions) { + async _diffStyle(style: StyleSpecification | string, options?: StyleSwapOptions & StyleOptions) { if (typeof style === 'string') { const url = style; - const request = this._requestManager.transformRequest(url, ResourceType.Style); - getJSON(request, new AbortController()).then((response) => { + const request = await this._requestManager.transformRequest(url, ResourceType.Style); + try { + const response = await getJSON(request, new AbortController()); this._updateDiff(response.data, options); - }).catch((error) => { + } catch (error) { if (error) { this.fire(new ErrorEvent(error)); } - }); + } } else if (typeof style === 'object') { this._updateDiff(style, options); } @@ -2710,8 +2711,8 @@ export class Map extends Camera { * ``` * @see [Add an icon to the map](https://maplibre.org/maplibre-gl-js/docs/examples/add-an-icon-to-the-map/) */ - loadImage(url: string): Promise> { - return ImageRequest.getImage(this._requestManager.transformRequest(url, ResourceType.Image), new AbortController()); + async loadImage(url: string): Promise> { + return ImageRequest.getImage(await this._requestManager.transformRequest(url, ResourceType.Image), new AbortController()); } /** diff --git a/src/ui/map_tests/map_style.test.ts b/src/ui/map_tests/map_style.test.ts index 913619a9146..73b8c72687e 100644 --- a/src/ui/map_tests/map_style.test.ts +++ b/src/ui/map_tests/map_style.test.ts @@ -269,6 +269,50 @@ describe('setStyle', () => { expect(loadedStyle.layers).toHaveLength(1); }); + // in this special case, retrieval of the style JSON is not done by style but by map + test('can asynchronously transform style JSON request specified to setStyle with diffing', async () => { + server.respondWith('style.json', JSON.stringify(createStyle())); + + const style = extend(createStyle(), { + sources: { + maplibre: { + type: 'vector', + minzoom: 1, + maxzoom: 10, + tiles: ['http://example.com/{z}/{x}/{y}.png'] + } + }, + layers: [{ + id: 'layerId0', + type: 'circle', + source: 'maplibre', + 'source-layer': 'sourceLayer' + }, { + id: 'layerId1', + type: 'circle', + source: 'maplibre', + 'source-layer': 'sourceLayer' + }] + }); + + const map = createMap({style}); + const transformRequestSpy = vi.fn(async (url) => ({ + url, + headers: {Authorization: 'Bearer token'} + })); + map.setTransformRequest(transformRequestSpy); + await map.once('style.load'); + + map.setStyle('style.json', {diff: true}); + await sleep(0); + server.respond(); + await map.once('style.load'); + + expect(transformRequestSpy).toHaveBeenCalledWith('style.json', 'Style'); + expect(server.requests[0].url).toBe('style.json'); + expect(server.requests[0].requestHeaders.Authorization).toBe('Bearer token'); + }); + test('transformStyle should get called when passed to setStyle after the map is initialised without a style', async () => { const map = createMap({deleteStyle: true}); map.setStyle(createStyle(), { diff --git a/src/util/request_manager.ts b/src/util/request_manager.ts index df2902be1d0..0aae312b61e 100644 --- a/src/util/request_manager.ts +++ b/src/util/request_manager.ts @@ -18,7 +18,7 @@ export const enum ResourceType { * This function is used to tranform a request. * It is used just before executing the relevant request. */ -export type RequestTransformFunction = (url: string, resourceType?: ResourceType) => RequestParameters | undefined; +export type RequestTransformFunction = (url: string, resourceType?: ResourceType) => RequestParameters | Promise | undefined; export class RequestManager { _transformRequestFn: RequestTransformFunction | null;