diff --git a/README.md b/README.md index 2d16a3a..1e77296 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,26 @@ function MyComponent() { } ``` +You can also pass a function that returns a token (sync or async). MapKit JS may call this throughout a session when it needs a new token — for example when a short-lived JWT expires. Prefer minting tokens on your server; never put your MapKit private key in client-side code. + +```tsx +function MyComponent() { + return ( + { + const res = await fetch('/api/mapkit-token'); + if (!res.ok) throw new Error('Failed to fetch MapKit token'); + return res.text(); + }} + > + + + ); +} +``` + +MapKit JS is initialized once per page, so all `` instances share the same authorization context — use a single token source for the whole app. + You can see all the supported parameters in Storybook (see above). ## Features diff --git a/src/components/Map.tsx b/src/components/Map.tsx index 1b9b4ed..98b0492 100644 --- a/src/components/Map.tsx +++ b/src/components/Map.tsx @@ -3,7 +3,7 @@ import React, { } from 'react'; import { useMediaQuery } from 'usehooks-ts'; import MapContext from '../context/MapContext'; -import load from '../util/loader'; +import load, { setMapKitToken } from '../util/loader'; import { ColorScheme, Distances, FeatureVisibility, LoadPriority, MapType, fromMapKitMapType, @@ -73,10 +73,21 @@ const Map = React.forwardRef(null); const exists = useRef(false); + // MapKit may re-invoke authorizationCallback mid-session (e.g. JWT expiry). + // We only need the *latest* token available for those later calls — not to + // run work when `token` changes — so a ref (synced during render) is a + // better fit than useEffect. Init still runs once below; this just keeps + // the default loader's provider current. Custom `load` owns its own auth. + const tokenRef = useRef(token); + tokenRef.current = token; + if (typeof customLoad !== 'function') { + setMapKitToken(tokenRef.current); + } + // Load the map useEffect(() => { const loadMap = typeof customLoad === 'function' ? customLoad : load; - loadMap(token).then(() => { + loadMap(tokenRef.current).then(() => { if (exists.current) return; const options = initialRegion ? { region: toMapKitCoordinateRegion(initialRegion) } diff --git a/src/components/MapProps.tsx b/src/components/MapProps.tsx index 4b80d6e..4e698a0 100644 --- a/src/components/MapProps.tsx +++ b/src/components/MapProps.tsx @@ -4,17 +4,26 @@ import { PointOfInterestCategory, FeatureVisibility, } from '../util/parameters'; +import { MapKitToken } from '../util/token'; export default interface MapProps { /** * Custom load method for MapKit JS. + * Receives the same token (or provider) as the `token` prop. + * When using a custom load, you own `mapkit.init` and authorization. */ - load?: (token: string) => Promise; + load?: (token: MapKitToken) => Promise; /** - * The token provided by MapKit JS. + * A MapKit JS JWT string, or a function that returns one (sync or async). + * + * MapKit JS may request a token throughout a session when the previous one + * expires. Use a provider function to mint short-lived tokens from your + * server — never put your MapKit private key in client-side code. + * + * @see {@link https://developer.apple.com/documentation/mapkitjs/mapkitinitializationoptions/authorizationcallback} */ - token: string; + token: MapKitToken; /** * The map’s color scheme when displaying standard or muted standard map types. diff --git a/src/index.ts b/src/index.ts index 651c1c3..51ef95b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,3 +23,6 @@ export type { MapInteractionEvent, UserLocationChangeEvent, UserLocationError, UserLocationErrorEvent, } from './events'; + +export type { MapKitToken } from './util/token'; +export { resolveMapKitToken } from './util/token'; diff --git a/src/stories/Map.stories.tsx b/src/stories/Map.stories.tsx index 81ba261..0948838 100644 --- a/src/stories/Map.stories.tsx +++ b/src/stories/Map.stories.tsx @@ -15,6 +15,7 @@ import { } from '../util/parameters'; import Marker from '../components/Marker'; import { MapInteractionEvent } from '..'; +import { resolveMapKitToken } from '../util/token'; // @ts-ignore const token = import.meta.env.STORYBOOK_MAPKIT_JS_TOKEN!; @@ -303,6 +304,34 @@ export const RegionChangeEvent = () => { ); }; +export const TokenProvider = () => { + const initialRegion: CoordinateRegion = useMemo( + () => ({ + centerLatitude: 40.7538, + centerLongitude: -73.986, + latitudeDelta: 0.03, + longitudeDelta: 0.03, + }), + [], + ); + + // Simulates a short-lived token from your backend. MapKit may call this + // again during a session when it needs a fresh JWT. + return ( + { + await new Promise((r) => { + setTimeout(r, 50); + }); + return token; + }} + initialRegion={initialRegion} + showsMapTypeControl={false} + /> + ); +}; +TokenProvider.storyName = 'Token Provider Function'; + export const CustomLoadFunction = () => { const initialRegion: CoordinateRegion = useMemo( () => ({ @@ -324,16 +353,19 @@ export const CustomLoadFunction = () => { delete window.initMapKit; window.mapkit.init({ authorizationCallback: (done) => { - done(customLoadToken); + resolveMapKitToken(customLoadToken).then(done); }, }); resolve(); }; element.src = 'https://cdn.apple-mapkit.com/mk/5.x.x/mapkit.core.js'; element.dataset.callback = 'initMapKit'; - element.dataset.initialToken = customLoadToken; element.dataset.libraries = 'map'; element.crossOrigin = 'anonymous'; + // initialToken only accepts a string JWT + if (typeof customLoadToken === 'string') { + element.dataset.initialToken = customLoadToken; + } document.head.appendChild(element); })} token={token} diff --git a/src/util/loader.ts b/src/util/loader.ts index 3c2627f..72a37e3 100644 --- a/src/util/loader.ts +++ b/src/util/loader.ts @@ -1,15 +1,34 @@ +import { MapKitToken, resolveMapKitToken } from './token'; + let loadingPromise: Promise | null = null; /** - * Loads the MapKit JS API with the given token. + * Latest token provider. MapKit may re-invoke authorizationCallback during a + * session, so this must stay up to date after the initial load. + */ +let currentToken: MapKitToken | null = null; + +/** + * Updates the token (or token provider) used by MapKit JS authorization. + * Safe to call after MapKit has already been initialized. + */ +export function setMapKitToken(token: MapKitToken): void { + currentToken = token; +} + +/** + * Loads the MapKit JS API with the given token or token provider. * * If the library is already loaded or loading, this function will not attempt - * to load it a second time. + * to load it a second time. The token provider is always updated so later + * MapKit authorization requests use the latest value. * - * @param token The MapKit JS token + * @param token The MapKit JS token, or a function that returns one * @returns A promise resolving when the library is loaded. */ -export default function load(token: string): Promise { +export default function load(token: MapKitToken): Promise { + setMapKitToken(token); + if (loadingPromise !== null) { return loadingPromise; } @@ -18,7 +37,20 @@ export default function load(token: string): Promise { const script = document.createElement('script'); script.addEventListener('load', () => { mapkit.init({ - authorizationCallback: (done) => done(token), + authorizationCallback: (done) => { + if (currentToken === null) { + // eslint-disable-next-line no-console + console.error('[mapkit-react] No MapKit token has been set'); + return; + } + resolveMapKitToken(currentToken) + .then(done) + .catch((error) => { + // MapKit's done() has no error channel + // eslint-disable-next-line no-console + console.error('[mapkit-react] Failed to obtain MapKit token', error); + }); + }, }); resolve(); diff --git a/src/util/token.ts b/src/util/token.ts new file mode 100644 index 0000000..25f344d --- /dev/null +++ b/src/util/token.ts @@ -0,0 +1,17 @@ +/** + * A MapKit JS authorization token, or a function (sync or async) that returns one. + * + * MapKit JS may call the provider throughout a session (not only at init) + * when it needs a new token. Prefer a short-lived JWT minted by your server; + * never put your MapKit private key in client-side code. + * + * @see {@link https://developer.apple.com/documentation/mapkitjs/mapkitinitializationoptions/authorizationcallback} + */ +export type MapKitToken = string | (() => string | Promise); + +/** + * Resolves a {@link MapKitToken} to a JWT string. + */ +export function resolveMapKitToken(token: MapKitToken): Promise { + return Promise.resolve(typeof token === 'function' ? token() : token); +} diff --git a/support.md b/support.md index cfbc425..2737cd7 100644 --- a/support.md +++ b/support.md @@ -7,7 +7,7 @@ | Feature | Supported | | --------------------------------------- | ------------------------------------------------------------------------------------- | | MapKitInitOptions.language | ❌ | -| MapKitInitOptions.authorizationCallback | ⚠️
You can only pass token as strings, callbacks are not supported. | +| MapKitInitOptions.authorizationCallback | ✅
Pass a JWT string or a provider function via the token prop (string \| () => string \| Promise<string>). MapKit may re-invoke the provider throughout a session. | ### Properties