@gscdump/nuxt 0.21.3 → 0.22.4
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.
- package/README.md +36 -27
- package/app/components/GscDashboardPage.vue +2 -2
- package/app/components/GscHero.vue +3 -4
- package/app/components/GscLazyTopResult.vue +7 -5
- package/app/components/GscPerformanceChart.vue +4 -4
- package/app/components/GscSourceDebugPanel.vue +2 -1
- package/app/composables/_useGscBackfill.ts +13 -5
- package/app/composables/_useGscQueryDispatcher.ts +5 -4
- package/app/composables/_useGscResource.ts +144 -56
- package/app/composables/useGscAnalytics.ts +13 -4
- package/app/composables/useGscAnalyticsSourceInfo.ts +11 -5
- package/app/composables/useGscAnalyzer.ts +33 -25
- package/app/composables/useGscAnalyzerBatch.ts +2 -2
- package/app/composables/useGscAnalyzerDefs.ts +2 -1
- package/app/composables/useGscCountries.ts +5 -6
- package/app/composables/useGscCurrentSite.ts +32 -11
- package/app/composables/useGscInspectionHistory.ts +5 -5
- package/app/composables/useGscInspections.ts +5 -5
- package/app/composables/useGscPanelContext.ts +9 -2
- package/app/composables/useGscQuery.ts +6 -8
- package/app/composables/useGscQueryDispatcher.ts +14 -122
- package/app/composables/useGscRequestIndexingInspect.ts +20 -11
- package/app/composables/useGscResource.ts +11 -114
- package/app/composables/useGscRollup.ts +66 -81
- package/app/composables/useGscSearchAppearance.ts +5 -6
- package/app/composables/useGscSiteAnalyzer.ts +456 -0
- package/app/composables/useGscSitemapHistory.ts +5 -5
- package/app/composables/useGscSitemaps.ts +5 -5
- package/app/queries/gsc.ts +163 -0
- package/app/utils/duckdb-wasm.ts +166 -0
- package/app/utils/gsc-rpc.ts +18 -0
- package/module.ts +1 -1
- package/nuxt.config.ts +25 -0
- package/package.json +140 -8
- package/types.ts +8 -7
- package/app/composables/useGscEngine.ts +0 -16
- package/app/composables/useGscParquetTable.ts +0 -199
- package/app/composables/useGscRollupTable.ts +0 -78
- package/app/composables/useGscRowQuery.ts +0 -56
- package/app/composables/useGscSnapshotAnalyzer.contract.ts +0 -150
- package/app/composables/useGscSnapshotAnalyzer.routing.ts +0 -87
- package/app/composables/useGscSnapshotAnalyzer.ts +0 -559
- package/app/utils/setup-gsc-fetch-auth.ts +0 -62
package/README.md
CHANGED
|
@@ -31,13 +31,25 @@ pnpm add @gscdump/nuxt
|
|
|
31
31
|
export default defineNuxtConfig({
|
|
32
32
|
extends: ['@gscdump/nuxt'],
|
|
33
33
|
runtimeConfig: {
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
public: {
|
|
35
|
+
analytics: {
|
|
36
|
+
apiBase: '', // same-origin by default; set to your analytics origin in consumer mode
|
|
37
|
+
},
|
|
36
38
|
},
|
|
37
39
|
},
|
|
38
40
|
})
|
|
39
41
|
```
|
|
40
42
|
|
|
43
|
+
Nuxt auto-imports the layer composables. If you prefer explicit imports, use
|
|
44
|
+
per-composable entrypoints:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
import { setGscAuth } from '@gscdump/nuxt/composables/useGscAuth'
|
|
48
|
+
import { useGscRollup } from '@gscdump/nuxt/composables/useGscRollup'
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The primary query interface is `useGscQuery` / `useGscAnalyzer`.
|
|
52
|
+
|
|
41
53
|
## Register an auth provider
|
|
42
54
|
|
|
43
55
|
```ts
|
|
@@ -60,11 +72,11 @@ export default defineNitroPlugin((nitro) => {
|
|
|
60
72
|
|
|
61
73
|
| Need | Endpoint | Client composable |
|
|
62
74
|
| --- | --- | --- |
|
|
63
|
-
| Pre-aggregated widgets (top pages, daily totals) | `GET /api/sites/:siteId/rollup/:id` | `useGscRollup` / `useGscRollups` / `useGscRollupFanout` |
|
|
64
|
-
| Raw rows filtered by dimension/range (`where(eq(page, ...))`) | `POST /api/sites/:siteId/rows` | `
|
|
65
|
-
| Analyzer result (striking-distance, CTR anomaly, …) | `POST /api/sites/:siteId/analyze` | `useGscAnalyzer(siteId).analyze(...)` |
|
|
75
|
+
| Pre-aggregated widgets (top pages, daily totals) | `GET /api/__gsc/sites/:siteId/rollup/:id` | `useGscRollup` / `useGscRollups` / `useGscRollupFanout` |
|
|
76
|
+
| Raw rows filtered by dimension/range (`where(eq(page, ...))`) | `POST /api/__gsc/sites/:siteId/rows` | `useGscQuery({ siteId, params })` |
|
|
77
|
+
| Analyzer result (striking-distance, CTR anomaly, …) | `POST /api/__gsc/sites/:siteId/analyze` | `useGscAnalyzer(siteId).analyze(...)` |
|
|
66
78
|
|
|
67
|
-
Rule of thumb: reach for `/rows` + `
|
|
79
|
+
Rule of thumb: reach for `/rows` + `useGscQuery` for detail pages that
|
|
68
80
|
need a freeform slice of the data. Only fall back to `/analyze` when the
|
|
69
81
|
server runs an actual analyzer (anything in the registry); hitting it for
|
|
70
82
|
plain row lookups routes through analyzer-gating logic you don't need.
|
|
@@ -78,39 +90,36 @@ the data — e.g. nuxtseo.com pro consuming gscdump.com. Two pieces:
|
|
|
78
90
|
in `nuxt.config.ts` (or via `NUXT_PUBLIC_ANALYTICS_API_BASE`). Empty =
|
|
79
91
|
same-origin (the default origin-mode shape).
|
|
80
92
|
|
|
81
|
-
2. **Hand the layer
|
|
82
|
-
|
|
83
|
-
`
|
|
93
|
+
2. **Hand the layer per-viewer auth**: register a client plugin that trades
|
|
94
|
+
the viewer's host session for an api key, then call
|
|
95
|
+
`setGscAuth({ apiKey: key })`. The layer's `useGscFetch`
|
|
84
96
|
attaches the header to every `/api/__gsc/*` call automatically. Browser
|
|
85
97
|
parquet reads use exact-object URLs minted by the source endpoint; runtime
|
|
86
98
|
preflights can include this header, while DuckDB-WASM range reads are
|
|
87
99
|
authorized by the token embedded in each URL.
|
|
88
100
|
|
|
89
|
-
|
|
101
|
+
Wire it from a `'pre'`-enforced client plugin so downstream layer plugins see
|
|
102
|
+
the auth on first read. The layer's `setGscAuth` accepts a value, a `Ref`,
|
|
103
|
+
or a getter — use a getter when credentials may refresh during the session:
|
|
90
104
|
|
|
91
105
|
```ts
|
|
92
106
|
// plugins/00.gscdump-auth.client.ts
|
|
93
|
-
export default
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
flag.value = !!settings?.browserAnalyzerEnabled
|
|
107
|
+
export default defineNuxtPlugin({
|
|
108
|
+
name: 'gscdump-auth',
|
|
109
|
+
enforce: 'pre',
|
|
110
|
+
async setup() {
|
|
111
|
+
const creds = ref<{ apiKey: string, browserAnalyzerEnabled?: boolean } | null>(null)
|
|
112
|
+
creds.value = await $fetch('/api/me/gscdump-credentials').catch(() => null)
|
|
113
|
+
setGscAuth(() => ({
|
|
114
|
+
apiKey: creds.value?.apiKey ?? null,
|
|
115
|
+
browserAnalyzerEnabled: !!creds.value?.browserAnalyzerEnabled,
|
|
116
|
+
}))
|
|
104
117
|
},
|
|
105
118
|
})
|
|
106
119
|
```
|
|
107
120
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
where a `useGscQuery` mounts before credentials resolve.
|
|
111
|
-
|
|
112
|
-
`credentialsEndpoint` returns `{ apiKey: string, ... }` by default. Override
|
|
113
|
-
the field name with `tokenField` and the header name with `headerName`.
|
|
121
|
+
`useGscQuery` mounts that race the credential fetch resolve once the
|
|
122
|
+
`'pre'` plugin's `await` settles — Nuxt blocks subsequent plugins on it.
|
|
114
123
|
|
|
115
124
|
Onboarding and lifecycle state stay outside this layer. A consumer app should
|
|
116
125
|
use `@gscdump/sdk` from its host backend to register users/sites, read
|
|
@@ -14,10 +14,10 @@ const gapClass = computed(() => (
|
|
|
14
14
|
</script>
|
|
15
15
|
|
|
16
16
|
<template>
|
|
17
|
-
<div class="flex flex-col min-h-screen">
|
|
17
|
+
<div class="flex flex-col min-h-screen overflow-x-clip">
|
|
18
18
|
<slot name="header" />
|
|
19
19
|
<div
|
|
20
|
-
class="max-w-[1128px] px-4 sm:px-6 lg:px-9 pt-4 pb-10 flex flex-col flex-1 w-full"
|
|
20
|
+
class="max-w-[1128px] min-w-0 px-4 sm:px-6 lg:px-9 pt-4 pb-10 flex flex-col flex-1 w-full"
|
|
21
21
|
:class="gapClass"
|
|
22
22
|
>
|
|
23
23
|
<slot />
|
|
@@ -204,10 +204,9 @@ const chartPrevData = computed(() => stats.value?.previous?.series ?? [])
|
|
|
204
204
|
v-if="stats && chartData.length"
|
|
205
205
|
class="rounded-lg border border-default bg-default p-4"
|
|
206
206
|
>
|
|
207
|
-
<
|
|
208
|
-
:
|
|
209
|
-
:
|
|
210
|
-
:show-comparison="compareMode !== 'none' && chartPrevData.length > 0"
|
|
207
|
+
<GscPerformanceChart
|
|
208
|
+
:value="chartData"
|
|
209
|
+
:prev-value="compareMode !== 'none' ? chartPrevData : null"
|
|
211
210
|
:height="220"
|
|
212
211
|
/>
|
|
213
212
|
</div>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
2
|
import { useIntersectionObserver } from '@vueuse/core'
|
|
3
|
-
import {
|
|
3
|
+
import { gscQueries } from '../queries/gsc'
|
|
4
|
+
import { useGscRpc } from '../utils/gsc-rpc'
|
|
4
5
|
|
|
5
6
|
const props = defineProps<{
|
|
6
7
|
siteUrl: string
|
|
@@ -21,14 +22,15 @@ const { stop } = useIntersectionObserver(
|
|
|
21
22
|
return
|
|
22
23
|
loaded.value = true
|
|
23
24
|
stop()
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
const request = useGscRpc().query(
|
|
26
|
+
gscQueries.topAssociation(props.siteUrl, {
|
|
26
27
|
type: props.type,
|
|
27
28
|
identifier: props.identifier,
|
|
28
29
|
startDate: props.startDate,
|
|
29
30
|
endDate: props.endDate,
|
|
30
|
-
},
|
|
31
|
-
}
|
|
31
|
+
}),
|
|
32
|
+
) as unknown as Promise<{ value: string | null }>
|
|
33
|
+
request
|
|
32
34
|
.then(r => value.value = r.value)
|
|
33
35
|
.catch(() => {})
|
|
34
36
|
},
|
|
@@ -297,11 +297,11 @@ const position = (d: DataRow) => d.position ?? 0
|
|
|
297
297
|
<div v-if="loading" class="loading-skeleton">
|
|
298
298
|
<div class="flex-1 flex items-end gap-1">
|
|
299
299
|
<div v-for="i in 30" :key="i" class="flex-1 h-full flex">
|
|
300
|
-
<
|
|
300
|
+
<USkeleton class="w-full self-end" :style="{ height: `${28 + ((i * 17) % 56)}%` }" />
|
|
301
301
|
</div>
|
|
302
302
|
</div>
|
|
303
303
|
<div class="flex justify-between mt-3">
|
|
304
|
-
<
|
|
304
|
+
<USkeleton v-for="i in 5" :key="i" class="h-3 w-12" />
|
|
305
305
|
</div>
|
|
306
306
|
</div>
|
|
307
307
|
|
|
@@ -444,11 +444,11 @@ const position = (d: DataRow) => d.position ?? 0
|
|
|
444
444
|
<div class="loading-skeleton">
|
|
445
445
|
<div class="flex-1 flex items-end gap-1">
|
|
446
446
|
<div v-for="i in 30" :key="i" class="flex-1">
|
|
447
|
-
<
|
|
447
|
+
<USkeleton class="w-full self-end" :style="{ height: `${28 + ((i * 17) % 56)}%` }" />
|
|
448
448
|
</div>
|
|
449
449
|
</div>
|
|
450
450
|
<div class="flex justify-between mt-3">
|
|
451
|
-
<
|
|
451
|
+
<USkeleton v-for="i in 5" :key="i" class="h-3 w-12" />
|
|
452
452
|
</div>
|
|
453
453
|
</div>
|
|
454
454
|
</template>
|
|
@@ -37,11 +37,12 @@ interface WhoamiResponse {
|
|
|
37
37
|
}
|
|
38
38
|
const whoami = ref<WhoamiResponse | null>(null)
|
|
39
39
|
const whoamiLoading = ref(false)
|
|
40
|
+
const analyticsClient = useGscAnalyticsClient()
|
|
40
41
|
watchEffect(() => {
|
|
41
42
|
if (!import.meta.client || resolvedSite.value || !open.value || whoami.value || whoamiLoading.value)
|
|
42
43
|
return
|
|
43
44
|
whoamiLoading.value = true
|
|
44
|
-
const request =
|
|
45
|
+
const request = analyticsClient.whoami() as Promise<WhoamiResponse>
|
|
45
46
|
request
|
|
46
47
|
.then((res) => { whoami.value = res })
|
|
47
48
|
.catch(() => { /* surface via empty state, not a toast */ })
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
// on-demand backfill request, polls sync-progress, and invokes a refetch once
|
|
3
3
|
// the requested range is synced.
|
|
4
4
|
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { invalidateNuxtQueries } from 'nuxt-use-query/query-cache'
|
|
6
|
+
import { gscQueries } from '../queries/gsc'
|
|
7
|
+
import { useGscRpc } from '../utils/gsc-rpc'
|
|
7
8
|
|
|
8
9
|
interface BackfillRange {
|
|
9
10
|
startDate: string
|
|
@@ -35,6 +36,7 @@ export function useGscBackfill(): GscBackfillState & {
|
|
|
35
36
|
maybeTrigger: (meta: unknown, siteId: string, refetch: () => Promise<unknown> | unknown) => boolean
|
|
36
37
|
reset: () => void
|
|
37
38
|
} {
|
|
39
|
+
const rpc = useGscRpc()
|
|
38
40
|
const pending = ref(false)
|
|
39
41
|
const range = ref<BackfillRange | null>(null)
|
|
40
42
|
const percent = ref(0)
|
|
@@ -48,7 +50,7 @@ export function useGscBackfill(): GscBackfillState & {
|
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
async function isRangeCovered(siteId: string, req: BackfillRange): Promise<boolean> {
|
|
51
|
-
const progress = await
|
|
53
|
+
const progress = await rpc.query(gscQueries.syncProgress(), { silent: true }).catch(() => null) as SyncProgressResponse | null
|
|
52
54
|
const site = progress?.sites?.find(s => s.id === siteId)
|
|
53
55
|
if (!site?.oldestDateSynced || !site?.newestDateSynced)
|
|
54
56
|
return false
|
|
@@ -63,7 +65,7 @@ export function useGscBackfill(): GscBackfillState & {
|
|
|
63
65
|
error.value = null
|
|
64
66
|
attemptedKeys.add(rangeKey(siteId, req))
|
|
65
67
|
|
|
66
|
-
await
|
|
68
|
+
await rpc.execute(gscQueries.backfill(siteId), { ...req }, { silent: true }).catch((err: unknown) => {
|
|
67
69
|
error.value = (err as { data?: { message?: string }, message?: string })?.data?.message
|
|
68
70
|
|| (err as Error)?.message
|
|
69
71
|
|| 'Failed to queue backfill'
|
|
@@ -86,8 +88,14 @@ export function useGscBackfill(): GscBackfillState & {
|
|
|
86
88
|
if (!covered)
|
|
87
89
|
error.value = 'Backfill did not complete within the expected window. Try again later.'
|
|
88
90
|
|
|
89
|
-
if (covered)
|
|
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')
|
|
90
97
|
await refetch()
|
|
98
|
+
}
|
|
91
99
|
pending.value = false
|
|
92
100
|
range.value = null
|
|
93
101
|
}
|
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
|
|
8
8
|
import type { _useGscAuthInternal } from './useGscAuth'
|
|
9
9
|
import type { GscQueryDecisionReason, GscQueryEngine } from './useGscQuery'
|
|
10
|
-
|
|
10
|
+
|
|
11
|
+
const ENGINE_STATE_KEY = 'gscdump:engine'
|
|
11
12
|
|
|
12
13
|
type InternalAuthState = ReturnType<typeof _useGscAuthInternal>['value']
|
|
13
14
|
|
|
@@ -26,14 +27,14 @@ export interface GscFallbackEvent {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
export interface PickEngineOpts {
|
|
29
|
-
/** Per-call override. Wins over `
|
|
30
|
+
/** Per-call override. Wins over the `gscdump:engine` useState + `runtimeConfig.public.analytics.defaultEngine`. */
|
|
30
31
|
perCall?: GscQueryEngine
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
export interface GscQueryDispatcher {
|
|
34
35
|
/**
|
|
35
36
|
* Resolve the active engine into a concrete mode + reason. Consults the
|
|
36
|
-
* resolution chain: `opts.perCall` → `
|
|
37
|
+
* resolution chain: `opts.perCall` → `gscdump:engine` useState → runtimeConfig →
|
|
37
38
|
* `'auto'`. Then maps `auto` against `auth.browserAnalyzerEnabled`.
|
|
38
39
|
*/
|
|
39
40
|
pickEngine: (auth: InternalAuthState, opts?: PickEngineOpts) => GscEngineDecision
|
|
@@ -110,7 +111,7 @@ export function createDefaultGscQueryDispatcher(
|
|
|
110
111
|
}
|
|
111
112
|
|
|
112
113
|
function resolveDefaultEngine(): GscQueryEngine {
|
|
113
|
-
const override =
|
|
114
|
+
const override = useState<GscQueryEngine | null>(ENGINE_STATE_KEY, () => null).value
|
|
114
115
|
if (override)
|
|
115
116
|
return override
|
|
116
117
|
const cfg = useRuntimeConfig().public.analytics as { defaultEngine?: GscQueryEngine } | undefined
|
|
@@ -1,36 +1,42 @@
|
|
|
1
|
-
// Site-keyed async resource composable. Owns key-watch,
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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.
|
|
1
|
+
// Site-keyed async resource composable. Owns key-watch, status classification,
|
|
2
|
+
// refresh, and cache policy for every read-only `/api/__gsc/*` operation in
|
|
3
|
+
// the layer. Resource composables stay thin adapters that wire reactive keys
|
|
4
|
+
// to `gscQueries.*(...)` operations (preferred) — or to a `fetcher` for the
|
|
5
|
+
// rollup composables that mix multiple operations + progress side-effects.
|
|
11
6
|
|
|
12
7
|
import type { ComputedRef, Ref, WatchSource } from '@vue/runtime-core'
|
|
8
|
+
import type { NuxtRpcQueryOperation } from 'nuxt-use-query/rpc'
|
|
13
9
|
import type { GscErrorStatus } from '../utils/gsc-error'
|
|
10
|
+
import { useNuxtRpcQuery } from 'nuxt-use-query/rpc'
|
|
14
11
|
import { classifyGscError } from '../utils/gsc-error'
|
|
12
|
+
import { useGscFetch } from '../utils/gsc-fetch'
|
|
15
13
|
|
|
16
14
|
export type GscResourceStatus = 'idle' | 'pending' | 'success' | 'empty' | GscErrorStatus
|
|
17
15
|
|
|
18
16
|
export interface UseGscResourceOptions<TArgs extends readonly unknown[], TData> {
|
|
19
17
|
/** Reactive args passed to the fetcher. Resource stays idle while any required key is null/undefined. */
|
|
20
18
|
keys: { [I in keyof TArgs]: MaybeRefOrGetter<TArgs[I] | null | undefined> }
|
|
21
|
-
/**
|
|
22
|
-
|
|
19
|
+
/** RPC query operation invoked with the resolved keys. Preferred over `fetcher` — gets Zod validation + shared cache keying. */
|
|
20
|
+
operation?: (...args: TArgs) => NuxtRpcQueryOperation<any, any>
|
|
21
|
+
/** Composite async fetcher invoked with the resolved keys. Use only when an operation can't model the request (multi-call, progress side-effects). */
|
|
22
|
+
fetcher?: (...args: TArgs) => Promise<TData | null>
|
|
23
23
|
/** Predicate for the `empty` status — defaults to checking `null`/`[]`/typical container fields. */
|
|
24
24
|
isEmpty?: (data: TData) => boolean
|
|
25
25
|
/** Extra reactive sources that should retrigger a fetch. */
|
|
26
26
|
watchSources?: WatchSource[]
|
|
27
|
+
/** Optional namespace for the fetcher-mode cache key. Defaults to `gsc-resource`. Ignored when `operation` is provided (the operation owns its key). */
|
|
28
|
+
namespace?: string
|
|
29
|
+
/** Milliseconds before cached data is considered stale. Defaults to `nuxt-use-query`'s 0. */
|
|
30
|
+
staleTime?: number
|
|
31
|
+
/** Evict cached payload after the last consumer unmounts. Defaults to `nuxt-use-query`'s 5 min. */
|
|
32
|
+
gcTime?: number
|
|
27
33
|
}
|
|
28
34
|
|
|
29
35
|
export interface UseGscResourceReturn<TData> {
|
|
30
|
-
data: Ref<TData | null
|
|
31
|
-
status: Ref<GscResourceStatus
|
|
36
|
+
data: Readonly<Ref<TData | null>>
|
|
37
|
+
status: Readonly<Ref<GscResourceStatus>>
|
|
32
38
|
loading: ComputedRef<boolean>
|
|
33
|
-
error: Ref<Error | null
|
|
39
|
+
error: Readonly<Ref<Error | null>>
|
|
34
40
|
refresh: () => Promise<void>
|
|
35
41
|
}
|
|
36
42
|
|
|
@@ -53,14 +59,8 @@ function defaultIsEmpty(v: unknown): boolean {
|
|
|
53
59
|
export function useGscResource<TArgs extends readonly unknown[], TData>(
|
|
54
60
|
opts: UseGscResourceOptions<TArgs, TData>,
|
|
55
61
|
): UseGscResourceReturn<TData> {
|
|
56
|
-
const
|
|
57
|
-
const
|
|
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 {
|
|
62
|
+
const namespace = opts.namespace ?? 'gsc-resource'
|
|
63
|
+
const resolvedArgs = computed<TArgs | null>(() => {
|
|
64
64
|
const out: unknown[] = []
|
|
65
65
|
for (const k of opts.keys) {
|
|
66
66
|
const v = toValue(k)
|
|
@@ -69,46 +69,134 @@ export function useGscResource<TArgs extends readonly unknown[], TData>(
|
|
|
69
69
|
out.push(v)
|
|
70
70
|
}
|
|
71
71
|
return out as unknown as TArgs
|
|
72
|
-
}
|
|
72
|
+
})
|
|
73
73
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
74
|
+
const enabled = computed(() => resolvedArgs.value != null)
|
|
75
|
+
const gscFetch = useGscFetch()
|
|
76
|
+
|
|
77
|
+
// Operation branch: route through useNuxtRpcQuery so we inherit Zod
|
|
78
|
+
// response validation, RPC key serialization, and shared cache invalidation.
|
|
79
|
+
if (opts.operation) {
|
|
80
|
+
const operation = computed<NuxtRpcQueryOperation<any, any> | null>(() => {
|
|
81
|
+
const args = resolvedArgs.value
|
|
82
|
+
return args ? opts.operation!(...args) : null
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Idle placeholder when args aren't ready — useNuxtRpcQuery is always
|
|
86
|
+
// mounted, but the `enabled` gate prevents the request firing.
|
|
87
|
+
const fallbackOp = { key: `${namespace}:idle`, path: `${namespace}:idle`, response: { parse: (v: unknown) => v } as any }
|
|
88
|
+
|
|
89
|
+
const query = useNuxtRpcQuery<any>(
|
|
90
|
+
() => operation.value ?? fallbackOp,
|
|
91
|
+
{
|
|
92
|
+
enabled,
|
|
93
|
+
watch: opts.watchSources,
|
|
94
|
+
staleTime: opts.staleTime,
|
|
95
|
+
gcTime: opts.gcTime,
|
|
96
|
+
$fetch: gscFetch as any,
|
|
97
|
+
} as any,
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
const data = computed<TData | null>(() => {
|
|
101
|
+
if (!enabled.value)
|
|
102
|
+
return null
|
|
103
|
+
return (query.displayData.value ?? null) as TData | null
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const error = computed<Error | null>(() => {
|
|
107
|
+
if (!enabled.value)
|
|
108
|
+
return null
|
|
109
|
+
const e = query.error.value
|
|
110
|
+
return e == null ? null : e instanceof Error ? e : new Error(String(e))
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const status = computed<GscResourceStatus>(() => {
|
|
114
|
+
if (!enabled.value)
|
|
115
|
+
return 'idle'
|
|
116
|
+
if (query.status.value === 'pending')
|
|
117
|
+
return 'pending'
|
|
118
|
+
if (query.status.value === 'error') {
|
|
119
|
+
const classified = classifyGscError(error.value)
|
|
120
|
+
return classified.status
|
|
121
|
+
}
|
|
122
|
+
const out = data.value
|
|
123
|
+
if (query.status.value === 'success')
|
|
124
|
+
return out == null || (opts.isEmpty ?? defaultIsEmpty)(out) ? 'empty' : 'success'
|
|
125
|
+
return 'idle'
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const loading = computed(() => status.value === 'pending')
|
|
129
|
+
|
|
130
|
+
async function refresh(): Promise<void> {
|
|
131
|
+
if (!enabled.value)
|
|
95
132
|
return
|
|
96
|
-
|
|
97
|
-
error.value = e instanceof Error ? e : new Error(String(e))
|
|
98
|
-
status.value = classified.status
|
|
99
|
-
data.value = null
|
|
133
|
+
await query.refresh()
|
|
100
134
|
}
|
|
135
|
+
|
|
136
|
+
return { data, status, loading, error, refresh }
|
|
101
137
|
}
|
|
102
138
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
139
|
+
// Fetcher branch: composables that can't be modelled as a single RPC
|
|
140
|
+
// operation (rollup fan-out, progress tracking) drop down to a raw
|
|
141
|
+
// useNuxtQuery + bound $fetch.
|
|
142
|
+
const queryKey = computed(() => {
|
|
143
|
+
const args = resolvedArgs.value
|
|
144
|
+
return args ? `${namespace}:${JSON.stringify(args)}` : `${namespace}:idle`
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const fetchResource = (async () => {
|
|
148
|
+
const args = resolvedArgs.value
|
|
149
|
+
if (!args)
|
|
150
|
+
return null
|
|
151
|
+
if (opts.fetcher)
|
|
152
|
+
return await opts.fetcher(...args)
|
|
153
|
+
throw new Error('useGscResource: no operation or fetcher configured')
|
|
154
|
+
}) as unknown as typeof $fetch
|
|
155
|
+
|
|
156
|
+
const query = useNuxtQuery<TData | null, Error>(() => queryKey.value, {
|
|
157
|
+
key: () => queryKey.value,
|
|
158
|
+
enabled,
|
|
159
|
+
watch: opts.watchSources,
|
|
160
|
+
staleTime: opts.staleTime,
|
|
161
|
+
gcTime: opts.gcTime,
|
|
162
|
+
$fetch: fetchResource,
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
const data = computed<TData | null>(() => {
|
|
166
|
+
if (!enabled.value)
|
|
167
|
+
return null
|
|
168
|
+
return (query.displayData.value ?? null) as TData | null
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
const error = computed<Error | null>(() => {
|
|
172
|
+
if (!enabled.value)
|
|
173
|
+
return null
|
|
174
|
+
const e = query.error.value
|
|
175
|
+
return e == null ? null : e instanceof Error ? e : new Error(String(e))
|
|
176
|
+
})
|
|
108
177
|
|
|
109
|
-
|
|
110
|
-
|
|
178
|
+
const status = computed<GscResourceStatus>(() => {
|
|
179
|
+
if (!enabled.value)
|
|
180
|
+
return 'idle'
|
|
181
|
+
if (query.status.value === 'pending')
|
|
182
|
+
return 'pending'
|
|
183
|
+
if (query.status.value === 'error') {
|
|
184
|
+
const classified = classifyGscError(error.value)
|
|
185
|
+
return classified.status
|
|
186
|
+
}
|
|
187
|
+
const out = data.value
|
|
188
|
+
if (query.status.value === 'success')
|
|
189
|
+
return out == null || (opts.isEmpty ?? defaultIsEmpty)(out) ? 'empty' : 'success'
|
|
190
|
+
return 'idle'
|
|
111
191
|
})
|
|
112
192
|
|
|
193
|
+
const loading = computed(() => status.value === 'pending')
|
|
194
|
+
|
|
195
|
+
async function refresh(): Promise<void> {
|
|
196
|
+
if (!enabled.value)
|
|
197
|
+
return
|
|
198
|
+
await query.refresh()
|
|
199
|
+
}
|
|
200
|
+
|
|
113
201
|
return { data, status, loading, error, refresh }
|
|
114
202
|
}
|
|
@@ -127,6 +127,7 @@ export function useGscBootProgress(): {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
function createContext(): GscAnalyticsContext {
|
|
130
|
+
const client = useGscAnalyticsClient()
|
|
130
131
|
const sites = ref<SiteListItem[] | null>(null)
|
|
131
132
|
const sitesLoading = ref(false)
|
|
132
133
|
const sitesError = ref<Error | null>(null)
|
|
@@ -135,7 +136,7 @@ function createContext(): GscAnalyticsContext {
|
|
|
135
136
|
async function refreshSites(): Promise<void> {
|
|
136
137
|
sitesLoading.value = true
|
|
137
138
|
sitesError.value = null
|
|
138
|
-
sites.value = await
|
|
139
|
+
sites.value = await client.listSites().catch((err: unknown) => {
|
|
139
140
|
sitesError.value = err instanceof Error ? err : new Error(String(err))
|
|
140
141
|
return null
|
|
141
142
|
})
|
|
@@ -171,14 +172,22 @@ function createContext(): GscAnalyticsContext {
|
|
|
171
172
|
// Lazy-load: defer refreshSites until something actually reads `sites`.
|
|
172
173
|
// Eager kick-off used to race host plugins that set auth via `setGscAuth`;
|
|
173
174
|
// first call would 401 and the rest of the session would silently degrade.
|
|
174
|
-
// Triggering on first read
|
|
175
|
-
//
|
|
175
|
+
// Triggering on first read still needs to wait until hydration is done:
|
|
176
|
+
// `refreshSites()` flips `sitesLoading` synchronously, and doing that during
|
|
177
|
+
// the client's first render makes the sidebar disagree with SSR markup.
|
|
176
178
|
let kickedOff = false
|
|
177
179
|
function maybeKickOff(): void {
|
|
178
180
|
if (kickedOff || !import.meta.client)
|
|
179
181
|
return
|
|
180
182
|
kickedOff = true
|
|
181
|
-
|
|
183
|
+
const start = (): void => {
|
|
184
|
+
void refreshSites()
|
|
185
|
+
}
|
|
186
|
+
const nuxtApp = useNuxtApp()
|
|
187
|
+
if (nuxtApp.isHydrating)
|
|
188
|
+
onNuxtReady(start)
|
|
189
|
+
else
|
|
190
|
+
queueMicrotask(start)
|
|
182
191
|
}
|
|
183
192
|
const lazySites = computed<SiteListItem[] | null>(() => {
|
|
184
193
|
maybeKickOff()
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
// entry per site/searchType/range via the shared site-resource seam, so they
|
|
10
10
|
// collapse to one network read per session slice.
|
|
11
11
|
|
|
12
|
-
import type { SourceCapabilities } from '@gscdump/analysis'
|
|
13
12
|
import type { SourceInfoOptions } from '@gscdump/contracts'
|
|
13
|
+
import type { SourceCapabilities } from '@gscdump/engine/source'
|
|
14
14
|
import { acquireSharedEntry, useGscSharedSiteResource } from './_useGscSharedSiteResource'
|
|
15
15
|
import { useGscAnalyticsClient } from './useGscAnalyticsClient'
|
|
16
16
|
|
|
@@ -69,12 +69,17 @@ function sourceInfoKey(siteId: string, options?: SourceInfoOptions): string {
|
|
|
69
69
|
])
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
function fetchInto(
|
|
72
|
+
function fetchInto(
|
|
73
|
+
entry: SourceInfoEntry,
|
|
74
|
+
siteId: string,
|
|
75
|
+
options?: SourceInfoOptions,
|
|
76
|
+
client = useGscAnalyticsClient(),
|
|
77
|
+
): Promise<void> {
|
|
73
78
|
if (entry.pending.value)
|
|
74
79
|
return entry.pending.value
|
|
75
80
|
entry.loading.value = true
|
|
76
81
|
entry.error.value = null
|
|
77
|
-
const p = (
|
|
82
|
+
const p = (client.getSourceInfo(siteId, options) as Promise<GscAnalyticsSourceInfo>)
|
|
78
83
|
.then((data) => {
|
|
79
84
|
entry.info.value = data
|
|
80
85
|
})
|
|
@@ -112,6 +117,7 @@ export function useGscAnalyticsSourceInfo(
|
|
|
112
117
|
siteId: MaybeRefOrGetter<string | null | undefined>,
|
|
113
118
|
options: MaybeRefOrGetter<SourceInfoOptions | null | undefined> = null,
|
|
114
119
|
): GscAnalyticsSourceInfoState {
|
|
120
|
+
const client = useGscAnalyticsClient()
|
|
115
121
|
const cacheKey = computed(() => {
|
|
116
122
|
const id = toValue(siteId)
|
|
117
123
|
return id ? sourceInfoKey(id, toValue(options) ?? undefined) : null
|
|
@@ -129,7 +135,7 @@ export function useGscAnalyticsSourceInfo(
|
|
|
129
135
|
if (!id)
|
|
130
136
|
return
|
|
131
137
|
if (entry.info.value == null && entry.pending.value == null && entry.error.value == null)
|
|
132
|
-
void fetchInto(entry, id, toValue(options) ?? undefined)
|
|
138
|
+
void fetchInto(entry, id, toValue(options) ?? undefined, client)
|
|
133
139
|
}, { immediate: true })
|
|
134
140
|
}
|
|
135
141
|
|
|
@@ -143,7 +149,7 @@ export function useGscAnalyticsSourceInfo(
|
|
|
143
149
|
if (!entry || !id)
|
|
144
150
|
return
|
|
145
151
|
entry.info.value = null
|
|
146
|
-
await fetchInto(entry, id, toValue(options) ?? undefined)
|
|
152
|
+
await fetchInto(entry, id, toValue(options) ?? undefined, client)
|
|
147
153
|
}
|
|
148
154
|
|
|
149
155
|
function supports(analyzerId: MaybeRefOrGetter<string>): ComputedRef<boolean> {
|