diff --git a/package.json b/package.json index 8f0078cb..2121e666 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "docs": "typedoc src/index.ts && cp -r dist docs/dist && cp -r examples docs/examples", "format": "eslint . --fix", "lint": "eslint .", - "prepare": "rm -rf dist && rollup -c", + "prepack": "rm -rf dist && rollup -c", "test": "jest src/*", "test:e2e": "jest e2e/*" }, diff --git a/src/2.0/2.0.md b/src/2.0/2.0.md new file mode 100644 index 00000000..ff7ec405 --- /dev/null +++ b/src/2.0/2.0.md @@ -0,0 +1,42 @@ +# Proposed 2.0 API + +The proposed API consists of just two main functions: `setOptions` to +configure the options and `importLibrary` to asynchronously load a library. +Both are exported as functions or with the default-export. + +```ts +// using the default export +import ApiLoader from "@googlemaps/js-api-loader"; + +// initial configuration +ApiLoader.setOptions({ apiKey: "...", version: "weekly" }); + +// loading a library +const { Map } = await ApiLoader.importLibrary("maps"); +``` + +```ts +// alternative, using named exports +import { setOptions, importLibrary } from "@googlemaps/js-api-loader"; + +const { Map } = await importLibrary("maps"); +``` + +## Internal Behavior + +- the ApiLoader doesn't do anything (except for storing the options) until + the `importLibrary` function is called for the first time. This allows + users to configure the loader in a central place of their application + even if the maps API isn't used on most pages. + +- Once the importLibrary function is called, the options are frozen and + attempts to modify them will get ignored + + - the first call to importLibrary initiates the bootstrapping, once the + maps API is loaded, importLibrary will directly forward to the + `google.maps.importLibrary` function. + +- if an attempt to load the API fails, the loader will resolve all pending + importLibrary promises with an error and will retry loading with the next + importLibrary call. This allows users to implement their own handling of + unstable network conditions and the like diff --git a/src/2.0/index.ts b/src/2.0/index.ts new file mode 100644 index 00000000..5f866d84 --- /dev/null +++ b/src/2.0/index.ts @@ -0,0 +1,82 @@ +import { APILoadingError, type APIOptions, bootstrapLoader } from "./loader"; + +// fixme: remove the second importLibrary signature and ApiLibraryMap interface +// once proper typings are implemented in @types/google.maps +// (https://github.com/googlemaps/js-types/issues/95) + +interface APILibraryMap { + core: google.maps.CoreLibrary; + drawing: google.maps.DrawingLibrary; + elevation: google.maps.ElevationLibrary; + geocoding: google.maps.GeocodingLibrary; + geometry: google.maps.GeometryLibrary; + journeySharing: google.maps.JourneySharingLibrary; + maps: google.maps.MapsLibrary; + maps3d: google.maps.Maps3DLibrary; + marker: google.maps.MarkerLibrary; + places: google.maps.PlacesLibrary; + routes: google.maps.RoutesLibrary; + streetView: google.maps.StreetViewLibrary; + visualization: google.maps.VisualizationLibrary; +} + +type APILibraryName = keyof APILibraryMap; + +let isBootrapped_ = false; +let options_: APIOptions = {}; +const libraries_: Partial = {}; + +/** + * Sets the options for the Maps JavaScript API. + * Has to be called before any library is loaded for the first time. + * Will throw an error after a library has been loaded for the first time. + */ +export function setOptions(options: APIOptions) { + if (isBootrapped_) { + return; + } + + options_ = options; +} + +/** + * Import the specified library. + */ +export async function importLibrary( + ...p: Parameters +): ReturnType; + +export async function importLibrary< + TLibraryName extends APILibraryName, + TLibrary extends APILibraryMap[TLibraryName], +>(libraryName: TLibraryName): Promise { + if (!isBootrapped_) { + bootstrapLoader(options_); + isBootrapped_ = true; + } + + if (!libraries_[libraryName]) { + try { + const library = (await google.maps.importLibrary( + libraryName + )) as TLibrary; + libraries_[libraryName] = library; + } catch (error) { + if (error instanceof APILoadingError) { + isBootrapped_ = false; + throw new Error("The Google Maps JavaScript API failed to load."); + } + + throw error; + } + } + + return libraries_[libraryName] as TLibrary; +} + +const api = { + setOptions, + importLibrary, +}; + +export default api; diff --git a/src/2.0/loader.ts b/src/2.0/loader.ts new file mode 100644 index 00000000..0a56b3e2 --- /dev/null +++ b/src/2.0/loader.ts @@ -0,0 +1,91 @@ +/* + * NOTE: this is functionally equivalent to (and originally copied from) the + * official dynamic library import script: + * https://developers.google.com/maps/documentation/javascript/load-maps-js-api + * + * Formatting etc: + * - removed IIFE parameters + * - formatted code, inlined/renamed variables and functions + * - fixed typescript compatibility + * - slightly restructured + * Functional Changes: + * - added support for TrustedTypes + * - add APILoadingError + */ + +export type APIOptions = { + key?: string; + v?: string; + libraries?: string | string[]; + language?: string; + region?: string; + authReferrerPolicy?: string; + mapIds?: string | string[]; + channel?: string; + solutionChannel?: string; +}; + +export class APILoadingError extends Error {} + +/** + * Creates the bootstrap function for the API loader. The bootstrap function is + * an initial version of `google.maps.importLibrary` that loads the actual JS + * which will then replace the bootstrap-function with the actual implementation. + */ +export function bootstrapLoader(options: APIOptions) { + if (google.maps.importLibrary) + throw new Error("bootstrapLoader can only be called once"); + + let apiLoadedPromise: Promise; + + // @ts-ignore + if (!window.google) window.google = {}; + // @ts-ignore + if (!window.google.maps) window.google.maps = {}; + + const libraries = new Set(); + const urlParameters = new URLSearchParams(); + + const startLoading = () => { + if (!apiLoadedPromise) { + apiLoadedPromise = new Promise((resolve, reject) => { + urlParameters.set("libraries", [...libraries] + ""); + for (const [name, value] of Object.entries(options)) { + urlParameters.set( + name.replace(/[A-Z]/g, (t) => "_" + t[0].toLowerCase()), + value as string + ); + } + urlParameters.set("callback", `google.maps.__ib__`); + + const scriptEl = document.createElement("script"); + scriptEl.src = `https://maps.googleapis.com/maps/api/js?${urlParameters.toString()}`; + + scriptEl.onerror = () => { + reject(new APILoadingError()); + }; + + const nonceScript = + document.querySelector("script[nonce]"); + scriptEl.nonce = nonceScript?.nonce || ""; + + // @ts-ignore + google.maps["__ib__"] = resolve; + + document.head.append(scriptEl); + }); + } + + return apiLoadedPromise; + }; + + // create the intermediate importLibrary function that loads the API and calls + // the real importLibrary function. + google.maps.importLibrary = async (library: string) => { + libraries.add(library); + + await startLoading(); + + return google.maps.importLibrary(library); + }; +}