Skip to content

Commit 2ebed54

Browse files
harlan-zwclaude
andcommitted
fix(google-maps): static maps proxy, color mode, and bug fixes
- Add googleStaticMapsProxy config for CORS fixes and caching (#380, #83) - API key stored server-side only (not exposed to client) - Referer validation to prevent external abuse - Add mapIds prop for light/dark color mode support (#539) - Fix MarkerClusterer optional peer dep with inline types (#540) - Add PinElement cleanup on unmount - Fix importLibrary cache to retry on failure Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent f713a1a commit 2ebed54

File tree

5 files changed

+188
-17
lines changed

5 files changed

+188
-17
lines changed

src/module.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
addComponentsDir,
44
addImports,
55
addPluginTemplate,
6+
addServerHandler,
67
addTemplate,
78
createResolver,
89
defineNuxtModule,
@@ -78,6 +79,22 @@ export interface ModuleOptions {
7879
*/
7980
integrity?: boolean | 'sha256' | 'sha384' | 'sha512'
8081
}
82+
/**
83+
* Google Static Maps proxy configuration.
84+
* Proxies static map images through your server to fix CORS issues and enable caching.
85+
*/
86+
googleStaticMapsProxy?: {
87+
/**
88+
* Enable proxying Google Static Maps through your own origin.
89+
* @default false
90+
*/
91+
enabled?: boolean
92+
/**
93+
* Cache duration for static map images in seconds.
94+
* @default 3600 (1 hour)
95+
*/
96+
cacheMaxAge?: number
97+
}
8198
/**
8299
* Whether the module is enabled.
83100
*
@@ -115,6 +132,10 @@ export default defineNuxtModule<ModuleOptions>({
115132
timeout: 15_000, // Configures the maximum time (in milliseconds) allowed for each fetch attempt.
116133
},
117134
},
135+
googleStaticMapsProxy: {
136+
enabled: false,
137+
cacheMaxAge: 3600,
138+
},
118139
enabled: true,
119140
debug: false,
120141
},
@@ -136,11 +157,21 @@ export default defineNuxtModule<ModuleOptions>({
136157
if (unheadVersion?.startsWith('1')) {
137158
logger.error(`Nuxt Scripts requires Unhead >= 2, you are using v${unheadVersion}. Please run \`nuxi upgrade --clean\` to upgrade...`)
138159
}
139-
nuxt.options.runtimeConfig['nuxt-scripts'] = { version }
160+
nuxt.options.runtimeConfig['nuxt-scripts'] = {
161+
version,
162+
// Private proxy config with API key (server-side only)
163+
googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled
164+
? { apiKey: (nuxt.options.runtimeConfig.public.scripts as any)?.googleMaps?.apiKey }
165+
: undefined,
166+
}
140167
nuxt.options.runtimeConfig.public['nuxt-scripts'] = {
141168
// expose for devtools
142169
version: nuxt.options.dev ? version : undefined,
143170
defaultScriptOptions: config.defaultScriptOptions,
171+
// Only expose enabled and cacheMaxAge to client, not apiKey
172+
googleStaticMapsProxy: config.googleStaticMapsProxy?.enabled
173+
? { enabled: true, cacheMaxAge: config.googleStaticMapsProxy.cacheMaxAge }
174+
: undefined,
144175
}
145176

146177
// Merge registry config with existing runtimeConfig.public.scripts for proper env var resolution
@@ -250,6 +281,14 @@ export default defineNuxtModule<ModuleOptions>({
250281
})
251282
})
252283

284+
// Add Google Static Maps proxy handler if enabled
285+
if (config.googleStaticMapsProxy?.enabled) {
286+
addServerHandler({
287+
route: '/_scripts/google-static-maps-proxy',
288+
handler: await resolvePath('./runtime/server/google-static-maps-proxy'),
289+
})
290+
}
291+
253292
if (nuxt.options.dev)
254293
setupDevToolsUI(config, resolvePath)
255294
},

src/runtime/components/GoogleMaps/ScriptGoogleMaps.vue

Lines changed: 50 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { withQuery } from 'ufo'
66
import type { QueryObject } from 'ufo'
77
import { defu } from 'defu'
88
import { hash } from 'ohash'
9-
import { useHead } from 'nuxt/app'
9+
import { tryUseNuxtApp, useHead, useRuntimeConfig } from 'nuxt/app'
1010
import type { ElementScriptTrigger } from '#nuxt-scripts/types'
1111
import { scriptRuntimeConfig } from '#nuxt-scripts/utils'
1212
import { useScriptTriggerElement } from '#nuxt-scripts/composables/useScriptTriggerElement'
@@ -104,6 +104,17 @@ const props = withDefaults(defineProps<{
104104
* Extra Markers to add to the map.
105105
*/
106106
markers?: (`${string},${string}` | google.maps.marker.AdvancedMarkerElementOptions)[]
107+
/**
108+
* Map IDs for light and dark color modes.
109+
* When provided, the map will automatically switch styles based on color mode.
110+
* Requires @nuxtjs/color-mode or manual colorMode prop.
111+
*/
112+
mapIds?: { light?: string, dark?: string }
113+
/**
114+
* Manual color mode control. When provided, overrides auto-detection from @nuxtjs/color-mode.
115+
* Accepts 'light', 'dark', or a reactive ref.
116+
*/
117+
colorMode?: 'light' | 'dark'
107118
}>(), {
108119
// @ts-expect-error untyped
109120
trigger: ['mouseenter', 'mouseover', 'mousedown'],
@@ -119,6 +130,26 @@ const emits = defineEmits<{
119130
}>()
120131
121132
const apiKey = props.apiKey || scriptRuntimeConfig('googleMaps')?.apiKey
133+
const runtimeConfig = useRuntimeConfig()
134+
const proxyConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy
135+
136+
// Color mode support - try to auto-detect from @nuxtjs/color-mode
137+
const nuxtApp = tryUseNuxtApp()
138+
const nuxtColorMode = nuxtApp?.$colorMode as { value: string } | undefined
139+
140+
const currentColorMode = computed(() => {
141+
if (props.colorMode)
142+
return props.colorMode
143+
if (nuxtColorMode?.value)
144+
return nuxtColorMode.value === 'dark' ? 'dark' : 'light'
145+
return 'light'
146+
})
147+
148+
const currentMapId = computed(() => {
149+
if (!props.mapIds)
150+
return props.mapOptions?.mapId
151+
return props.mapIds[currentColorMode.value] || props.mapIds.light || props.mapOptions?.mapId
152+
})
122153
123154
const mapsApi = ref<typeof google.maps | undefined>()
124155
@@ -144,10 +175,10 @@ const { load, status, onLoaded } = useScriptGoogleMaps({
144175
})
145176
146177
const options = computed(() => {
147-
return defu({ center: centerOverride.value }, props.mapOptions, {
178+
const mapId = props.mapOptions?.styles ? undefined : (currentMapId.value || 'map')
179+
return defu({ center: centerOverride.value, mapId }, props.mapOptions, {
148180
center: props.center,
149181
zoom: 15,
150-
mapId: props.mapOptions?.styles ? undefined : 'map',
151182
})
152183
})
153184
const ready = ref(false)
@@ -266,8 +297,13 @@ function importLibrary<T>(key: string): Promise<T> {
266297
}
267298
}, { immediate: true })
268299
})
269-
libraries.set(key, p)
270-
return p as any as Promise<T>
300+
// Clear cache on failure to allow retry
301+
const cached = Promise.resolve(p).catch((err) => {
302+
libraries.delete(key)
303+
throw err
304+
})
305+
libraries.set(key, cached)
306+
return cached as Promise<T>
271307
}
272308
273309
const googleMaps = {
@@ -380,7 +416,7 @@ onMounted(() => {
380416
})
381417
})
382418
383-
if (import.meta.server) {
419+
if (import.meta.server && !proxyConfig?.enabled) {
384420
useHead({
385421
link: [
386422
{
@@ -419,7 +455,8 @@ const placeholder = computed(() => {
419455
center,
420456
}, {
421457
size: `${props.width}x${props.height}`,
422-
key: apiKey,
458+
// Only include API key if not using proxy (proxy injects it server-side)
459+
key: proxyConfig?.enabled ? undefined : apiKey,
423460
scale: 2, // we assume a high DPI to avoid hydration issues
424461
style: props.mapOptions?.styles ? transformMapStyles(props.mapOptions.styles) : undefined,
425462
markers: [
@@ -438,7 +475,12 @@ const placeholder = computed(() => {
438475
})
439476
.join('|'),
440477
})
441-
return withQuery('https://maps.googleapis.com/maps/api/staticmap', placeholderOptions as QueryObject)
478+
479+
const baseUrl = proxyConfig?.enabled
480+
? '/_scripts/google-static-maps-proxy'
481+
: 'https://maps.googleapis.com/maps/api/staticmap'
482+
483+
return withQuery(baseUrl, placeholderOptions as QueryObject)
442484
})
443485
444486
const placeholderAttrs = computed(() => {

src/runtime/components/GoogleMaps/ScriptGoogleMapsMarkerClusterer.vue

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,28 @@
33
</template>
44

55
<script lang="ts">
6-
import type { MarkerClusterer, MarkerClustererOptions } from '@googlemaps/markerclusterer'
76
import { inject, onUnmounted, provide, shallowRef, type InjectionKey, type ShallowRef } from 'vue'
87
import { whenever } from '@vueuse/core'
98
import { MAP_INJECTION_KEY } from './ScriptGoogleMaps.vue'
109
10+
// Inline types to avoid requiring @googlemaps/markerclusterer as a build-time dependency
11+
export interface MarkerClustererInstance {
12+
render: () => void
13+
setMap: (map: google.maps.Map | null) => void
14+
addListener: (event: string, handler: () => void) => void
15+
addMarker: (marker: google.maps.marker.AdvancedMarkerElement | google.maps.Marker, noDraw?: boolean) => void
16+
removeMarker: (marker: google.maps.marker.AdvancedMarkerElement | google.maps.Marker, noDraw?: boolean) => boolean
17+
}
18+
19+
export interface MarkerClustererOptions {
20+
markers?: google.maps.marker.AdvancedMarkerElement[]
21+
algorithm?: unknown
22+
renderer?: unknown
23+
onClusterClick?: unknown
24+
}
25+
1126
export const MARKER_CLUSTERER_INJECTION_KEY = Symbol('marker-clusterer') as InjectionKey<{
12-
markerClusterer: ShallowRef<MarkerClusterer | undefined>
27+
markerClusterer: ShallowRef<MarkerClustererInstance | undefined>
1328
requestRerender: () => void
1429
}>
1530
</script>
@@ -26,19 +41,19 @@ const markerClustererEvents = [
2641
] as const
2742
2843
const emit = defineEmits<{
29-
(event: typeof markerClustererEvents[number], payload: MarkerClusterer): void
44+
(event: typeof markerClustererEvents[number], payload: MarkerClustererInstance): void
3045
}>()
3146
3247
const mapContext = inject(MAP_INJECTION_KEY, undefined)
3348
34-
const markerClusterer = shallowRef<MarkerClusterer | undefined>(undefined)
49+
const markerClusterer = shallowRef<MarkerClustererInstance | undefined>(undefined)
3550
3651
whenever(() => mapContext?.map.value, async (map) => {
3752
const { MarkerClusterer } = await import('@googlemaps/markerclusterer')
3853
markerClusterer.value = new MarkerClusterer({
3954
map,
4055
...props.options,
41-
})
56+
} as any) as MarkerClustererInstance
4257
4358
setupMarkerClustererEventListeners(markerClusterer.value)
4459
}, {
@@ -76,9 +91,9 @@ provide(
7691
},
7792
)
7893
79-
function setupMarkerClustererEventListeners(markerClusterer: MarkerClusterer) {
94+
function setupMarkerClustererEventListeners(clusterer: MarkerClustererInstance) {
8095
markerClustererEvents.forEach((event) => {
81-
markerClusterer.addListener(event, () => emit(event, markerClusterer))
96+
clusterer.addListener(event, () => emit(event, clusterer))
8297
})
8398
}
8499
</script>

src/runtime/components/GoogleMaps/ScriptGoogleMapsPinElement.vue

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
</template>
33

44
<script setup lang="ts">
5-
import { inject, shallowRef } from 'vue'
5+
import { inject, onUnmounted, shallowRef } from 'vue'
66
import { whenever } from '@vueuse/core'
77
import { MAP_INJECTION_KEY } from './ScriptGoogleMaps.vue'
88
import { ADVANCED_MARKER_ELEMENT_INJECTION_KEY } from './ScriptGoogleMapsAdvancedMarkerElement.vue'
@@ -42,4 +42,12 @@ whenever(
4242
once: true,
4343
},
4444
)
45+
46+
onUnmounted(() => {
47+
if (advancedMarkerElementContext?.advancedMarkerElement.value && pinElement.value) {
48+
// Clear the content from the parent marker
49+
advancedMarkerElementContext.advancedMarkerElement.value.content = null
50+
}
51+
pinElement.value = undefined
52+
})
4553
</script>
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { createError, defineEventHandler, getHeader, getQuery, setHeader } from 'h3'
2+
import { $fetch } from 'ofetch'
3+
import { withQuery } from 'ufo'
4+
import { useRuntimeConfig } from '#imports'
5+
6+
export default defineEventHandler(async (event) => {
7+
const runtimeConfig = useRuntimeConfig()
8+
const publicConfig = (runtimeConfig.public['nuxt-scripts'] as any)?.googleStaticMapsProxy
9+
const privateConfig = (runtimeConfig['nuxt-scripts'] as any)?.googleStaticMapsProxy
10+
11+
if (!publicConfig?.enabled) {
12+
throw createError({
13+
statusCode: 404,
14+
statusMessage: 'Google Static Maps proxy is not enabled',
15+
})
16+
}
17+
18+
// Get API key from private config (server-side only, not exposed to client)
19+
const apiKey = privateConfig?.apiKey
20+
if (!apiKey) {
21+
throw createError({
22+
statusCode: 500,
23+
statusMessage: 'Google Maps API key not configured for proxy',
24+
})
25+
}
26+
27+
// Validate referer to prevent external abuse
28+
const referer = getHeader(event, 'referer')
29+
const host = getHeader(event, 'host')
30+
if (referer && host) {
31+
const refererUrl = new URL(referer).host
32+
if (refererUrl !== host) {
33+
throw createError({
34+
statusCode: 403,
35+
statusMessage: 'Invalid referer',
36+
})
37+
}
38+
}
39+
40+
const query = getQuery(event)
41+
42+
// Remove any client-provided key and use server-side key
43+
const { key: _clientKey, ...safeQuery } = query
44+
45+
const googleMapsUrl = withQuery('https://maps.googleapis.com/maps/api/staticmap', {
46+
...safeQuery,
47+
key: apiKey,
48+
})
49+
50+
const response = await $fetch.raw(googleMapsUrl, {
51+
headers: {
52+
'User-Agent': 'Nuxt Scripts Google Static Maps Proxy',
53+
},
54+
}).catch((error: any) => {
55+
throw createError({
56+
statusCode: error.statusCode || 500,
57+
statusMessage: error.statusMessage || 'Failed to fetch static map',
58+
})
59+
})
60+
61+
const cacheMaxAge = publicConfig.cacheMaxAge || 3600
62+
setHeader(event, 'Content-Type', response.headers.get('content-type') || 'image/png')
63+
setHeader(event, 'Cache-Control', `public, max-age=${cacheMaxAge}, s-maxage=${cacheMaxAge}`)
64+
setHeader(event, 'Vary', 'Accept-Encoding')
65+
66+
return response._data
67+
})

0 commit comments

Comments
 (0)