mapbox-gl
Version:
A WebGL interactive maps library
343 lines (302 loc) • 13.2 kB
JavaScript
// @flow
import window from './window';
import { extend, warnOnce } from './util';
import { isMapboxHTTPURL, hasCacheDefeatingSku } from './mapbox';
import config from './config';
import assert from 'assert';
import { cacheGet, cachePut } from './tile_request_cache';
import type { Callback } from '../types/callback';
import type { Cancelable } from '../types/cancelable';
/**
* The type of a resource.
* @private
* @readonly
* @enum {string}
*/
const ResourceType = {
Unknown: 'Unknown',
Style: 'Style',
Source: 'Source',
Tile: 'Tile',
Glyphs: 'Glyphs',
SpriteImage: 'SpriteImage',
SpriteJSON: 'SpriteJSON',
Image: 'Image'
};
export { ResourceType };
if (typeof Object.freeze == 'function') {
Object.freeze(ResourceType);
}
/**
* A `RequestParameters` object to be returned from Map.options.transformRequest callbacks.
* @typedef {Object} RequestParameters
* @property {string} url The URL to be requested.
* @property {Object} headers The headers to be sent with the request.
* @property {string} credentials `'same-origin'|'include'` Use 'include' to send cookies with cross-origin requests.
*/
export type RequestParameters = {
url: string,
headers?: Object,
method?: 'GET' | 'POST' | 'PUT',
body?: string,
type?: 'string' | 'json' | 'arrayBuffer',
credentials?: 'same-origin' | 'include',
collectResourceTiming?: boolean
};
export type ResponseCallback<T> = (error: ?Error, data: ?T, cacheControl: ?string, expires: ?string) => void;
class AJAXError extends Error {
status: number;
url: string;
constructor(message: string, status: number, url: string) {
if (status === 401 && isMapboxHTTPURL(url)) {
message += ': you may have provided an invalid Mapbox access token. See https://www.mapbox.com/api-documentation/#access-tokens-and-token-scopes';
}
super(message);
this.status = status;
this.url = url;
// work around for https://github.com/Rich-Harris/buble/issues/40
this.name = this.constructor.name;
this.message = message;
}
toString() {
return `${this.name}: ${this.message} (${this.status}): ${this.url}`;
}
}
function isWorker() {
return typeof WorkerGlobalScope !== 'undefined' && typeof self !== 'undefined' &&
self instanceof WorkerGlobalScope;
}
// Ensure that we're sending the correct referrer from blob URL worker bundles.
// For files loaded from the local file system, `location.origin` will be set
// to the string(!) "null" (Firefox), or "file://" (Chrome, Safari, Edge, IE),
// and we will set an empty referrer. Otherwise, we're using the document's URL.
/* global self, WorkerGlobalScope */
export const getReferrer = isWorker() ?
() => self.worker && self.worker.referrer :
() => {
const origin = window.location.origin;
if (origin && origin !== 'null' && origin !== 'file://') {
return origin + window.location.pathname;
}
};
function makeFetchRequest(requestParameters: RequestParameters, callback: ResponseCallback<any>): Cancelable {
const controller = new window.AbortController();
const request = new window.Request(requestParameters.url, {
method: requestParameters.method || 'GET',
body: requestParameters.body,
credentials: requestParameters.credentials,
headers: requestParameters.headers,
referrer: getReferrer(),
signal: controller.signal
});
let complete = false;
let aborted = false;
const cacheIgnoringSearch = hasCacheDefeatingSku(request.url);
if (requestParameters.type === 'json') {
request.headers.set('Accept', 'application/json');
}
const validateOrFetch = (err, cachedResponse, responseIsFresh) => {
if (aborted) return;
if (err) {
// Do fetch in case of cache error.
// HTTP pages in Edge trigger a security error that can be ignored.
if (err.message !== 'SecurityError') {
warnOnce(err);
}
}
if (cachedResponse && responseIsFresh) {
return finishRequest(cachedResponse);
}
if (cachedResponse) {
// We can't do revalidation with 'If-None-Match' because then the
// request doesn't have simple cors headers.
}
const requestTime = Date.now();
window.fetch(request).then(response => {
if (response.ok) {
const cacheableResponse = cacheIgnoringSearch ? response.clone() : null;
return finishRequest(response, cacheableResponse, requestTime);
} else {
return callback(new AJAXError(response.statusText, response.status, requestParameters.url));
}
}).catch(error => {
if (error.code === 20) {
// silence expected AbortError
return;
}
callback(new Error(error.message));
});
};
const finishRequest = (response, cacheableResponse, requestTime) => {
(
requestParameters.type === 'arrayBuffer' ? response.arrayBuffer() :
requestParameters.type === 'json' ? response.json() :
response.text()
).then(result => {
if (aborted) return;
if (cacheableResponse && requestTime) {
// The response needs to be inserted into the cache after it has completely loaded.
// Until it is fully loaded there is a chance it will be aborted. Aborting while
// reading the body can cause the cache insertion to error. We could catch this error
// in most browsers but in Firefox it seems to sometimes crash the tab. Adding
// it to the cache here avoids that error.
cachePut(request, cacheableResponse, requestTime);
}
complete = true;
callback(null, result, response.headers.get('Cache-Control'), response.headers.get('Expires'));
}).catch(err => callback(new Error(err.message)));
};
if (cacheIgnoringSearch) {
cacheGet(request, validateOrFetch);
} else {
validateOrFetch(null, null);
}
return { cancel: () => {
aborted = true;
if (!complete) controller.abort();
}};
}
function makeXMLHttpRequest(requestParameters: RequestParameters, callback: ResponseCallback<any>): Cancelable {
const xhr: XMLHttpRequest = new window.XMLHttpRequest();
xhr.open(requestParameters.method || 'GET', requestParameters.url, true);
if (requestParameters.type === 'arrayBuffer') {
xhr.responseType = 'arraybuffer';
}
for (const k in requestParameters.headers) {
xhr.setRequestHeader(k, requestParameters.headers[k]);
}
if (requestParameters.type === 'json') {
xhr.setRequestHeader('Accept', 'application/json');
}
xhr.withCredentials = requestParameters.credentials === 'include';
xhr.onerror = () => {
callback(new Error(xhr.statusText));
};
xhr.onload = () => {
if (((xhr.status >= 200 && xhr.status < 300) || xhr.status === 0) && xhr.response !== null) {
let data: mixed = xhr.response;
if (requestParameters.type === 'json') {
// We're manually parsing JSON here to get better error messages.
try {
data = JSON.parse(xhr.response);
} catch (err) {
return callback(err);
}
}
callback(null, data, xhr.getResponseHeader('Cache-Control'), xhr.getResponseHeader('Expires'));
} else {
callback(new AJAXError(xhr.statusText, xhr.status, requestParameters.url));
}
};
xhr.send(requestParameters.body);
return { cancel: () => xhr.abort() };
}
export const makeRequest = function(requestParameters: RequestParameters, callback: ResponseCallback<any>): Cancelable {
// We're trying to use the Fetch API if possible. However, in some situations we can't use it:
// - IE11 doesn't support it at all. In this case, we dispatch the request to the main thread so
// that we can get an accruate referrer header.
// - Safari exposes window.AbortController, but it doesn't work actually abort any requests in
// some versions (see https://bugs.webkit.org/show_bug.cgi?id=174980#c2)
// - Requests for resources with the file:// URI scheme don't work with the Fetch API either. In
// this case we unconditionally use XHR on the current thread since referrers don't matter.
if (!/^file:/.test(requestParameters.url)) {
if (window.fetch && window.Request && window.AbortController && window.Request.prototype.hasOwnProperty('signal')) {
return makeFetchRequest(requestParameters, callback);
}
if (isWorker() && self.worker && self.worker.actor) {
return self.worker.actor.send('getResource', requestParameters, callback);
}
}
return makeXMLHttpRequest(requestParameters, callback);
};
export const getJSON = function(requestParameters: RequestParameters, callback: ResponseCallback<Object>): Cancelable {
return makeRequest(extend(requestParameters, { type: 'json' }), callback);
};
export const getArrayBuffer = function(requestParameters: RequestParameters, callback: ResponseCallback<ArrayBuffer>): Cancelable {
return makeRequest(extend(requestParameters, { type: 'arrayBuffer' }), callback);
};
export const postData = function(requestParameters: RequestParameters, callback: ResponseCallback<string>): Cancelable {
return makeRequest(extend(requestParameters, { method: 'POST' }), callback);
};
function sameOrigin(url) {
const a: HTMLAnchorElement = window.document.createElement('a');
a.href = url;
return a.protocol === window.document.location.protocol && a.host === window.document.location.host;
}
const transparentPngUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQYV2NgAAIAAAUAAarVyFEAAAAASUVORK5CYII=';
let imageQueue, numImageRequests;
export const resetImageRequestQueue = () => {
imageQueue = [];
numImageRequests = 0;
};
resetImageRequestQueue();
export const getImage = function(requestParameters: RequestParameters, callback: Callback<HTMLImageElement>): Cancelable {
// limit concurrent image loads to help with raster sources performance on big screens
if (numImageRequests >= config.MAX_PARALLEL_IMAGE_REQUESTS) {
const queued = {
requestParameters,
callback,
cancelled: false,
cancel() { this.cancelled = true; }
};
imageQueue.push(queued);
return queued;
}
numImageRequests++;
let advanced = false;
const advanceImageRequestQueue = () => {
if (advanced) return;
advanced = true;
numImageRequests--;
assert(numImageRequests >= 0);
while (imageQueue.length && numImageRequests < config.MAX_PARALLEL_IMAGE_REQUESTS) { // eslint-disable-line
const request = imageQueue.shift();
const {requestParameters, callback, cancelled} = request;
if (!cancelled) {
request.cancel = getImage(requestParameters, callback).cancel;
}
}
};
// request the image with XHR to work around caching issues
// see https://github.com/mapbox/mapbox-gl-js/issues/1470
const request = getArrayBuffer(requestParameters, (err: ?Error, data: ?ArrayBuffer, cacheControl: ?string, expires: ?string) => {
advanceImageRequestQueue();
if (err) {
callback(err);
} else if (data) {
const img: HTMLImageElement = new window.Image();
const URL = window.URL || window.webkitURL;
img.onload = () => {
callback(null, img);
URL.revokeObjectURL(img.src);
};
img.onerror = () => callback(new Error('Could not load image. Please make sure to use a supported image type such as PNG or JPEG. Note that SVGs are not supported.'));
const blob: Blob = new window.Blob([new Uint8Array(data)], { type: 'image/png' });
(img: any).cacheControl = cacheControl;
(img: any).expires = expires;
img.src = data.byteLength ? URL.createObjectURL(blob) : transparentPngUrl;
}
});
return {
cancel: () => {
request.cancel();
advanceImageRequestQueue();
}
};
};
export const getVideo = function(urls: Array<string>, callback: Callback<HTMLVideoElement>): Cancelable {
const video: HTMLVideoElement = window.document.createElement('video');
video.muted = true;
video.onloadstart = function() {
callback(null, video);
};
for (let i = 0; i < urls.length; i++) {
const s: HTMLSourceElement = window.document.createElement('source');
if (!sameOrigin(urls[i])) {
video.crossOrigin = 'Anonymous';
}
s.src = urls[i];
video.appendChild(s);
}
return { cancel: () => {} };
};