diff --git a/src/module.ts b/src/module.ts index 73f5668f..b075f491 100644 --- a/src/module.ts +++ b/src/module.ts @@ -3,6 +3,7 @@ import { addComponentsDir, addImports, addPluginTemplate, + addServerHandler, addTemplate, createResolver, defineNuxtModule, @@ -78,6 +79,22 @@ export interface ModuleOptions { */ integrity?: boolean | 'sha256' | 'sha384' | 'sha512' } + /** + * Google Static Maps proxy configuration. + * Proxies static map images through your server to fix CORS issues and enable caching. + */ + googleStaticMapsProxy?: { + /** + * Enable proxying Google Static Maps through your own origin. + * @default false + */ + enabled?: boolean + /** + * Cache duration for static map images in seconds. + * @default 3600 (1 hour) + */ + cacheMaxAge?: number + } /** * Whether the module is enabled. * @@ -115,6 +132,10 @@ export default defineNuxtModule({ timeout: 15_000, // Configures the maximum time (in milliseconds) allowed for each fetch attempt. }, }, + googleStaticMapsProxy: { + enabled: false, + cacheMaxAge: 3600, + }, enabled: true, debug: false, }, @@ -136,11 +157,21 @@ export default defineNuxtModule({ if (unheadVersion?.startsWith('1')) { logger.error(`Nuxt Scripts requires Unhead >= 2, you are using v${unheadVersion}. Please run \`nuxi upgrade --clean\` to upgrade...`) } - nuxt.options.runtimeConfig['nuxt-scripts'] = { version } + nuxt.options.runtimeConfig['nuxt-scripts'] = { + version, + // Private proxy config with API key (server-side only) + googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled + ? { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey } + : undefined, + } nuxt.options.runtimeConfig.public['nuxt-scripts'] = { // expose for devtools version: nuxt.options.dev ? version : undefined, defaultScriptOptions: config.defaultScriptOptions, + // Only expose enabled and cacheMaxAge to client, not apiKey + googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled + ? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.cacheMaxAge } + : undefined, } // Merge registry config with existing runtimeConfig.public.scripts for proper env var resolution @@ -250,6 +281,14 @@ export default defineNuxtModule({ }) }) + // Add Google Static Maps proxy handler if enabled + if (config.googleStaticMapsProxy?.enabled) { + addServerHandler({ + route: '/_scripts/google-static-maps-proxy', + handler: await resolvePath('./runtime/server/google-static-maps-proxy'), + }) + } + if (nuxt.options.dev) setupDevToolsUI(config, resolvePath) }, diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue index 3630b314..9c0838bf 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue @@ -6,7 +6,7 @@ import { withQuery } from 'ufo' import type { QueryObject } from 'ufo' import { defu } from 'defu' import { hash } from 'ohash' -import { useHead } from 'nuxt/app' +import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app' import type { ElementScriptTrigger } from '#nuxt-scripts/types' import { scriptRuntimeConfig } from '#nuxt-scripts/utils' import { useScriptTriggerElement } from '#nuxt-scripts/composables/useScriptTriggerElement' @@ -104,6 +104,17 @@ const props = withDefaults(defineProps<{ * Extra Markers to add to the map. */ markers?: (`${string},${string}` | google.maps.marker.AdvancedMarkerElementOptions)[] + /** + * Map IDs for light and dark color modes. + * When provided, the map will automatically switch styles based on color mode. + * Requires @nuxtjs/color-mode or manual colorMode prop. + */ + mapIds?: { light?: string, dark?: string } + /** + * Manual color mode control. When provided, overrides auto-detection from @nuxtjs/color-mode. + * Accepts 'light', 'dark', or a reactive ref. + */ + colorMode?: 'light' | 'dark' }>(), { // @ts-expect-error untyped trigger: ['mouseenter', 'mouseover', 'mousedown'], @@ -119,6 +130,26 @@ const emits = defineEmits<{ }>() const apiKey = props.apiKey || scriptRuntimeConfig('googleMaps')?.apiKey +const runtimeConfig = useRuntimeConfig() +const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy + +// Color mode support - try to auto-detect from @nuxtjs/color-mode +const nuxtApp = tryUseNuxtApp() +const nuxtColorMode = nuxtApp?.$colorMode as { value: string } | undefined + +const currentColorMode = computed(() => { + if (props.colorMode) + return props.colorMode + if (nuxtColorMode?.value) + return nuxtColorMode.value === 'dark' ? 'dark' : 'light' + return 'light' +}) + +const currentMapId = computed(() => { + if (!props.mapIds) + return props.mapOptions?.mapId + return props.mapIds[currentColorMode.value] || props.mapIds.light || props.mapOptions?.mapId +}) const mapsApi = ref() @@ -144,10 +175,10 @@ const { load, status, onLoaded } = useScriptGoogleMaps({ }) const options = computed(() => { - return defu({ center: centerOverride.value }, props.mapOptions, { + const mapId = props.mapOptions?.styles ? undefined : (currentMapId.value || 'map') + return defu({ center: centerOverride.value, mapId }, props.mapOptions, { center: props.center, zoom: 15, - mapId: props.mapOptions?.styles ? undefined : 'map', }) }) const ready = ref(false) @@ -266,8 +297,13 @@ function importLibrary(key: string): Promise { } }, { immediate: true }) }) - libraries.set(key, p) - return p as any as Promise + // Clear cache on failure to allow retry + const cached = Promise.resolve(p).catch((err) => { + libraries.delete(key) + throw err + }) + libraries.set(key, cached) + return cached as Promise } const googleMaps = { @@ -380,7 +416,7 @@ onMounted(() => { }) }) -if (import.meta.server) { +if (import.meta.server && !proxyConfig?.enabled) { useHead({ link: [ { @@ -419,9 +455,11 @@ const placeholder = computed(() => { center, }, { size: `${props.width}x${props.height}`, - key: apiKey, + // Only include API key if not using proxy (proxy injects it server-side) + key: proxyConfig?.enabled ? undefined : apiKey, scale: 2, // we assume a high DPI to avoid hydration issues style: props.mapOptions?.styles ? transformMapStyles(props.mapOptions.styles) : undefined, + map_id: currentMapId.value, markers: [ ...(props.markers || []), props.centerMarker && center, @@ -438,7 +476,12 @@ const placeholder = computed(() => { }) .join('|'), }) - return withQuery('https://maps.googleapis.com/maps/api/staticmap', placeholderOptions as QueryObject) + + const baseUrl = proxyConfig?.enabled + ? '/_scripts/google-static-maps-proxy' + : 'https://maps.googleapis.com/maps/api/staticmap' + + return withQuery(baseUrl, placeholderOptions as QueryObject) }) const placeholderAttrs = computed(() => { diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue index d4725bb0..c21b1167 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue @@ -3,13 +3,28 @@ @@ -26,19 +41,19 @@ const markerClustererEvents = [ ] as const const emit = defineEmits<{ - (event: typeof markerClustererEvents[number], payload: MarkerClusterer): void + (event: typeof markerClustererEvents[number], payload: MarkerClustererInstance): void }>() const mapContext = inject(MAP_INJECTION_KEY, undefined) -const markerClusterer = shallowRef(undefined) +const markerClusterer = shallowRef(undefined) whenever(() => mapContext?.map.value, async (map) => { const { MarkerClusterer } = await import('@googlemaps/markerclusterer') markerClusterer.value = new MarkerClusterer({ map, ...props.options, - }) + } as any) as MarkerClustererInstance setupMarkerClustererEventListeners(markerClusterer.value) }, { @@ -76,9 +91,9 @@ provide( }, ) -function setupMarkerClustererEventListeners(markerClusterer: MarkerClusterer) { +function setupMarkerClustererEventListeners(clusterer: MarkerClustererInstance) { markerClustererEvents.forEach((event) => { - markerClusterer.addListener(event, () => emit(event, markerClusterer)) + clusterer.addListener(event, () => emit(event, clusterer)) }) } diff --git a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue index 0ef56248..be1216cf 100644 --- a/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue +++ b/src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue @@ -2,7 +2,7 @@ diff --git a/src/runtime/server/google-static-maps-proxy.ts b/src/runtime/server/google-static-maps-proxy.ts new file mode 100644 index 00000000..9995f8f4 --- /dev/null +++ b/src/runtime/server/google-static-maps-proxy.ts @@ -0,0 +1,67 @@ +import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3' +import { $fetch } from 'ofetch' +import { withQuery } from 'ufo' +import { useRuntimeConfig } from '#imports' + +export default defineEventHandler(async (event) => { + const runtimeConfig = useRuntimeConfig() + const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy + const privateConfig = (runtimeConfig['nuxt-scripts'] as any)?.googleStaticMapsProxy + + if (!publicConfig?.enabled) { + throw createError({ + statusCode: 404, + statusMessage: 'Google Static Maps proxy is not enabled', + }) + } + + // Get API key from private config (server-side only, not exposed to client) + const apiKey = privateConfig?.apiKey + if (!apiKey) { + throw createError({ + statusCode: 500, + statusMessage: 'Google Maps API key not configured for proxy', + }) + } + + // Validate referer to prevent external abuse + const referer = getHeader(event, 'referer') + const host = getHeader(event, 'host') + if (referer && host) { + const refererUrl = new URL(referer).host + if (refererUrl !== host) { + throw createError({ + statusCode: 403, + statusMessage: 'Invalid referer', + }) + } + } + + const query = getQuery(event) + + // Remove any client-provided key and use server-side key + const { key: _clientKey, ...safeQuery } = query + + const googleMapsUrl = withQuery('https://maps.googleapis.com/maps/api/staticmap', { + ...safeQuery, + key: apiKey, + }) + + const response = await $fetch.raw(googleMapsUrl, { + headers: { + 'User-Agent': 'Nuxt Scripts Google Static Maps Proxy', + }, + }).catch((error: any) => { + throw createError({ + statusCode: error.statusCode || 500, + statusMessage: error.statusMessage || 'Failed to fetch static map', + }) + }) + + const cacheMaxAge = publicConfig.cacheMaxAge || 3600 + setHeader(event, 'Content-Type', response.headers.get('content-type') || 'image/png') + setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`) + setHeader(event, 'Vary', 'Accept-Encoding') + + return response._data +})