@gscdump/nuxt 0.22.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +10 -137
  2. package/dist/module.d.mts +22 -0
  3. package/dist/module.mjs +30 -0
  4. package/dist/runtime-config.d.mts +12 -0
  5. package/dist/runtime-config.mjs +27 -0
  6. package/package.json +26 -176
  7. package/app/assets/css/main.css +0 -2
  8. package/app/components/GscAnalyzerPanel.vue +0 -94
  9. package/app/components/GscAnonymizationBanner.vue +0 -46
  10. package/app/components/GscBootProgress.vue +0 -297
  11. package/app/components/GscCommandPalette.vue +0 -77
  12. package/app/components/GscDashboardPage.vue +0 -26
  13. package/app/components/GscEngineBadge.vue +0 -28
  14. package/app/components/GscHero.vue +0 -214
  15. package/app/components/GscLazyTopResult.vue +0 -47
  16. package/app/components/GscPerformanceChart.vue +0 -532
  17. package/app/components/GscPositionDistributionChart.vue +0 -63
  18. package/app/components/GscQueryLabel.vue +0 -63
  19. package/app/components/GscSitePageHeader.vue +0 -40
  20. package/app/components/GscSourceDebugPanel.vue +0 -196
  21. package/app/components/GscSyncStatusCell.vue +0 -54
  22. package/app/components/GscTopRollupCard.vue +0 -90
  23. package/app/composables/_useGscBackfill.ts +0 -119
  24. package/app/composables/_useGscQueryDispatcher.ts +0 -123
  25. package/app/composables/_useGscResource.ts +0 -202
  26. package/app/composables/_useGscSharedSiteResource.ts +0 -136
  27. package/app/composables/useGscAnalytics.ts +0 -206
  28. package/app/composables/useGscAnalyticsClient.ts +0 -9
  29. package/app/composables/useGscAnalyticsConfig.ts +0 -8
  30. package/app/composables/useGscAnalyticsSourceInfo.ts +0 -165
  31. package/app/composables/useGscAnalyzer.ts +0 -460
  32. package/app/composables/useGscAnalyzerBatch.ts +0 -106
  33. package/app/composables/useGscAnalyzerDefs.ts +0 -119
  34. package/app/composables/useGscAuth.ts +0 -115
  35. package/app/composables/useGscConsoleUrl.ts +0 -45
  36. package/app/composables/useGscCountries.ts +0 -46
  37. package/app/composables/useGscCurrentSite.ts +0 -36
  38. package/app/composables/useGscInspectionHistory.ts +0 -42
  39. package/app/composables/useGscInspections.ts +0 -52
  40. package/app/composables/useGscPanelContext.ts +0 -31
  41. package/app/composables/useGscPeriod.ts +0 -243
  42. package/app/composables/useGscQuery.ts +0 -301
  43. package/app/composables/useGscQueryDispatcher.ts +0 -14
  44. package/app/composables/useGscRequestIndexingInspect.ts +0 -29
  45. package/app/composables/useGscResource.ts +0 -11
  46. package/app/composables/useGscRollup.ts +0 -237
  47. package/app/composables/useGscSearchAppearance.ts +0 -46
  48. package/app/composables/useGscSiteAnalyzer.ts +0 -456
  49. package/app/composables/useGscSitemapHistory.ts +0 -41
  50. package/app/composables/useGscSitemaps.ts +0 -45
  51. package/app/composables/useGscTableState.ts +0 -119
  52. package/app/plugins/analytics.ts +0 -57
  53. package/app/queries/gsc.ts +0 -163
  54. package/app/utils/anonymization.ts +0 -24
  55. package/app/utils/country-names.ts +0 -56
  56. package/app/utils/duckdb-wasm.ts +0 -166
  57. package/app/utils/gsc-constants.ts +0 -10
  58. package/app/utils/gsc-error.ts +0 -58
  59. package/app/utils/gsc-fetch.ts +0 -94
  60. package/app/utils/gsc-filters.ts +0 -32
  61. package/app/utils/gsc-rows.ts +0 -72
  62. package/app/utils/gsc-rpc.ts +0 -18
  63. package/app/utils/position.ts +0 -7
  64. package/module.ts +0 -81
  65. package/nuxt.config.ts +0 -61
  66. package/types.ts +0 -115
@@ -1,196 +0,0 @@
1
- <script setup lang="ts">
2
- // Floating debug panel showing which AnalysisQuerySource the server resolved
3
- // for the current site. Reads `/api/__gsc/sites/:siteId/source-info`. Ships in the
4
- // layer so every consumer gets the same shape without reimplementing.
5
- //
6
- // Siting:
7
- // - `site` prop > `siteId` prop > route param `id` (the canonical path)
8
- // - Off-site routes (overview, /pricing, etc.) render a "no site context" tile.
9
- //
10
- // Visibility:
11
- // - Hidden by default; click the floating ⓘ pill to open.
12
- // - Preference persists in localStorage per origin (key `gsc-debug-panel`).
13
-
14
- const props = defineProps<{
15
- /** Explicit site id. Falls back to route.params.id. */
16
- site?: string
17
- /** @deprecated Use `site`. Kept so older callers still type-check. */
18
- siteId?: string
19
- }>()
20
-
21
- const route = useRoute()
22
- const resolvedSite = computed(() => props.site ?? props.siteId ?? (typeof route.params.id === 'string' ? route.params.id : null))
23
-
24
- const open = useLocalStorage('gsc-debug-panel', false)
25
-
26
- const { info, loading, error } = useGscAnalyticsSourceInfo(() => resolvedSite.value)
27
-
28
- // Off-site fallback — `/api/__gsc/whoami` exposes identity.attrs (tier,
29
- // plan, etc.) so the panel can still show viewer context on routes without
30
- // a site (overview, pricing, landing pages). Only fetched when we have no
31
- // site to resolve, so per-site routes pay nothing for this.
32
- interface WhoamiResponse {
33
- userId: string
34
- siteIds: string[] | null
35
- identityAttrs: Record<string, unknown>
36
- sourceProviderRegistered: boolean
37
- }
38
- const whoami = ref<WhoamiResponse | null>(null)
39
- const whoamiLoading = ref(false)
40
- const analyticsClient = useGscAnalyticsClient()
41
- watchEffect(() => {
42
- if (!import.meta.client || resolvedSite.value || !open.value || whoami.value || whoamiLoading.value)
43
- return
44
- whoamiLoading.value = true
45
- const request = analyticsClient.whoami() as Promise<WhoamiResponse>
46
- request
47
- .then((res) => { whoami.value = res })
48
- .catch(() => { /* surface via empty state, not a toast */ })
49
- .finally(() => { whoamiLoading.value = false })
50
- })
51
-
52
- function fmtKind(kind: 'row' | 'sql'): string {
53
- return kind === 'sql' ? 'SQL' : 'Rows'
54
- }
55
- </script>
56
-
57
- <template>
58
- <ClientOnly>
59
- <!-- Toggle affordance: always visible, bottom-right. -->
60
- <button
61
- type="button"
62
- class="fixed z-40 bottom-3 right-3 inline-flex items-center gap-1.5 px-2.5 py-1.5 rounded-full border border-default bg-default/90 backdrop-blur text-[11px] font-mono text-muted hover:text-default shadow-sm transition-colors"
63
- :title="open ? 'Hide data-source debug panel' : 'Show data-source debug panel'"
64
- @click="open = !open"
65
- >
66
- <span class="size-1.5 rounded-full" :class="info ? (info.kind === 'sql' ? 'bg-primary' : 'bg-amber-500') : 'bg-dimmed'" />
67
- src: {{ info?.name ?? (loading ? '…' : resolvedSite ? '—' : 'no site') }}
68
- <UIcon :name="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" class="size-3" />
69
- </button>
70
-
71
- <!-- Panel body -->
72
- <div
73
- v-if="open"
74
- class="fixed z-40 bottom-12 right-3 w-[320px] rounded-lg border border-default bg-default/95 backdrop-blur shadow-lg text-[12px] font-mono"
75
- >
76
- <header class="flex items-center justify-between px-3 py-2 border-b border-default">
77
- <span class="font-semibold text-default">Analytics source</span>
78
- <button type="button" class="text-dimmed hover:text-default" @click="open = false">
79
- <UIcon name="i-lucide-x" class="size-3.5" />
80
- </button>
81
- </header>
82
-
83
- <div v-if="!resolvedSite" class="flex flex-col divide-y divide-default">
84
- <div class="px-3 py-2.5 text-dimmed text-[11px] leading-snug">
85
- No site context on this route. Showing identity only.
86
- </div>
87
- <div v-if="whoamiLoading && !whoami" class="p-3 text-dimmed">
88
- Resolving…
89
- </div>
90
- <div v-else-if="whoami" class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2.5">
91
- <span class="text-dimmed">userId</span>
92
- <span class="text-default truncate" :title="whoami.userId">{{ whoami.userId }}</span>
93
-
94
- <span class="text-dimmed">sites</span>
95
- <span class="text-default">
96
- {{ whoami.siteIds == null ? 'all' : `${whoami.siteIds.length} scoped` }}
97
- </span>
98
-
99
- <span class="text-dimmed">source</span>
100
- <span :class="whoami.sourceProviderRegistered ? 'text-success' : 'text-error'">
101
- {{ whoami.sourceProviderRegistered ? 'registered' : 'not registered' }}
102
- </span>
103
-
104
- <template v-for="(value, key) in whoami.identityAttrs" :key="key">
105
- <span class="text-dimmed">{{ key }}</span>
106
- <span class="text-default truncate" :title="String(value)">{{ String(value) }}</span>
107
- </template>
108
- </div>
109
- </div>
110
- <div v-else-if="loading && !info" class="p-3 text-dimmed">
111
- Resolving…
112
- </div>
113
- <div v-else-if="error" class="p-3 text-error">
114
- <div class="font-semibold mb-1">
115
- Failed to resolve source
116
- </div>
117
- <div class="text-[11px] leading-snug">
118
- {{ error.message }}
119
- </div>
120
- </div>
121
- <div v-else-if="info" class="flex flex-col divide-y divide-default">
122
- <div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2.5">
123
- <span class="text-dimmed">site</span>
124
- <span class="truncate text-default" :title="resolvedSite">{{ resolvedSite }}</span>
125
-
126
- <span class="text-dimmed">name</span>
127
- <span class="text-default">{{ info.name }}</span>
128
-
129
- <span class="text-dimmed">kind</span>
130
- <span>
131
- <span
132
- class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wider font-semibold"
133
- :class="info.kind === 'sql' ? 'bg-primary/10 text-primary' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'"
134
- >{{ fmtKind(info.kind) }}</span>
135
- </span>
136
-
137
- <span class="text-dimmed">attach</span>
138
- <span :class="info.browserAttachEligible ? 'text-success' : 'text-muted'">
139
- {{ info.browserAttachEligible ? 'browser-attached' : 'server-routed' }}
140
- </span>
141
-
142
- <span class="text-dimmed">analyzers</span>
143
- <span class="text-default">
144
- {{ info.supportedAnalyzerIds.length }}
145
- <span class="text-dimmed">runnable</span>
146
- </span>
147
-
148
- <template v-for="(value, key) in (info.identityAttrs ?? {})" :key="key">
149
- <span class="text-dimmed">{{ key }}</span>
150
- <span class="text-default truncate" :title="String(value)">{{ String(value) }}</span>
151
- </template>
152
- </div>
153
-
154
- <div class="px-3 py-2.5">
155
- <div class="text-[10px] uppercase tracking-widest text-dimmed font-semibold mb-1.5">
156
- capabilities
157
- </div>
158
- <div class="flex flex-wrap gap-1">
159
- <template v-for="(value, cap) in info.capabilities" :key="cap">
160
- <span
161
- v-if="value"
162
- class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-elevated text-muted border border-default"
163
- >
164
- {{ cap }}
165
- </span>
166
- </template>
167
- <span
168
- v-if="!Object.values(info.capabilities).some(Boolean)"
169
- class="text-[11px] text-dimmed"
170
- >
171
- none advertised
172
- </span>
173
- </div>
174
- </div>
175
-
176
- <details class="px-3 py-2.5">
177
- <summary class="text-[10px] uppercase tracking-widest text-dimmed font-semibold cursor-pointer">
178
- supported analyzer ids ({{ info.supportedAnalyzerIds.length }})
179
- </summary>
180
- <div class="mt-2 max-h-48 overflow-y-auto">
181
- <div
182
- v-for="id in info.supportedAnalyzerIds"
183
- :key="id"
184
- class="text-[11px] text-muted py-0.5"
185
- >
186
- {{ id }}
187
- </div>
188
- <div v-if="!info.supportedAnalyzerIds.length" class="text-[11px] text-dimmed">
189
- none
190
- </div>
191
- </div>
192
- </details>
193
- </div>
194
- </div>
195
- </ClientOnly>
196
- </template>
@@ -1,54 +0,0 @@
1
- <script setup lang="ts">
2
- const props = defineProps<{
3
- data?: {
4
- status: string
5
- rowsFetched: number
6
- rowsInserted: number
7
- error: string | null
8
- }
9
- }>()
10
-
11
- const statusColor = computed(() => {
12
- if (!props.data)
13
- return 'text-muted'
14
- switch (props.data.status) {
15
- case 'completed': return 'text-success'
16
- case 'processing': return 'text-info'
17
- case 'queued': return 'text-muted'
18
- case 'failed': return 'text-error'
19
- default: return 'text-muted'
20
- }
21
- })
22
-
23
- const statusIcon = computed(() => {
24
- if (!props.data)
25
- return '-'
26
- switch (props.data.status) {
27
- case 'completed': return '✓'
28
- case 'processing': return '⟳'
29
- case 'queued': return '○'
30
- case 'failed': return '✗'
31
- default: return '?'
32
- }
33
- })
34
- </script>
35
-
36
- <template>
37
- <span v-if="!data" class="text-muted">-</span>
38
- <UTooltip v-else>
39
- <span :class="statusColor" class="font-mono">
40
- {{ statusIcon }}
41
- <span v-if="data.status === 'completed'" class="text-muted ml-1">{{ data.rowsFetched }}</span>
42
- </span>
43
- <template #content>
44
- <div class="text-xs space-y-1">
45
- <div>Status: {{ data.status }}</div>
46
- <div>Fetched: {{ data.rowsFetched?.toLocaleString() || 0 }}</div>
47
- <div>Inserted: {{ data.rowsInserted?.toLocaleString() || 0 }}</div>
48
- <div v-if="data.error" class="text-error">
49
- Error: {{ data.error }}
50
- </div>
51
- </div>
52
- </template>
53
- </UTooltip>
54
- </template>
@@ -1,90 +0,0 @@
1
- <script setup lang="ts">
2
- // Reads a `top_*_28d` rollup directly from R2 and renders the top 10 rows.
3
- // Zero DuckDB round-trip — the rollup pre-aggregated clicks over the
4
- // trailing 28 days when sync last completed. Hidden when the rollup isn't
5
- // built yet (dashboard still shows the live tables as the canonical view).
6
-
7
- import { useGscRollup } from '../composables/useGscRollup'
8
-
9
- interface TopPageRow { url: string, clicks: number, impressions: number, sum_position: number }
10
- interface TopKeywordRow { query: string, clicks: number, impressions: number, sum_position: number }
11
- type TopRow = TopPageRow | TopKeywordRow
12
-
13
- const { siteIdentifier, rollupId, title, labelKey, hrefBase } = defineProps<{
14
- siteIdentifier: string | null | undefined
15
- rollupId: 'top_pages_28d' | 'top_keywords_28d'
16
- title: string
17
- // Field on each row to render as the label (e.g. 'url' or 'query')
18
- labelKey: 'url' | 'query'
19
- // Optional: if set, label becomes a link to `${hrefBase}${encodeURIComponent(label)}`
20
- hrefBase?: string
21
- }>()
22
-
23
- const { envelope, loading } = useGscRollup<TopRow[]>(() => siteIdentifier ?? null, () => rollupId)
24
-
25
- const top10 = computed(() => (envelope.value?.payload ?? []).slice(0, 10))
26
- const hasClicks = computed(() => top10.value.some((r: TopRow) => r.clicks > 0))
27
-
28
- const builtAtRelative = computed(() => {
29
- const builtAt = envelope.value?.builtAt
30
- if (!builtAt)
31
- return null
32
- const ageMs = Date.now() - builtAt
33
- const hours = Math.floor(ageMs / 3600_000)
34
- if (hours < 1)
35
- return 'just now'
36
- if (hours < 24)
37
- return `${hours}h ago`
38
- return `${Math.floor(hours / 24)}d ago`
39
- })
40
- </script>
41
-
42
- <template>
43
- <UCard v-if="loading || top10.length">
44
- <template #header>
45
- <div class="flex items-center justify-between gap-4">
46
- <div>
47
- <h3 class="font-semibold">
48
- {{ title }}
49
- </h3>
50
- <p class="text-xs text-muted mt-0.5">
51
- Last 28 days<span v-if="builtAtRelative"> · built {{ builtAtRelative }}</span>
52
- </p>
53
- </div>
54
- <span class="text-[10px] font-mono uppercase text-cyan-400 border border-cyan-500/40 bg-cyan-500/5 px-1.5 py-0.5">
55
- rollup
56
- </span>
57
- </div>
58
- </template>
59
-
60
- <div v-if="loading && !top10.length" class="space-y-2">
61
- <USkeleton v-for="i in 5" :key="i" class="h-6" />
62
- </div>
63
- <div v-else-if="!hasClicks" class="text-sm text-muted py-4 text-center">
64
- No clicks in the last 28 days.
65
- </div>
66
- <div v-else class="space-y-1">
67
- <div
68
- v-for="(row, i) in top10"
69
- :key="String((row as any)[labelKey])"
70
- class="flex items-center justify-between gap-3 py-1 text-sm border-b border-default/40 last:border-0"
71
- >
72
- <div class="flex items-center gap-2 min-w-0">
73
- <span class="text-xs text-muted tabular-nums w-5 text-right">{{ i + 1 }}</span>
74
- <component
75
- :is="hrefBase ? 'NuxtLink' : 'span'"
76
- :to="hrefBase ? `${hrefBase}${encodeURIComponent(String((row as any)[labelKey]))}` : undefined"
77
- class="truncate"
78
- :class="hrefBase ? 'text-primary hover:underline' : ''"
79
- >
80
- {{ (row as any)[labelKey] }}
81
- </component>
82
- </div>
83
- <div class="flex items-center gap-3 text-xs shrink-0 tabular-nums text-muted">
84
- <span class="text-neutral-200">{{ row.clicks.toLocaleString() }}</span>
85
- <span>{{ row.impressions.toLocaleString() }} impr</span>
86
- </div>
87
- </div>
88
- </div>
89
- </UCard>
90
- </template>
@@ -1,119 +0,0 @@
1
- // Detects `meta.backfillRequired` from tool/analysis responses, kicks off an
2
- // on-demand backfill request, polls sync-progress, and invokes a refetch once
3
- // the requested range is synced.
4
-
5
- import { invalidateNuxtQueries } from 'nuxt-use-query/query-cache'
6
- import { gscQueries } from '../queries/gsc'
7
- import { useGscRpc } from '../utils/gsc-rpc'
8
-
9
- interface BackfillRange {
10
- startDate: string
11
- endDate: string
12
- }
13
-
14
- interface SyncProgressResponse {
15
- sites?: Array<{
16
- id: string
17
- oldestDateSynced?: string | null
18
- newestDateSynced?: string | null
19
- syncStatus?: string
20
- backfill?: { percent?: number }
21
- }>
22
- }
23
-
24
- export interface GscBackfillState {
25
- pending: Ref<boolean>
26
- range: Ref<BackfillRange | null>
27
- percent: Ref<number>
28
- error: Ref<string | null>
29
- }
30
-
31
- const POLL_INTERVAL_MS = 4000
32
- const POLL_MAX_MS = 15 * 60 * 1000
33
-
34
- export function useGscBackfill(): GscBackfillState & {
35
- request: (siteId: string, req: BackfillRange, refetch: () => Promise<unknown> | unknown) => Promise<void>
36
- maybeTrigger: (meta: unknown, siteId: string, refetch: () => Promise<unknown> | unknown) => boolean
37
- reset: () => void
38
- } {
39
- const rpc = useGscRpc()
40
- const pending = ref(false)
41
- const range = ref<BackfillRange | null>(null)
42
- const percent = ref(0)
43
- const error = ref<string | null>(null)
44
- // Track ranges we've already attempted in this session so a failed/timed-out
45
- // backfill doesn't retrigger on every refetch.
46
- const attemptedKeys = new Set<string>()
47
-
48
- function rangeKey(siteId: string, r: BackfillRange): string {
49
- return `${siteId}:${r.startDate}:${r.endDate}`
50
- }
51
-
52
- async function isRangeCovered(siteId: string, req: BackfillRange): Promise<boolean> {
53
- const progress = await rpc.query(gscQueries.syncProgress(), { silent: true }).catch(() => null) as SyncProgressResponse | null
54
- const site = progress?.sites?.find(s => s.id === siteId)
55
- if (!site?.oldestDateSynced || !site?.newestDateSynced)
56
- return false
57
- percent.value = site.backfill?.percent ?? percent.value
58
- return req.startDate >= site.oldestDateSynced && req.endDate <= site.newestDateSynced
59
- }
60
-
61
- async function request(siteId: string, req: BackfillRange, refetch: () => Promise<unknown> | unknown): Promise<void> {
62
- pending.value = true
63
- range.value = req
64
- percent.value = 0
65
- error.value = null
66
- attemptedKeys.add(rangeKey(siteId, req))
67
-
68
- await rpc.execute(gscQueries.backfill(siteId), { ...req }, { silent: true }).catch((err: unknown) => {
69
- error.value = (err as { data?: { message?: string }, message?: string })?.data?.message
70
- || (err as Error)?.message
71
- || 'Failed to queue backfill'
72
- })
73
-
74
- if (error.value) {
75
- pending.value = false
76
- return
77
- }
78
-
79
- const started = Date.now()
80
- let covered = false
81
- while (Date.now() - started < POLL_MAX_MS) {
82
- await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
83
- covered = await isRangeCovered(siteId, req)
84
- if (covered)
85
- break
86
- }
87
-
88
- if (!covered)
89
- error.value = 'Backfill did not complete within the expected window. Try again later.'
90
-
91
- if (covered) {
92
- // Invalidate everything that depends on the newly synced range so
93
- // consumers refetch instead of relying on the caller-passed refetch.
94
- invalidateNuxtQueries(key => key.startsWith(`gsc:source-info:${siteId}`)
95
- || key.startsWith(`gsc:analysis-sources:${siteId}`)
96
- || key === 'gsc:sync-progress')
97
- await refetch()
98
- }
99
- pending.value = false
100
- range.value = null
101
- }
102
-
103
- function maybeTrigger(meta: unknown, siteId: string, refetch: () => Promise<unknown> | unknown): boolean {
104
- const backfillRequired = (meta as { backfillRequired?: BackfillRange })?.backfillRequired
105
- if (!backfillRequired || pending.value)
106
- return false
107
- if (attemptedKeys.has(rangeKey(siteId, backfillRequired)))
108
- return false
109
- request(siteId, backfillRequired, refetch)
110
- return true
111
- }
112
-
113
- function reset(): void {
114
- attemptedKeys.clear()
115
- error.value = null
116
- }
117
-
118
- return { pending, range, percent, error, request, maybeTrigger, reset }
119
- }
@@ -1,123 +0,0 @@
1
- // Per-query dispatch policy + fallback telemetry behind one swappable seam.
2
- //
3
- // `useGscQuery` owns *intent* (the caller's `engine: 'auto' | 'browser' | 'server'`);
4
- // the dispatcher owns *policy* (how `auto` resolves against current auth, what
5
- // to do with auto-fallback events). Default impl is no-op telemetry — hosts
6
- // that want fallback beacons override the provide with a configured factory.
7
-
8
- import type { _useGscAuthInternal } from './useGscAuth'
9
- import type { GscQueryDecisionReason, GscQueryEngine } from './useGscQuery'
10
-
11
- const ENGINE_STATE_KEY = 'gscdump:engine'
12
-
13
- type InternalAuthState = ReturnType<typeof _useGscAuthInternal>['value']
14
-
15
- export interface GscEngineDecision {
16
- mode: 'browser' | 'server'
17
- reason: GscQueryDecisionReason
18
- /** The resolved intent before the auth/optin mapping — useful for callers that branch on "did the user ask for auto?". */
19
- requested: GscQueryEngine
20
- detail?: string
21
- }
22
-
23
- export interface GscFallbackEvent {
24
- reason: string
25
- at: number
26
- url: string
27
- }
28
-
29
- export interface PickEngineOpts {
30
- /** Per-call override. Wins over the `gscdump:engine` useState + `runtimeConfig.public.analytics.defaultEngine`. */
31
- perCall?: GscQueryEngine
32
- }
33
-
34
- export interface GscQueryDispatcher {
35
- /**
36
- * Resolve the active engine into a concrete mode + reason. Consults the
37
- * resolution chain: `opts.perCall` → `gscdump:engine` useState → runtimeConfig →
38
- * `'auto'`. Then maps `auto` against `auth.browserAnalyzerEnabled`.
39
- */
40
- pickEngine: (auth: InternalAuthState, opts?: PickEngineOpts) => GscEngineDecision
41
- /** Called once per auto-fallback. Default impl is a no-op. */
42
- reportFallback: (event: GscFallbackEvent) => void
43
- }
44
-
45
- export interface CreateDefaultDispatcherOpts {
46
- /** When set, fallbacks beacon to this URL with batched payloads. */
47
- telemetryEndpoint?: string
48
- }
49
-
50
- const FLUSH_DELAY_MS = 5000
51
-
52
- function createReporter(endpoint: string): (event: GscFallbackEvent) => void {
53
- const buffer: GscFallbackEvent[] = []
54
- let flushScheduled = false
55
-
56
- function flush(): void {
57
- flushScheduled = false
58
- if (buffer.length === 0)
59
- return
60
- const events = buffer.splice(0)
61
- const body = JSON.stringify({ events })
62
- if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
63
- navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }))
64
- return
65
- }
66
- fetch(endpoint, { method: 'POST', body, headers: { 'content-type': 'application/json' } }).catch(() => {})
67
- }
68
-
69
- function scheduleFlush(): void {
70
- if (flushScheduled || typeof window === 'undefined')
71
- return
72
- flushScheduled = true
73
- setTimeout(flush, FLUSH_DELAY_MS)
74
- if (typeof document !== 'undefined') {
75
- const handler = (): void => {
76
- if (document.visibilityState === 'hidden')
77
- flush()
78
- }
79
- document.addEventListener('visibilitychange', handler, { once: true })
80
- }
81
- }
82
-
83
- return (event: GscFallbackEvent): void => {
84
- buffer.push(event)
85
- scheduleFlush()
86
- }
87
- }
88
-
89
- export function createDefaultGscQueryDispatcher(
90
- opts: CreateDefaultDispatcherOpts = {},
91
- ): GscQueryDispatcher {
92
- const reportFallback = opts.telemetryEndpoint
93
- ? createReporter(opts.telemetryEndpoint)
94
- : (): void => {}
95
-
96
- return {
97
- pickEngine(auth, opts = {}): GscEngineDecision {
98
- const requested = opts.perCall ?? resolveDefaultEngine()
99
- if (requested === 'server')
100
- return { mode: 'server', reason: 'forced:server', requested }
101
- if (requested === 'browser')
102
- return { mode: 'browser', reason: 'forced:browser', requested }
103
- // 'auto': when the host has wired setGscAuth, derive from the per-user
104
- // browserAnalyzerEnabled flag. When unwired, preserve legacy 'auto' = browser.
105
- if (auth._initialized && !auth.browserAnalyzerEnabled)
106
- return { mode: 'server', reason: 'optin:off', requested }
107
- return { mode: 'browser', reason: 'auto:browser', requested }
108
- },
109
- reportFallback,
110
- }
111
- }
112
-
113
- function resolveDefaultEngine(): GscQueryEngine {
114
- const override = useState<GscQueryEngine | null>(ENGINE_STATE_KEY, () => null).value
115
- if (override)
116
- return override
117
- const cfg = useRuntimeConfig().public.analytics as { defaultEngine?: GscQueryEngine } | undefined
118
- return cfg?.defaultEngine ?? 'auto'
119
- }
120
-
121
- export function useGscQueryDispatcher(): GscQueryDispatcher {
122
- return useNuxtApp().$gscQueryDispatcher
123
- }