mapbox-gl
Version:
A WebGL interactive maps library
686 lines (572 loc) • 24.3 kB
JavaScript
// @flow
/***** START WARNING REMOVAL OR MODIFICATION OF THE
* FOLLOWING CODE VIOLATES THE MAPBOX TERMS OF SERVICE ******
* The following code is used to access Mapbox's APIs. Removal or modification
* of this code can result in higher fees and/or
* termination of your account with Mapbox.
*
* Under the Mapbox Terms of Service, you may not use this code to access Mapbox
* Mapping APIs other than through Mapbox SDKs.
*
* The Mapping APIs documentation is available at https://docs.mapbox.com/api/maps/#maps
* and the Mapbox Terms of Service are available at https://www.mapbox.com/tos/
******************************************************************************/
import config from './config.js';
import webpSupported from './webp_supported.js';
import {createSkuToken, SKU_ID} from './sku_token.js';
import {version as sdkVersion} from '../../package.json';
import {uuid, validateUuid, storageAvailable, b64DecodeUnicode, b64EncodeUnicode, warnOnce, extend} from './util.js';
import {postData, ResourceType, getData} from './ajax.js';
import {getLivePerformanceMetrics} from '../util/live_performance.js';
import type {LivePerformanceData} from '../util/live_performance.js';
import type {RequestParameters} from './ajax.js';
import type {Cancelable} from '../types/cancelable.js';
import type {TileJSON} from '../types/tilejson.js';
import assert from 'assert';
type ResourceTypeEnum = $Keys<typeof ResourceType>;
export type RequestTransformFunction = (url: string, resourceType?: ResourceTypeEnum) => RequestParameters;
type UrlObject = {|
protocol: string,
authority: string,
path: string,
params: Array<string>
|};
type EventCallback = (err: ?Error) => void;
export const AUTH_ERR_MSG: string = 'NO_ACCESS_TOKEN';
export class RequestManager {
_skuToken: string;
_skuTokenExpiresAt: number;
_transformRequestFn: ?RequestTransformFunction;
_customAccessToken: ?string;
_silenceAuthErrors: boolean;
constructor(transformRequestFn?: ?RequestTransformFunction, customAccessToken?: ?string, silenceAuthErrors: ?boolean) {
this._transformRequestFn = transformRequestFn;
this._customAccessToken = customAccessToken;
this._silenceAuthErrors = !!silenceAuthErrors;
this._createSkuToken();
}
_createSkuToken() {
const skuToken = createSkuToken();
this._skuToken = skuToken.token;
this._skuTokenExpiresAt = skuToken.tokenExpiresAt;
}
_isSkuTokenExpired(): boolean {
return Date.now() > this._skuTokenExpiresAt;
}
transformRequest(url: string, type: ResourceTypeEnum): RequestParameters {
if (this._transformRequestFn) {
return this._transformRequestFn(url, type) || {url};
}
return {url};
}
normalizeStyleURL(url: string, accessToken?: string): string {
if (!isMapboxURL(url)) return url;
const urlObject = parseUrl(url);
urlObject.params.push(`sdk=js-${sdkVersion}`);
urlObject.path = `/styles/v1${urlObject.path}`;
return this._makeAPIURL(urlObject, this._customAccessToken || accessToken);
}
normalizeGlyphsURL(url: string, accessToken?: string): string {
if (!isMapboxURL(url)) return url;
const urlObject = parseUrl(url);
urlObject.path = `/fonts/v1${urlObject.path}`;
return this._makeAPIURL(urlObject, this._customAccessToken || accessToken);
}
normalizeModelURL(url: string, accessToken?: string): string {
if (!isMapboxURL(url)) return url;
const urlObject = parseUrl(url);
urlObject.path = `/models/v1${urlObject.path}`;
return this._makeAPIURL(urlObject, this._customAccessToken || accessToken);
}
normalizeSourceURL(url: string, accessToken?: ?string, language?: ?string, worldview?: ?string): string {
if (!isMapboxURL(url)) return url;
const urlObject = parseUrl(url);
urlObject.path = `/v4/${urlObject.authority}.json`;
// TileJSON requests need a secure flag appended to their URLs so
// that the server knows to send SSL-ified resource references.
urlObject.params.push('secure');
if (language) {
urlObject.params.push(`language=${language}`);
}
if (worldview) {
urlObject.params.push(`worldview=${worldview}`);
}
return this._makeAPIURL(urlObject, this._customAccessToken || accessToken);
}
normalizeSpriteURL(url: string, format: string, extension: string, accessToken?: string): string {
const urlObject = parseUrl(url);
if (!isMapboxURL(url)) {
urlObject.path += `${format}${extension}`;
return formatUrl(urlObject);
}
urlObject.path = `/styles/v1${urlObject.path}/sprite${format}${extension}`;
return this._makeAPIURL(urlObject, this._customAccessToken || accessToken);
}
normalizeTileURL(tileURL: string, use2x?: boolean, rasterTileSize?: number): string {
if (this._isSkuTokenExpired()) {
this._createSkuToken();
}
if (tileURL && !isMapboxURL(tileURL)) return tileURL;
const urlObject = parseUrl(tileURL);
const imageExtensionRe = /(\.(png|jpg)\d*)(?=$)/;
const extension = webpSupported.supported ? '.webp' : '$1';
// The v4 mapbox tile API supports 512x512 image tiles but they must be requested as '@2x' tiles.
const use2xAs512 = rasterTileSize && urlObject.authority !== 'raster' && rasterTileSize === 512;
const suffix = use2x || use2xAs512 ? '@2x' : '';
urlObject.path = urlObject.path.replace(imageExtensionRe, `${suffix}${extension}`);
if (urlObject.authority === 'raster') {
urlObject.path = `/${config.RASTER_URL_PREFIX}${urlObject.path}`;
} else {
const tileURLAPIPrefixRe = /^.+\/v4\//;
urlObject.path = urlObject.path.replace(tileURLAPIPrefixRe, '/');
urlObject.path = `/${config.TILE_URL_VERSION}${urlObject.path}`;
}
const accessToken = this._customAccessToken || getAccessToken(urlObject.params) || config.ACCESS_TOKEN;
if (config.REQUIRE_ACCESS_TOKEN && accessToken && this._skuToken) {
urlObject.params.push(`sku=${this._skuToken}`);
}
return this._makeAPIURL(urlObject, accessToken);
}
canonicalizeTileURL(url: string, removeAccessToken: boolean): string {
// matches any file extension specified by a dot and one or more alphanumeric characters
const extensionRe = /\.[\w]+$/;
const urlObject = parseUrl(url);
// Make sure that we are dealing with a valid Mapbox tile URL.
// Has to begin with /v4/ or /raster/v1, with a valid filename + extension
if (!urlObject.path.match(/^(\/v4\/|\/raster\/v1\/)/) || !urlObject.path.match(extensionRe)) {
// Not a proper Mapbox tile URL.
return url;
}
// Reassemble the canonical URL from the parts we've parsed before.
let result = "mapbox://";
if (urlObject.path.match(/^\/raster\/v1\//)) {
// If the tile url has /raster/v1/, make the final URL mapbox://raster/....
const rasterPrefix = `/${config.RASTER_URL_PREFIX}/`;
result += `raster/${urlObject.path.replace(rasterPrefix, '')}`;
} else {
const tilesPrefix = `/${config.TILE_URL_VERSION}/`;
result += `tiles/${urlObject.path.replace(tilesPrefix, '')}`;
}
// Append the query string, minus the access token parameter.
let params = urlObject.params;
if (removeAccessToken) {
params = params.filter(p => !p.match(/^access_token=/));
}
if (params.length) result += `?${params.join('&')}`;
return result;
}
canonicalizeTileset(tileJSON: TileJSON, sourceURL?: string): Array<string> {
const removeAccessToken = sourceURL ? isMapboxURL(sourceURL) : false;
const canonical = [];
for (const url of tileJSON.tiles || []) {
if (isMapboxHTTPURL(url)) {
canonical.push(this.canonicalizeTileURL(url, removeAccessToken));
} else {
canonical.push(url);
}
}
return canonical;
}
_makeAPIURL(urlObject: UrlObject, accessToken: string | null | void): string {
const help = 'See https://docs.mapbox.com/api/overview/#access-tokens-and-token-scopes';
const apiUrlObject = parseUrl(config.API_URL);
urlObject.protocol = apiUrlObject.protocol;
urlObject.authority = apiUrlObject.authority;
if (urlObject.protocol === 'http') {
const i = urlObject.params.indexOf('secure');
if (i >= 0) urlObject.params.splice(i, 1);
}
if (apiUrlObject.path !== '/') {
urlObject.path = `${apiUrlObject.path}${urlObject.path}`;
}
if (!config.REQUIRE_ACCESS_TOKEN) return formatUrl(urlObject);
accessToken = accessToken || config.ACCESS_TOKEN;
if (!this._silenceAuthErrors) {
if (!accessToken)
throw new Error(`An API access token is required to use Mapbox GL. ${help}`);
if (accessToken[0] === 's')
throw new Error(`Use a public access token (pk.*) with Mapbox GL, not a secret access token (sk.*). ${help}`);
}
urlObject.params = urlObject.params.filter((d) => d.indexOf('access_token') === -1);
urlObject.params.push(`access_token=${accessToken || ''}`);
return formatUrl(urlObject);
}
}
export function isMapboxURL(url: string): boolean {
return url.indexOf('mapbox:') === 0;
}
export function isMapboxHTTPURL(url: string): boolean {
return config.API_URL_REGEX.test(url);
}
export function isMapboxHTTPCDNURL(url: string): boolean {
return config.API_CDN_URL_REGEX.test(url);
}
export function isMapboxHTTPStyleURL(url: string): boolean {
return config.API_STYLE_REGEX.test(url) && !isMapboxHTTPSpriteURL(url);
}
export function isMapboxHTTPTileJSONURL(url: string): boolean {
return config.API_TILEJSON_REGEX.test(url);
}
export function isMapboxHTTPSpriteURL(url: string): boolean {
return config.API_SPRITE_REGEX.test(url);
}
export function isMapboxHTTPFontsURL(url: string): boolean {
return config.API_FONTS_REGEX.test(url);
}
export function hasCacheDefeatingSku(url: string): boolean {
return url.indexOf('sku=') > 0 && isMapboxHTTPURL(url);
}
function getAccessToken(params: Array<string>): string | null {
for (const param of params) {
const match = param.match(/^access_token=(.*)$/);
if (match) {
return match[1];
}
}
return null;
}
const urlRe = /^(\w+):\/\/([^/?]*)(\/[^?]+)?\??(.+)?/;
function parseUrl(url: string): UrlObject {
const parts = url.match(urlRe);
if (!parts) {
throw new Error('Unable to parse URL object');
}
return {
protocol: parts[1],
authority: parts[2],
path: parts[3] || '/',
params: parts[4] ? parts[4].split('&') : []
};
}
function formatUrl(obj: UrlObject): string {
const params = obj.params.length ? `?${obj.params.join('&')}` : '';
return `${obj.protocol}://${obj.authority}${obj.path}${params}`;
}
const telemEventKey = 'mapbox.eventData';
function parseAccessToken(accessToken: ?string) {
if (!accessToken) {
return null;
}
const parts = accessToken.split('.');
if (!parts || parts.length !== 3) {
return null;
}
try {
const jsonData = JSON.parse(b64DecodeUnicode(parts[1]));
return jsonData;
} catch (e) {
return null;
}
}
type TelemetryEventType = 'appUserTurnstile' | 'map.load' | 'map.auth' | 'gljs.performance';
class TelemetryEvent {
eventData: any;
anonId: ?string;
queue: Array<any>;
type: TelemetryEventType;
pendingRequest: ?Cancelable;
_customAccessToken: ?string;
constructor(type: TelemetryEventType) {
this.type = type;
this.anonId = null;
this.eventData = {};
this.queue = [];
this.pendingRequest = null;
}
getStorageKey(domain: ?string): string {
const tokenData = parseAccessToken(config.ACCESS_TOKEN);
let u = '';
if (tokenData && tokenData['u']) {
u = b64EncodeUnicode(tokenData['u']);
} else {
u = config.ACCESS_TOKEN || '';
}
return domain ?
`${telemEventKey}.${domain}:${u}` :
`${telemEventKey}:${u}`;
}
fetchEventData() {
const isLocalStorageAvailable = storageAvailable('localStorage');
const storageKey = this.getStorageKey();
const uuidKey = this.getStorageKey('uuid');
if (isLocalStorageAvailable) {
//Retrieve cached data
try {
const data = localStorage.getItem(storageKey);
if (data) {
this.eventData = JSON.parse(data);
}
const uuid = localStorage.getItem(uuidKey);
if (uuid) this.anonId = uuid;
} catch (e) {
warnOnce('Unable to read from LocalStorage');
}
}
}
saveEventData() {
const isLocalStorageAvailable = storageAvailable('localStorage');
const storageKey = this.getStorageKey();
const uuidKey = this.getStorageKey('uuid');
const anonId = this.anonId;
if (isLocalStorageAvailable && anonId) {
try {
localStorage.setItem(uuidKey, anonId);
if (Object.keys(this.eventData).length >= 1) {
localStorage.setItem(storageKey, JSON.stringify(this.eventData));
}
} catch (e) {
warnOnce('Unable to write to LocalStorage');
}
}
}
processRequests(_: ?string) {}
/*
* If any event data should be persisted after the POST request, the callback should modify eventData`
* to the values that should be saved. For this reason, the callback should be invoked prior to the call
* to TelemetryEvent#saveData
*/
postEvent(timestamp: number, additionalPayload: {[_: string]: any}, callback: EventCallback, customAccessToken?: ?string) {
if (!config.EVENTS_URL) return;
const eventsUrlObject: UrlObject = parseUrl(config.EVENTS_URL);
eventsUrlObject.params.push(`access_token=${customAccessToken || config.ACCESS_TOKEN || ''}`);
const payload: Object = {
event: this.type,
created: new Date(timestamp).toISOString()
};
const finalPayload = additionalPayload ? extend(payload, additionalPayload) : payload;
const request: RequestParameters = {
url: formatUrl(eventsUrlObject),
headers: {
'Content-Type': 'text/plain' //Skip the pre-flight OPTIONS request
},
body: JSON.stringify([finalPayload])
};
this.pendingRequest = postData(request, (error) => {
this.pendingRequest = null;
callback(error);
this.saveEventData();
this.processRequests(customAccessToken);
});
}
queueRequest(event: any, customAccessToken?: ?string) {
this.queue.push(event);
this.processRequests(customAccessToken);
}
}
export class PerformanceEvent extends TelemetryEvent {
constructor() {
super('gljs.performance');
}
postPerformanceEvent(customAccessToken: ?string, performanceData: LivePerformanceData) {
if (config.EVENTS_URL) {
if (customAccessToken || config.ACCESS_TOKEN) {
this.queueRequest({timestamp: Date.now(), performanceData}, customAccessToken);
}
}
}
processRequests(customAccessToken?: ?string) {
if (this.pendingRequest || this.queue.length === 0) {
return;
}
const {timestamp, performanceData} = this.queue.shift();
const additionalPayload = getLivePerformanceMetrics(performanceData);
// Server will only process string for these entries
for (const metadata of additionalPayload.metadata) {
assert(typeof metadata.value === 'string');
}
for (const counter of additionalPayload.counters) {
assert(typeof counter.value === 'string');
}
for (const attribute of additionalPayload.attributes) {
assert(typeof attribute.value === 'string');
}
this.postEvent(timestamp, additionalPayload, () => {}, customAccessToken);
}
}
export class MapLoadEvent extends TelemetryEvent {
+success: {[_: number]: boolean};
skuToken: string;
errorCb: EventCallback;
constructor() {
super('map.load');
this.success = {};
this.skuToken = '';
}
postMapLoadEvent(mapId: number, skuToken: string, customAccessToken: ?string, callback: EventCallback) {
this.skuToken = skuToken;
this.errorCb = callback;
if (config.EVENTS_URL) {
if (customAccessToken || config.ACCESS_TOKEN) {
this.queueRequest({id: mapId, timestamp: Date.now()}, customAccessToken);
} else {
this.errorCb(new Error(AUTH_ERR_MSG));
}
}
}
processRequests(customAccessToken?: ?string) {
if (this.pendingRequest || this.queue.length === 0) return;
const {id, timestamp} = this.queue.shift();
// Only one load event should fire per map
if (id && this.success[id]) return;
if (!this.anonId) {
this.fetchEventData();
}
if (!validateUuid(this.anonId)) {
this.anonId = uuid();
}
const additionalPayload = {
sdkIdentifier: 'mapbox-gl-js',
sdkVersion,
skuId: SKU_ID,
skuToken: this.skuToken,
userId: this.anonId
};
this.postEvent(timestamp, additionalPayload, (err) => {
if (err) {
this.errorCb(err);
} else {
if (id) this.success[id] = true;
}
}, customAccessToken);
}
}
export class MapSessionAPI extends TelemetryEvent {
+success: {[_: number]: boolean};
skuToken: string;
errorCb: EventCallback;
constructor() {
super('map.auth');
this.success = {};
this.skuToken = '';
}
getSession(timestamp: number, token: string, callback: EventCallback, customAccessToken?: ?string) {
if (!config.API_URL || !config.SESSION_PATH) return;
const authUrlObject: UrlObject = parseUrl(config.API_URL + config.SESSION_PATH);
authUrlObject.params.push(`sku=${token || ''}`);
authUrlObject.params.push(`access_token=${customAccessToken || config.ACCESS_TOKEN || ''}`);
const request: RequestParameters = {
url: formatUrl(authUrlObject),
headers: {
'Content-Type': 'text/plain', //Skip the pre-flight OPTIONS request
}
};
this.pendingRequest = getData(request, (error) => {
this.pendingRequest = null;
callback(error);
this.saveEventData();
this.processRequests(customAccessToken);
});
}
getSessionAPI(mapId: number, skuToken: string, customAccessToken: ?string, callback: EventCallback) {
this.skuToken = skuToken;
this.errorCb = callback;
if (config.SESSION_PATH && config.API_URL) {
if (customAccessToken || config.ACCESS_TOKEN) {
this.queueRequest({id: mapId, timestamp: Date.now()}, customAccessToken);
} else {
this.errorCb(new Error(AUTH_ERR_MSG));
}
}
}
processRequests(customAccessToken?: ?string) {
if (this.pendingRequest || this.queue.length === 0) return;
const {id, timestamp} = this.queue.shift();
// Only one load event should fire per map
if (id && this.success[id]) return;
this.getSession(timestamp, this.skuToken, (err) => {
if (err) {
this.errorCb(err);
} else {
if (id) this.success[id] = true;
}
}, customAccessToken);
}
}
export class TurnstileEvent extends TelemetryEvent {
constructor(customAccessToken?: ?string) {
super('appUserTurnstile');
this._customAccessToken = customAccessToken;
}
postTurnstileEvent(tileUrls: Array<string>, customAccessToken?: ?string) {
//Enabled only when Mapbox Access Token is set and a source uses
// mapbox tiles.
if (config.EVENTS_URL &&
config.ACCESS_TOKEN &&
Array.isArray(tileUrls) &&
tileUrls.some(url => isMapboxURL(url) || isMapboxHTTPURL(url))) {
this.queueRequest(Date.now(), customAccessToken);
}
}
processRequests(customAccessToken?: ?string) {
if (this.pendingRequest || this.queue.length === 0) {
return;
}
if (!this.anonId || !this.eventData.lastSuccess || !this.eventData.tokenU) {
//Retrieve cached data
this.fetchEventData();
}
const tokenData = parseAccessToken(config.ACCESS_TOKEN);
const tokenU = tokenData ? tokenData['u'] : config.ACCESS_TOKEN;
//Reset event data cache if the access token owner changed.
let dueForEvent = tokenU !== this.eventData.tokenU;
if (!validateUuid(this.anonId)) {
this.anonId = uuid();
dueForEvent = true;
}
const nextUpdate = this.queue.shift();
// Record turnstile event once per calendar day.
if (this.eventData.lastSuccess) {
const lastUpdate = new Date(this.eventData.lastSuccess);
const nextDate = new Date(nextUpdate);
const daysElapsed = (nextUpdate - this.eventData.lastSuccess) / (24 * 60 * 60 * 1000);
dueForEvent = dueForEvent || daysElapsed >= 1 || daysElapsed < -1 || lastUpdate.getDate() !== nextDate.getDate();
} else {
dueForEvent = true;
}
if (!dueForEvent) {
this.processRequests();
return;
}
const additionalPayload = {
sdkIdentifier: 'mapbox-gl-js',
sdkVersion,
skuId: SKU_ID,
"enabled.telemetry": false,
userId: this.anonId
};
this.postEvent(nextUpdate, additionalPayload, (err) => {
if (!err) {
this.eventData.lastSuccess = nextUpdate;
this.eventData.tokenU = tokenU;
}
}, customAccessToken);
}
}
const turnstileEvent_ = new TurnstileEvent();
// $FlowFixMe[method-unbinding]
export const postTurnstileEvent: (tileUrls: Array<string>, customAccessToken?: ?string) => void = turnstileEvent_.postTurnstileEvent.bind(turnstileEvent_);
const mapLoadEvent_ = new MapLoadEvent();
// $FlowFixMe[method-unbinding]
export const postMapLoadEvent: (number, string, ?string, EventCallback) => void = mapLoadEvent_.postMapLoadEvent.bind(mapLoadEvent_);
export const performanceEvent_: PerformanceEvent = new PerformanceEvent();
// $FlowFixMe[method-unbinding]
export const postPerformanceEvent: (?string, LivePerformanceData) => void = performanceEvent_.postPerformanceEvent.bind(performanceEvent_);
const mapSessionAPI_ = new MapSessionAPI();
// $FlowFixMe[method-unbinding]
export const getMapSessionAPI: (number, string, ?string, EventCallback) => void = mapSessionAPI_.getSessionAPI.bind(mapSessionAPI_);
const authenticatedMaps = new Set();
export function storeAuthState(gl: WebGL2RenderingContext, state: boolean) {
if (state) {
authenticatedMaps.add(gl);
} else {
authenticatedMaps.delete(gl);
}
}
export function isMapAuthenticated(gl: WebGL2RenderingContext): boolean {
return authenticatedMaps.has(gl);
}
export function removeAuthState(gl: WebGL2RenderingContext) {
authenticatedMaps.delete(gl);
}
/***** END WARNING - REMOVAL OR MODIFICATION OF THE
PRECEDING CODE VIOLATES THE MAPBOX TERMS OF SERVICE ******/