@gscdump/nuxt 0.19.0

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 (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/app/assets/css/main.css +2 -0
  4. package/app/components/GscAnalyzerPanel.vue +94 -0
  5. package/app/components/GscAnonymizationBanner.vue +46 -0
  6. package/app/components/GscBootProgress.vue +297 -0
  7. package/app/components/GscCommandPalette.vue +77 -0
  8. package/app/components/GscDashboardPage.vue +26 -0
  9. package/app/components/GscEngineBadge.vue +28 -0
  10. package/app/components/GscHero.vue +215 -0
  11. package/app/components/GscLazyTopResult.vue +45 -0
  12. package/app/components/GscPerformanceChart.vue +532 -0
  13. package/app/components/GscPositionDistributionChart.vue +63 -0
  14. package/app/components/GscQueryLabel.vue +63 -0
  15. package/app/components/GscSitePageHeader.vue +40 -0
  16. package/app/components/GscSourceDebugPanel.vue +195 -0
  17. package/app/components/GscSyncStatusCell.vue +54 -0
  18. package/app/components/GscTopRollupCard.vue +90 -0
  19. package/app/composables/_useGscBackfill.ts +111 -0
  20. package/app/composables/_useGscQueryDispatcher.ts +122 -0
  21. package/app/composables/_useGscResource.ts +114 -0
  22. package/app/composables/_useGscSharedSiteResource.ts +136 -0
  23. package/app/composables/useGscAnalytics.ts +197 -0
  24. package/app/composables/useGscAnalyticsClient.ts +9 -0
  25. package/app/composables/useGscAnalyticsConfig.ts +8 -0
  26. package/app/composables/useGscAnalyticsSourceInfo.ts +143 -0
  27. package/app/composables/useGscAnalyzer.ts +374 -0
  28. package/app/composables/useGscAnalyzerBatch.ts +106 -0
  29. package/app/composables/useGscAnalyzerDefs.ts +118 -0
  30. package/app/composables/useGscAuth.ts +115 -0
  31. package/app/composables/useGscConsoleUrl.ts +45 -0
  32. package/app/composables/useGscCountries.ts +47 -0
  33. package/app/composables/useGscCurrentSite.ts +15 -0
  34. package/app/composables/useGscEngine.ts +16 -0
  35. package/app/composables/useGscInspectionHistory.ts +42 -0
  36. package/app/composables/useGscInspections.ts +52 -0
  37. package/app/composables/useGscPanelContext.ts +24 -0
  38. package/app/composables/useGscParquetTable.ts +199 -0
  39. package/app/composables/useGscPeriod.ts +243 -0
  40. package/app/composables/useGscQuery.ts +290 -0
  41. package/app/composables/useGscQueryDispatcher.ts +122 -0
  42. package/app/composables/useGscRequestIndexingInspect.ts +20 -0
  43. package/app/composables/useGscResource.ts +114 -0
  44. package/app/composables/useGscRollup.ts +252 -0
  45. package/app/composables/useGscRollupTable.ts +78 -0
  46. package/app/composables/useGscRowQuery.ts +56 -0
  47. package/app/composables/useGscSearchAppearance.ts +47 -0
  48. package/app/composables/useGscSitemapHistory.ts +41 -0
  49. package/app/composables/useGscSitemaps.ts +45 -0
  50. package/app/composables/useGscTableState.ts +119 -0
  51. package/app/plugins/analytics.ts +57 -0
  52. package/app/utils/anonymization.ts +24 -0
  53. package/app/utils/country-names.ts +56 -0
  54. package/app/utils/gsc-constants.ts +10 -0
  55. package/app/utils/gsc-error.ts +58 -0
  56. package/app/utils/gsc-fetch.ts +94 -0
  57. package/app/utils/gsc-filters.ts +32 -0
  58. package/app/utils/gsc-rows.ts +72 -0
  59. package/app/utils/position.ts +7 -0
  60. package/app/utils/setup-gsc-fetch-auth.ts +62 -0
  61. package/module.ts +81 -0
  62. package/nuxt.config.ts +36 -0
  63. package/package.json +75 -0
  64. package/types.ts +114 -0
@@ -0,0 +1,195 @@
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
+ watchEffect(() => {
41
+ if (!import.meta.client || resolvedSite.value || !open.value || whoami.value || whoamiLoading.value)
42
+ return
43
+ whoamiLoading.value = true
44
+ const request = useGscAnalyticsClient().whoami() as Promise<WhoamiResponse>
45
+ request
46
+ .then((res) => { whoami.value = res })
47
+ .catch(() => { /* surface via empty state, not a toast */ })
48
+ .finally(() => { whoamiLoading.value = false })
49
+ })
50
+
51
+ function fmtKind(kind: 'row' | 'sql'): string {
52
+ return kind === 'sql' ? 'SQL' : 'Rows'
53
+ }
54
+ </script>
55
+
56
+ <template>
57
+ <ClientOnly>
58
+ <!-- Toggle affordance: always visible, bottom-right. -->
59
+ <button
60
+ type="button"
61
+ 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"
62
+ :title="open ? 'Hide data-source debug panel' : 'Show data-source debug panel'"
63
+ @click="open = !open"
64
+ >
65
+ <span class="size-1.5 rounded-full" :class="info ? (info.kind === 'sql' ? 'bg-primary' : 'bg-amber-500') : 'bg-dimmed'" />
66
+ src: {{ info?.name ?? (loading ? '…' : resolvedSite ? '—' : 'no site') }}
67
+ <UIcon :name="open ? 'i-lucide-chevron-down' : 'i-lucide-chevron-up'" class="size-3" />
68
+ </button>
69
+
70
+ <!-- Panel body -->
71
+ <div
72
+ v-if="open"
73
+ 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"
74
+ >
75
+ <header class="flex items-center justify-between px-3 py-2 border-b border-default">
76
+ <span class="font-semibold text-default">Analytics source</span>
77
+ <button type="button" class="text-dimmed hover:text-default" @click="open = false">
78
+ <UIcon name="i-lucide-x" class="size-3.5" />
79
+ </button>
80
+ </header>
81
+
82
+ <div v-if="!resolvedSite" class="flex flex-col divide-y divide-default">
83
+ <div class="px-3 py-2.5 text-dimmed text-[11px] leading-snug">
84
+ No site context on this route. Showing identity only.
85
+ </div>
86
+ <div v-if="whoamiLoading && !whoami" class="p-3 text-dimmed">
87
+ Resolving…
88
+ </div>
89
+ <div v-else-if="whoami" class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2.5">
90
+ <span class="text-dimmed">userId</span>
91
+ <span class="text-default truncate" :title="whoami.userId">{{ whoami.userId }}</span>
92
+
93
+ <span class="text-dimmed">sites</span>
94
+ <span class="text-default">
95
+ {{ whoami.siteIds == null ? 'all' : `${whoami.siteIds.length} scoped` }}
96
+ </span>
97
+
98
+ <span class="text-dimmed">source</span>
99
+ <span :class="whoami.sourceProviderRegistered ? 'text-success' : 'text-error'">
100
+ {{ whoami.sourceProviderRegistered ? 'registered' : 'not registered' }}
101
+ </span>
102
+
103
+ <template v-for="(value, key) in whoami.identityAttrs" :key="key">
104
+ <span class="text-dimmed">{{ key }}</span>
105
+ <span class="text-default truncate" :title="String(value)">{{ String(value) }}</span>
106
+ </template>
107
+ </div>
108
+ </div>
109
+ <div v-else-if="loading && !info" class="p-3 text-dimmed">
110
+ Resolving…
111
+ </div>
112
+ <div v-else-if="error" class="p-3 text-error">
113
+ <div class="font-semibold mb-1">
114
+ Failed to resolve source
115
+ </div>
116
+ <div class="text-[11px] leading-snug">
117
+ {{ error.message }}
118
+ </div>
119
+ </div>
120
+ <div v-else-if="info" class="flex flex-col divide-y divide-default">
121
+ <div class="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 px-3 py-2.5">
122
+ <span class="text-dimmed">site</span>
123
+ <span class="truncate text-default" :title="resolvedSite">{{ resolvedSite }}</span>
124
+
125
+ <span class="text-dimmed">name</span>
126
+ <span class="text-default">{{ info.name }}</span>
127
+
128
+ <span class="text-dimmed">kind</span>
129
+ <span>
130
+ <span
131
+ class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] uppercase tracking-wider font-semibold"
132
+ :class="info.kind === 'sql' ? 'bg-primary/10 text-primary' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400'"
133
+ >{{ fmtKind(info.kind) }}</span>
134
+ </span>
135
+
136
+ <span class="text-dimmed">attach</span>
137
+ <span :class="info.browserAttachEligible ? 'text-success' : 'text-muted'">
138
+ {{ info.browserAttachEligible ? 'browser-attached' : 'server-routed' }}
139
+ </span>
140
+
141
+ <span class="text-dimmed">analyzers</span>
142
+ <span class="text-default">
143
+ {{ info.supportedAnalyzerIds.length }}
144
+ <span class="text-dimmed">runnable</span>
145
+ </span>
146
+
147
+ <template v-for="(value, key) in (info.identityAttrs ?? {})" :key="key">
148
+ <span class="text-dimmed">{{ key }}</span>
149
+ <span class="text-default truncate" :title="String(value)">{{ String(value) }}</span>
150
+ </template>
151
+ </div>
152
+
153
+ <div class="px-3 py-2.5">
154
+ <div class="text-[10px] uppercase tracking-widest text-dimmed font-semibold mb-1.5">
155
+ capabilities
156
+ </div>
157
+ <div class="flex flex-wrap gap-1">
158
+ <template v-for="(value, cap) in info.capabilities" :key="cap">
159
+ <span
160
+ v-if="value"
161
+ class="inline-flex items-center px-1.5 py-0.5 rounded text-[10px] bg-elevated text-muted border border-default"
162
+ >
163
+ {{ cap }}
164
+ </span>
165
+ </template>
166
+ <span
167
+ v-if="!Object.values(info.capabilities).some(Boolean)"
168
+ class="text-[11px] text-dimmed"
169
+ >
170
+ none advertised
171
+ </span>
172
+ </div>
173
+ </div>
174
+
175
+ <details class="px-3 py-2.5">
176
+ <summary class="text-[10px] uppercase tracking-widest text-dimmed font-semibold cursor-pointer">
177
+ supported analyzer ids ({{ info.supportedAnalyzerIds.length }})
178
+ </summary>
179
+ <div class="mt-2 max-h-48 overflow-y-auto">
180
+ <div
181
+ v-for="id in info.supportedAnalyzerIds"
182
+ :key="id"
183
+ class="text-[11px] text-muted py-0.5"
184
+ >
185
+ {{ id }}
186
+ </div>
187
+ <div v-if="!info.supportedAnalyzerIds.length" class="text-[11px] text-dimmed">
188
+ none
189
+ </div>
190
+ </div>
191
+ </details>
192
+ </div>
193
+ </div>
194
+ </ClientOnly>
195
+ </template>
@@ -0,0 +1,54 @@
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>
@@ -0,0 +1,90 @@
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>
@@ -0,0 +1,111 @@
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 { useGscFetch } from '../utils/gsc-fetch'
6
+ import { useGscAnalyticsClient } from './useGscAnalyticsClient'
7
+
8
+ interface BackfillRange {
9
+ startDate: string
10
+ endDate: string
11
+ }
12
+
13
+ interface SyncProgressResponse {
14
+ sites?: Array<{
15
+ id: string
16
+ oldestDateSynced?: string | null
17
+ newestDateSynced?: string | null
18
+ syncStatus?: string
19
+ backfill?: { percent?: number }
20
+ }>
21
+ }
22
+
23
+ export interface GscBackfillState {
24
+ pending: Ref<boolean>
25
+ range: Ref<BackfillRange | null>
26
+ percent: Ref<number>
27
+ error: Ref<string | null>
28
+ }
29
+
30
+ const POLL_INTERVAL_MS = 4000
31
+ const POLL_MAX_MS = 15 * 60 * 1000
32
+
33
+ export function useGscBackfill(): GscBackfillState & {
34
+ request: (siteId: string, req: BackfillRange, refetch: () => Promise<unknown> | unknown) => Promise<void>
35
+ maybeTrigger: (meta: unknown, siteId: string, refetch: () => Promise<unknown> | unknown) => boolean
36
+ reset: () => void
37
+ } {
38
+ const pending = ref(false)
39
+ const range = ref<BackfillRange | null>(null)
40
+ const percent = ref(0)
41
+ const error = ref<string | null>(null)
42
+ // Track ranges we've already attempted in this session so a failed/timed-out
43
+ // backfill doesn't retrigger on every refetch.
44
+ const attemptedKeys = new Set<string>()
45
+
46
+ function rangeKey(siteId: string, r: BackfillRange): string {
47
+ return `${siteId}:${r.startDate}:${r.endDate}`
48
+ }
49
+
50
+ async function isRangeCovered(siteId: string, req: BackfillRange): Promise<boolean> {
51
+ const progress = await useGscFetch()<SyncProgressResponse>('/api/sync-progress').catch(() => null)
52
+ const site = progress?.sites?.find(s => s.id === siteId)
53
+ if (!site?.oldestDateSynced || !site?.newestDateSynced)
54
+ return false
55
+ percent.value = site.backfill?.percent ?? percent.value
56
+ return req.startDate >= site.oldestDateSynced && req.endDate <= site.newestDateSynced
57
+ }
58
+
59
+ async function request(siteId: string, req: BackfillRange, refetch: () => Promise<unknown> | unknown): Promise<void> {
60
+ pending.value = true
61
+ range.value = req
62
+ percent.value = 0
63
+ error.value = null
64
+ attemptedKeys.add(rangeKey(siteId, req))
65
+
66
+ await useGscAnalyticsClient().requestBackfill(siteId, req).catch((err) => {
67
+ error.value = (err as { data?: { message?: string }, message?: string })?.data?.message
68
+ || (err as Error)?.message
69
+ || 'Failed to queue backfill'
70
+ })
71
+
72
+ if (error.value) {
73
+ pending.value = false
74
+ return
75
+ }
76
+
77
+ const started = Date.now()
78
+ let covered = false
79
+ while (Date.now() - started < POLL_MAX_MS) {
80
+ await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
81
+ covered = await isRangeCovered(siteId, req)
82
+ if (covered)
83
+ break
84
+ }
85
+
86
+ if (!covered)
87
+ error.value = 'Backfill did not complete within the expected window. Try again later.'
88
+
89
+ if (covered)
90
+ await refetch()
91
+ pending.value = false
92
+ range.value = null
93
+ }
94
+
95
+ function maybeTrigger(meta: unknown, siteId: string, refetch: () => Promise<unknown> | unknown): boolean {
96
+ const backfillRequired = (meta as { backfillRequired?: BackfillRange })?.backfillRequired
97
+ if (!backfillRequired || pending.value)
98
+ return false
99
+ if (attemptedKeys.has(rangeKey(siteId, backfillRequired)))
100
+ return false
101
+ request(siteId, backfillRequired, refetch)
102
+ return true
103
+ }
104
+
105
+ function reset(): void {
106
+ attemptedKeys.clear()
107
+ error.value = null
108
+ }
109
+
110
+ return { pending, range, percent, error, request, maybeTrigger, reset }
111
+ }
@@ -0,0 +1,122 @@
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
+ import { useGscEngine } from './useGscEngine'
11
+
12
+ type InternalAuthState = ReturnType<typeof _useGscAuthInternal>['value']
13
+
14
+ export interface GscEngineDecision {
15
+ mode: 'browser' | 'server'
16
+ reason: GscQueryDecisionReason
17
+ /** The resolved intent before the auth/optin mapping — useful for callers that branch on "did the user ask for auto?". */
18
+ requested: GscQueryEngine
19
+ detail?: string
20
+ }
21
+
22
+ export interface GscFallbackEvent {
23
+ reason: string
24
+ at: number
25
+ url: string
26
+ }
27
+
28
+ export interface PickEngineOpts {
29
+ /** Per-call override. Wins over `useGscEngine()` + `runtimeConfig.public.analytics.defaultEngine`. */
30
+ perCall?: GscQueryEngine
31
+ }
32
+
33
+ export interface GscQueryDispatcher {
34
+ /**
35
+ * Resolve the active engine into a concrete mode + reason. Consults the
36
+ * resolution chain: `opts.perCall` → `useGscEngine()` → runtimeConfig →
37
+ * `'auto'`. Then maps `auto` against `auth.browserAnalyzerEnabled`.
38
+ */
39
+ pickEngine: (auth: InternalAuthState, opts?: PickEngineOpts) => GscEngineDecision
40
+ /** Called once per auto-fallback. Default impl is a no-op. */
41
+ reportFallback: (event: GscFallbackEvent) => void
42
+ }
43
+
44
+ export interface CreateDefaultDispatcherOpts {
45
+ /** When set, fallbacks beacon to this URL with batched payloads. */
46
+ telemetryEndpoint?: string
47
+ }
48
+
49
+ const FLUSH_DELAY_MS = 5000
50
+
51
+ function createReporter(endpoint: string): (event: GscFallbackEvent) => void {
52
+ const buffer: GscFallbackEvent[] = []
53
+ let flushScheduled = false
54
+
55
+ function flush(): void {
56
+ flushScheduled = false
57
+ if (buffer.length === 0)
58
+ return
59
+ const events = buffer.splice(0)
60
+ const body = JSON.stringify({ events })
61
+ if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
62
+ navigator.sendBeacon(endpoint, new Blob([body], { type: 'application/json' }))
63
+ return
64
+ }
65
+ fetch(endpoint, { method: 'POST', body, headers: { 'content-type': 'application/json' } }).catch(() => {})
66
+ }
67
+
68
+ function scheduleFlush(): void {
69
+ if (flushScheduled || typeof window === 'undefined')
70
+ return
71
+ flushScheduled = true
72
+ setTimeout(flush, FLUSH_DELAY_MS)
73
+ if (typeof document !== 'undefined') {
74
+ const handler = (): void => {
75
+ if (document.visibilityState === 'hidden')
76
+ flush()
77
+ }
78
+ document.addEventListener('visibilitychange', handler, { once: true })
79
+ }
80
+ }
81
+
82
+ return (event: GscFallbackEvent): void => {
83
+ buffer.push(event)
84
+ scheduleFlush()
85
+ }
86
+ }
87
+
88
+ export function createDefaultGscQueryDispatcher(
89
+ opts: CreateDefaultDispatcherOpts = {},
90
+ ): GscQueryDispatcher {
91
+ const reportFallback = opts.telemetryEndpoint
92
+ ? createReporter(opts.telemetryEndpoint)
93
+ : (): void => {}
94
+
95
+ return {
96
+ pickEngine(auth, opts = {}): GscEngineDecision {
97
+ const requested = opts.perCall ?? resolveDefaultEngine()
98
+ if (requested === 'server')
99
+ return { mode: 'server', reason: 'forced:server', requested }
100
+ if (requested === 'browser')
101
+ return { mode: 'browser', reason: 'forced:browser', requested }
102
+ // 'auto': when the host has wired setGscAuth, derive from the per-user
103
+ // browserAnalyzerEnabled flag. When unwired, preserve legacy 'auto' = browser.
104
+ if (auth._initialized && !auth.browserAnalyzerEnabled)
105
+ return { mode: 'server', reason: 'optin:off', requested }
106
+ return { mode: 'browser', reason: 'auto:browser', requested }
107
+ },
108
+ reportFallback,
109
+ }
110
+ }
111
+
112
+ function resolveDefaultEngine(): GscQueryEngine {
113
+ const override = useGscEngine().value
114
+ if (override)
115
+ return override
116
+ const cfg = useRuntimeConfig().public.analytics as { defaultEngine?: GscQueryEngine } | undefined
117
+ return cfg?.defaultEngine ?? 'auto'
118
+ }
119
+
120
+ export function useGscQueryDispatcher(): GscQueryDispatcher {
121
+ return useNuxtApp().$gscQueryDispatcher
122
+ }
@@ -0,0 +1,114 @@
1
+ // Site-keyed async resource composable. Owns key-watch, stale-token discard,
2
+ // status classification, refresh, and dispose for every read-only `/api/__gsc/*`
3
+ // fetcher in the layer. Resource composables (useGscSitemaps,
4
+ // useGscIndexingDiagnostics, …) are thin adapters that wire reactive keys to
5
+ // `useGscAnalyticsClient().getX(...)` plus optional derived computeds.
6
+ //
7
+ // Stale-token (not AbortController) because `@gscdump/sdk`'s AnalyticsClient
8
+ // doesn't accept a signal. Late-arriving promises from a superseded run are
9
+ // dropped on `data`/`status` writes; the in-flight fetch still runs to
10
+ // completion, which is acceptable for read-only GETs.
11
+
12
+ import type { ComputedRef, Ref, WatchSource } from '@vue/runtime-core'
13
+ import type { GscErrorStatus } from '../utils/gsc-error'
14
+ import { classifyGscError } from '../utils/gsc-error'
15
+
16
+ export type GscResourceStatus = 'idle' | 'pending' | 'success' | 'empty' | GscErrorStatus
17
+
18
+ export interface UseGscResourceOptions<TArgs extends readonly unknown[], TData> {
19
+ /** Reactive args passed to the fetcher. Resource stays idle while any required key is null/undefined. */
20
+ keys: { [I in keyof TArgs]: MaybeRefOrGetter<TArgs[I] | null | undefined> }
21
+ /** Async fetcher invoked with the resolved keys. */
22
+ fetcher: (...args: TArgs) => Promise<TData | null>
23
+ /** Predicate for the `empty` status — defaults to checking `null`/`[]`/typical container fields. */
24
+ isEmpty?: (data: TData) => boolean
25
+ /** Extra reactive sources that should retrigger a fetch. */
26
+ watchSources?: WatchSource[]
27
+ }
28
+
29
+ export interface UseGscResourceReturn<TData> {
30
+ data: Ref<TData | null>
31
+ status: Ref<GscResourceStatus>
32
+ loading: ComputedRef<boolean>
33
+ error: Ref<Error | null>
34
+ refresh: () => Promise<void>
35
+ }
36
+
37
+ function defaultIsEmpty(v: unknown): boolean {
38
+ if (v == null)
39
+ return true
40
+ if (Array.isArray(v))
41
+ return v.length === 0
42
+ if (typeof v === 'object') {
43
+ const rec = v as Record<string, unknown>
44
+ for (const key of ['rows', 'records', 'snapshots', 'items', 'results']) {
45
+ const arr = rec[key]
46
+ if (Array.isArray(arr))
47
+ return arr.length === 0
48
+ }
49
+ }
50
+ return false
51
+ }
52
+
53
+ export function useGscResource<TArgs extends readonly unknown[], TData>(
54
+ opts: UseGscResourceOptions<TArgs, TData>,
55
+ ): UseGscResourceReturn<TData> {
56
+ const data = shallowRef<TData | null>(null)
57
+ const status = ref<GscResourceStatus>('idle')
58
+ const error = ref<Error | null>(null)
59
+ const loading = computed(() => status.value === 'pending')
60
+
61
+ let runToken = 0
62
+
63
+ function resolveKeys(): TArgs | null {
64
+ const out: unknown[] = []
65
+ for (const k of opts.keys) {
66
+ const v = toValue(k)
67
+ if (v == null || v === '')
68
+ return null
69
+ out.push(v)
70
+ }
71
+ return out as unknown as TArgs
72
+ }
73
+
74
+ async function refresh(): Promise<void> {
75
+ const args = resolveKeys()
76
+ const token = ++runToken
77
+ if (!args) {
78
+ data.value = null
79
+ status.value = 'idle'
80
+ error.value = null
81
+ return
82
+ }
83
+ status.value = 'pending'
84
+ error.value = null
85
+ try {
86
+ const out = await opts.fetcher(...args)
87
+ if (token !== runToken)
88
+ return
89
+ data.value = out
90
+ const empty = out == null || (opts.isEmpty ?? defaultIsEmpty)(out)
91
+ status.value = empty ? 'empty' : 'success'
92
+ }
93
+ catch (e) {
94
+ if (token !== runToken)
95
+ return
96
+ const classified = classifyGscError(e)
97
+ error.value = e instanceof Error ? e : new Error(String(e))
98
+ status.value = classified.status
99
+ data.value = null
100
+ }
101
+ }
102
+
103
+ watch(
104
+ [...opts.keys.map(k => () => toValue(k)), ...(opts.watchSources ?? [])],
105
+ refresh,
106
+ { immediate: true },
107
+ )
108
+
109
+ onScopeDispose(() => {
110
+ runToken++
111
+ })
112
+
113
+ return { data, status, loading, error, refresh }
114
+ }