@googlemaps/js-api-loader
Version:
Wrapper for the loading of Google Maps JavaScript API script in the browser
651 lines (596 loc) • 18.7 kB
text/typescript
/**
* Copyright 2019 Google LLC. All Rights Reserved.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at.
*
* Http://www.apache.org/licenses/LICENSE-2.0.
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import isEqual from "fast-deep-equal";
export const DEFAULT_ID = "__googleMapsScriptId";
// https://developers.google.com/maps/documentation/javascript/libraries#libraries-for-dynamic-library-import
export type Library =
| "core"
| "maps"
| "maps3d"
| "places"
| "geocoding"
| "routes"
| "marker"
| "geometry"
| "elevation"
| "streetView"
| "journeySharing"
| "drawing"
| "visualization";
export type Libraries = Library[];
/**
* The Google Maps JavaScript API
* [documentation](https://developers.google.com/maps/documentation/javascript/tutorial)
* is the authoritative source for [[LoaderOptions]].
/**
* Loader options
*/
export interface LoaderOptions {
/**
* See https://developers.google.com/maps/documentation/javascript/get-api-key.
*/
apiKey: string;
/**
* @deprecated See https://developers.google.com/maps/premium/overview.
*/
channel?: string;
/**
* @deprecated See https://developers.google.com/maps/premium/overview, use `apiKey` instead.
*/
client?: string;
/**
* In your application you can specify release channels or version numbers:
*
* The weekly version is specified with `version=weekly`. This version is
* updated once per week, and is the most current.
*
* ```
* const loader = Loader({apiKey, version: 'weekly'});
* ```
*
* The quarterly version is specified with `version=quarterly`. This version
* is updated once per quarter, and is the most predictable.
*
* ```
* const loader = Loader({apiKey, version: 'quarterly'});
* ```
*
* The version number is specified with `version=n.nn`. You can choose
* `version=3.40`, `version=3.39`, or `version=3.38`. Version numbers are
* updated once per quarter.
*
* ```
* const loader = Loader({apiKey, version: '3.40'});
* ```
*
* If you do not explicitly specify a version, you will receive the
* weekly version by default.
*/
version?: string;
/**
* The id of the script tag. Before adding a new script, the Loader will check for an existing one.
*/
id?: string;
/**
* When loading the Maps JavaScript API via the URL you may optionally load
* additional libraries through use of the libraries URL parameter. Libraries
* are modules of code that provide additional functionality to the main Maps
* JavaScript API but are not loaded unless you specifically request them.
*
* ```
* const loader = Loader({
* apiKey,
* libraries: ['drawing', 'geometry', 'places', 'visualization'],
* });
* ```
*
* Set the [list of libraries](https://developers.google.com/maps/documentation/javascript/libraries) for more options.
*/
libraries?: Libraries;
/**
* By default, the Maps JavaScript API uses the user's preferred language
* setting as specified in the browser, when displaying textual information
* such as the names for controls, copyright notices, driving directions and
* labels on maps. In most cases, it's preferable to respect the browser
* setting. However, if you want the Maps JavaScript API to ignore the
* browser's language setting, you can force it to display information in a
* particular language when loading the Maps JavaScript API code.
*
* For example, the following example localizes the language to Japan:
*
* ```
* const loader = Loader({apiKey, language: 'ja', region: 'JP'});
* ```
*
* See the [list of supported
* languages](https://developers.google.com/maps/faq#languagesupport). Note
* that new languages are added often, so this list may not be exhaustive.
*
*/
language?: string;
/**
* When you load the Maps JavaScript API from maps.googleapis.com it applies a
* default bias for application behavior towards the United States. If you
* want to alter your application to serve different map tiles or bias the
* application (such as biasing geocoding results towards the region), you can
* override this default behavior by adding a region parameter when loading
* the Maps JavaScript API code.
*
* The region parameter accepts Unicode region subtag identifiers which
* (generally) have a one-to-one mapping to country code Top-Level Domains
* (ccTLDs). Most Unicode region identifiers are identical to ISO 3166-1
* codes, with some notable exceptions. For example, Great Britain's ccTLD is
* "uk" (corresponding to the domain .co.uk) while its region identifier is
* "GB."
*
* For example, the following example localizes the map to the United Kingdom:
*
* ```
* const loader = Loader({apiKey, region: 'GB'});
* ```
*/
region?: string;
/**
* @deprecated Passing `mapIds` is no longer required in the script tag.
*/
mapIds?: string[];
/**
* Use a custom url and path to load the Google Maps API script.
*/
url?: string;
/**
* Use a cryptographic nonce attribute.
*/
nonce?: string;
/**
* The number of script load retries.
*/
retries?: number;
/**
* Maps JS customers can configure HTTP Referrer Restrictions in the Cloud
* Console to limit which URLs are allowed to use a particular API Key. By
* default, these restrictions can be configured to allow only certain paths
* to use an API Key. If any URL on the same domain or origin may use the API
* Key, you can set `auth_referrer_policy=origin` to limit the amount of data
* sent when authorizing requests from the Maps JavaScript API. This is
* available starting in version 3.46. When this parameter is specified and
* HTTP Referrer Restrictions are enabled on Cloud Console, Maps JavaScript
* API will only be able to load if there is an HTTP Referrer Restriction that
* matches the current website's domain without a path specified.
*/
authReferrerPolicy?: "origin";
}
/**
* The status of the [[Loader]].
*/
export enum LoaderStatus {
INITIALIZED,
LOADING,
SUCCESS,
FAILURE,
}
/**
* [[Loader]] makes it easier to add Google Maps JavaScript API to your application
* dynamically using
* [Promises](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise).
* It works by dynamically creating and appending a script node to the the
* document head and wrapping the callback function so as to return a promise.
*
* ```
* const loader = new Loader({
* apiKey: "",
* version: "weekly",
* libraries: ["places"]
* });
*
* loader.load().then((google) => {
* const map = new google.maps.Map(...)
* })
* ```
*/
export class Loader {
private static instance: Loader;
/**
* See [[LoaderOptions.version]]
*/
public readonly version: string;
/**
* See [[LoaderOptions.apiKey]]
*/
public readonly apiKey: string;
/**
* See [[LoaderOptions.channel]]
*/
public readonly channel: string;
/**
* See [[LoaderOptions.client]]
*/
public readonly client: string;
/**
* See [[LoaderOptions.id]]
*/
public readonly id: string;
/**
* See [[LoaderOptions.libraries]]
*/
public readonly libraries: Libraries;
/**
* See [[LoaderOptions.language]]
*/
public readonly language: string;
/**
* See [[LoaderOptions.region]]
*/
public readonly region: string;
/**
* See [[LoaderOptions.mapIds]]
*/
public readonly mapIds: string[];
/**
* See [[LoaderOptions.nonce]]
*/
public readonly nonce: string | null;
/**
* See [[LoaderOptions.retries]]
*/
public readonly retries: number;
/**
* See [[LoaderOptions.url]]
*/
public readonly url: string;
/**
* See [[LoaderOptions.authReferrerPolicy]]
*/
public readonly authReferrerPolicy: "origin";
private callbacks: ((e: ErrorEvent) => void)[] = [];
private done = false;
private loading = false;
private onerrorEvent: ErrorEvent;
private errors: ErrorEvent[] = [];
/**
* Creates an instance of Loader using [[LoaderOptions]]. No defaults are set
* using this library, instead the defaults are set by the Google Maps
* JavaScript API server.
*
* ```
* const loader = Loader({apiKey, version: 'weekly', libraries: ['places']});
* ```
*/
constructor({
apiKey,
authReferrerPolicy,
channel,
client,
id = DEFAULT_ID,
language,
libraries = [],
mapIds,
nonce,
region,
retries = 3,
url = "https://maps.googleapis.com/maps/api/js",
version,
}: LoaderOptions) {
this.apiKey = apiKey;
this.authReferrerPolicy = authReferrerPolicy;
this.channel = channel;
this.client = client;
this.id = id || DEFAULT_ID; // Do not allow empty string
this.language = language;
this.libraries = libraries;
this.mapIds = mapIds;
this.nonce = nonce;
this.region = region;
this.retries = retries;
this.url = url;
this.version = version;
if (Loader.instance) {
if (!isEqual(this.options, Loader.instance.options)) {
throw new Error(
`Loader must not be called again with different options. ${JSON.stringify(
this.options
)} !== ${JSON.stringify(Loader.instance.options)}`
);
}
return Loader.instance;
}
Loader.instance = this;
}
public get options(): LoaderOptions {
return {
version: this.version,
apiKey: this.apiKey,
channel: this.channel,
client: this.client,
id: this.id,
libraries: this.libraries,
language: this.language,
region: this.region,
mapIds: this.mapIds,
nonce: this.nonce,
url: this.url,
authReferrerPolicy: this.authReferrerPolicy,
};
}
public get status(): LoaderStatus {
if (this.errors.length) {
return LoaderStatus.FAILURE;
}
if (this.done) {
return LoaderStatus.SUCCESS;
}
if (this.loading) {
return LoaderStatus.LOADING;
}
return LoaderStatus.INITIALIZED;
}
private get failed(): boolean {
return this.done && !this.loading && this.errors.length >= this.retries + 1;
}
/**
* CreateUrl returns the Google Maps JavaScript API script url given the [[LoaderOptions]].
*
* @ignore
* @deprecated
*/
public createUrl(): string {
let url = this.url;
url += `?callback=__googleMapsCallback&loading=async`;
if (this.apiKey) {
url += `&key=${this.apiKey}`;
}
if (this.channel) {
url += `&channel=${this.channel}`;
}
if (this.client) {
url += `&client=${this.client}`;
}
if (this.libraries.length > 0) {
url += `&libraries=${this.libraries.join(",")}`;
}
if (this.language) {
url += `&language=${this.language}`;
}
if (this.region) {
url += `®ion=${this.region}`;
}
if (this.version) {
url += `&v=${this.version}`;
}
if (this.mapIds) {
url += `&map_ids=${this.mapIds.join(",")}`;
}
if (this.authReferrerPolicy) {
url += `&auth_referrer_policy=${this.authReferrerPolicy}`;
}
return url;
}
public deleteScript(): void {
const script = document.getElementById(this.id);
if (script) {
script.remove();
}
}
/**
* Load the Google Maps JavaScript API script and return a Promise.
* @deprecated, use importLibrary() instead.
*/
public load(): Promise<typeof google> {
return this.loadPromise();
}
/**
* Load the Google Maps JavaScript API script and return a Promise.
*
* @ignore
* @deprecated, use importLibrary() instead.
*/
public loadPromise(): Promise<typeof google> {
return new Promise((resolve, reject) => {
this.loadCallback((err: ErrorEvent) => {
if (!err) {
resolve(window.google);
} else {
reject(err.error);
}
});
});
}
/**
* See https://developers.google.com/maps/documentation/javascript/reference/top-level#google.maps.importLibrary
*/
public importLibrary(name: "core"): Promise<google.maps.CoreLibrary>;
public importLibrary(name: "maps"): Promise<google.maps.MapsLibrary>;
public importLibrary(name: "maps3d"): Promise<google.maps.Maps3DLibrary>;
public importLibrary(name: "places"): Promise<google.maps.PlacesLibrary>;
public importLibrary(
name: "geocoding"
): Promise<google.maps.GeocodingLibrary>;
public importLibrary(name: "routes"): Promise<google.maps.RoutesLibrary>;
public importLibrary(name: "marker"): Promise<google.maps.MarkerLibrary>;
public importLibrary(name: "geometry"): Promise<google.maps.GeometryLibrary>;
public importLibrary(
name: "elevation"
): Promise<google.maps.ElevationLibrary>;
public importLibrary(
name: "streetView"
): Promise<google.maps.StreetViewLibrary>;
public importLibrary(
name: "journeySharing"
): Promise<google.maps.JourneySharingLibrary>;
public importLibrary(name: "drawing"): Promise<google.maps.DrawingLibrary>;
public importLibrary(
name: "visualization"
): Promise<google.maps.VisualizationLibrary>;
public importLibrary(name: Library): Promise<unknown>;
public importLibrary(name: Library): Promise<unknown> {
this.execute();
return google.maps.importLibrary(name);
}
/**
* Load the Google Maps JavaScript API script with a callback.
* @deprecated, use importLibrary() instead.
*/
public loadCallback(fn: (e: ErrorEvent) => void): void {
this.callbacks.push(fn);
this.execute();
}
/**
* Set the script on document.
*/
private setScript(): void {
if (document.getElementById(this.id)) {
// TODO wrap onerror callback for cases where the script was loaded elsewhere
this.callback();
return;
}
const params = {
key: this.apiKey,
channel: this.channel,
client: this.client,
libraries: this.libraries.length && this.libraries,
v: this.version,
mapIds: this.mapIds,
language: this.language,
region: this.region,
authReferrerPolicy: this.authReferrerPolicy,
};
// keep the URL minimal:
Object.keys(params).forEach(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(key) => !(params as any)[key] && delete (params as any)[key]
);
if (!window?.google?.maps?.importLibrary) {
// tweaked copy of https://developers.google.com/maps/documentation/javascript/load-maps-js-api#dynamic-library-import
// which also sets the base url, the id, and the nonce
/* eslint-disable */
((g) => {
// @ts-ignore
let h,
a,
k,
p = "The Google Maps JavaScript API",
c = "google",
l = "importLibrary",
q = "__ib__",
m = document,
b = window;
// @ts-ignore
b = b[c] || (b[c] = {});
// @ts-ignore
const d = b.maps || (b.maps = {}),
r = new Set(),
e = new URLSearchParams(),
u = () =>
// @ts-ignore
h || (h = new Promise(async (f, n) => {
await (a = m.createElement("script"));
a.id = this.id;
e.set("libraries", [...r] + "");
// @ts-ignore
for (k in g) e.set(k.replace(/[A-Z]/g, (t) => "_" + t[0].toLowerCase()), g[k]);
e.set("callback", c + ".maps." + q);
a.src = this.url + `?` + e;
d[q] = f;
a.onerror = () => (h = n(Error(p + " could not load.")));
// @ts-ignore
a.nonce = this.nonce || m.querySelector("script[nonce]")?.nonce || "";
m.head.append(a);
}));
// @ts-ignore
d[l] ? console.warn(p + " only loads once. Ignoring:", g) : (d[l] = (f, ...n) => r.add(f) && u().then(() => d[l](f, ...n)));
})(params);
/* eslint-enable */
}
// While most libraries populate the global namespace when loaded via bootstrap params,
// this is not the case for "marker" when used with the inline bootstrap loader
// (and maybe others in the future). So ensure there is an importLibrary for each:
const libraryPromises = this.libraries.map((library) =>
this.importLibrary(library)
);
// ensure at least one library, to kick off loading...
if (!libraryPromises.length) {
libraryPromises.push(this.importLibrary("core"));
}
Promise.all(libraryPromises).then(
() => this.callback(),
(error) => {
const event = new ErrorEvent("error", { error }); // for backwards compat
this.loadErrorCallback(event);
}
);
}
/**
* Reset the loader state.
*/
private reset(): void {
this.deleteScript();
this.done = false;
this.loading = false;
this.errors = [];
this.onerrorEvent = null;
}
private resetIfRetryingFailed(): void {
if (this.failed) {
this.reset();
}
}
private loadErrorCallback(e: ErrorEvent): void {
this.errors.push(e);
if (this.errors.length <= this.retries) {
const delay = this.errors.length * 2 ** this.errors.length;
console.error(
`Failed to load Google Maps script, retrying in ${delay} ms.`
);
setTimeout(() => {
this.deleteScript();
this.setScript();
}, delay);
} else {
this.onerrorEvent = e;
this.callback();
}
}
private callback(): void {
this.done = true;
this.loading = false;
this.callbacks.forEach((cb) => {
cb(this.onerrorEvent);
});
this.callbacks = [];
}
private execute(): void {
this.resetIfRetryingFailed();
if (this.loading) {
// do nothing but wait
return;
}
if (this.done) {
this.callback();
} else {
// short circuit and warn if google.maps is already loaded
if (window.google && window.google.maps && window.google.maps.version) {
console.warn(
"Google Maps already loaded outside @googlemaps/js-api-loader. " +
"This may result in undesirable behavior as options and script parameters may not match."
);
this.callback();
return;
}
this.loading = true;
this.setScript();
}
}
}