/// import AbstractProvider, { EndpointArgument, ParseArgument, ProviderOptions, SearchArgument, SearchResult, } from './provider'; import { APIOptions, importLibrary, setOptions, } from '@googlemaps/js-api-loader'; export interface RequestResult { results: google.maps.GeocoderResult[]; status?: google.maps.GeocoderStatus; } export interface GeocodeError { code: Exclude; endpoint: 'GEOCODER_GEOCODE'; message: string; name: 'MapsRequestError'; stack: string; } // the options of @googlemaps/js-api-loader 1.x, which this provider used to // pass on as they were. they keep working with the 2.x loader export interface GoogleProviderOptions extends ProviderOptions { apiKey: string; /** Maps JavaScript API version, sent as `v` */ version?: string; language?: string; region?: string; libraries?: string[]; mapIds?: string[]; authReferrerPolicy?: string; channel?: string; client?: string; solutionChannel?: string; /** @deprecated not supported by the 2.x loader, ignored */ id?: string; /** @deprecated ignored, the loader uses the nonce of a script on the page */ nonce?: string; /** @deprecated not supported by the 2.x loader, ignored */ retries?: number; /** @deprecated ignored, the API always loads from maps.googleapis.com */ url?: string; } const IGNORED_OPTIONS = ['id', 'nonce', 'retries', 'url'] as const; function apiOptions(options: GoogleProviderOptions): APIOptions { const ignored = IGNORED_OPTIONS.filter((key) => options[key] !== undefined); if (ignored.length > 0) { console.warn( `GoogleProvider: the ${ignored.join(', ')} option(s) are not ` + 'supported anymore and are ignored', ); } const given: Record = { key: options.apiKey, v: options.version, language: options.language, region: options.region, libraries: options.libraries, mapIds: options.mapIds, authReferrerPolicy: options.authReferrerPolicy, channel: options.channel, client: options.client, solutionChannel: options.solutionChannel, }; // every key ends up in the script url, so leave out the unset ones const result: Record = {}; Object.keys(given).forEach((key) => { if (given[key] !== undefined) { result[key] = given[key]; } }); return result as APIOptions; } // Google calls window.gm_authFailure when it rejects the key, and then never // answers the geocoder. Searches wait for this too, so they fail instead of // hanging. Created once, other handlers on the page keep being called let authFailure: Promise | undefined; function whenAuthFails(): Promise { if (!authFailure) { authFailure = new Promise((_, reject) => { const page = window as unknown as { gm_authFailure?: () => void }; const previous = page.gm_authFailure; page.gm_authFailure = () => { previous?.(); reject( new Error( 'Google Maps rejected the API key, see the browser console for the reason', ), ); }; }); // only searches report it authFailure.catch(() => {}); } return authFailure; } function whenAborted(signal?: AbortSignal): Promise { return new Promise((_, reject) => { const abort = () => reject(new DOMException('The search was aborted', 'AbortError')); if (signal?.aborted) { abort(); } signal?.addEventListener('abort', abort); }); } async function loadGeocoder( options: GoogleProviderOptions, ): Promise { // the Maps API is already on the page (a script tag, or a library like // GoogleMutant). loading it a second time breaks it const maps = typeof google !== 'undefined' ? google.maps : undefined; if (maps?.Geocoder) { return new maps.Geocoder(); } // loaded with loading=async, the classes come from importLibrary if (maps?.importLibrary) { const { Geocoder } = (await maps.importLibrary( 'geocoding', )) as google.maps.GeocodingLibrary; return new Geocoder(); } setOptions(apiOptions(options)); const { Geocoder } = await importLibrary('geocoding'); return new Geocoder(); } export default class GoogleProvider extends AbstractProvider< RequestResult, google.maps.GeocoderResult > { loader: Promise | null = null; geocoder: google.maps.Geocoder | null = null; constructor(options: GoogleProviderOptions) { super(options); if (typeof window !== 'undefined') { whenAuthFails(); this.loader = loadGeocoder(options).then((geocoder) => { this.geocoder = geocoder; return geocoder; }); } } endpoint(_args: EndpointArgument): never { throw new Error('Method not implemented.'); } parse( response: ParseArgument, ): SearchResult[] { return response.data.results.map((r) => { const { lat, lng } = r.geometry.location.toJSON(); const { east, north, south, west } = r.geometry.viewport.toJSON(); return { x: lng, y: lat, label: r.formatted_address, bounds: [ [south, west], [north, east], ], raw: r, }; }); } async search( options: SearchArgument, ): Promise[]> { const geocoder = this.geocoder || (await this.loader); if (!geocoder) { throw new Error( 'GoogleMaps GeoCoder is not loaded. Are you trying to run this server side?', ); } // the callback works with every version of the Maps API. some return // nothing from geocode when a callback is given const answer = new Promise((resolve, reject) => { geocoder.geocode({ address: options.query }, (results, status) => { if (status === 'OK' || status === 'ZERO_RESULTS') { resolve({ results: results || [] }); } else { reject(new Error(`Google geocoder answered ${status}`)); } }); }); const response = await Promise.race([ answer, whenAuthFails(), whenAborted(options.signal), ]); return this.parse({ data: response }); } }