@gscdump/nuxt 0.21.2 → 0.22.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.
package/README.md CHANGED
@@ -86,31 +86,28 @@ the data — e.g. nuxtseo.com pro consuming gscdump.com. Two pieces:
86
86
  preflights can include this header, while DuckDB-WASM range reads are
87
87
  authorized by the token embedded in each URL.
88
88
 
89
- The `setupGscFetchAuth` helper collapses the boilerplate:
89
+ Wire it from a `'pre'`-enforced client plugin so downstream layer plugins see
90
+ the auth on first read. The layer's `setGscAuth` accepts a value, a `Ref`,
91
+ or a getter — use a getter when credentials may refresh during the session:
90
92
 
91
93
  ```ts
92
94
  // plugins/00.gscdump-auth.client.ts
93
- export default setupGscFetchAuth({
94
- credentialsEndpoint: '/api/me/gscdump-credentials',
95
- // Optional: preload host-specific state. Runs inside runWithContext so
96
- // composables work. Useful for useState-backed flags read by the layer's
97
- // composables at construction time.
98
- async onReady() {
99
- const flag = useState<boolean | null>('app:browser-analyzer-enabled', () => null)
100
- if (flag.value !== null)
101
- return
102
- const settings = await useGscFetch()<{ browserAnalyzerEnabled: boolean }>('/api/user/settings').catch(() => null)
103
- flag.value = !!settings?.browserAnalyzerEnabled
95
+ export default defineNuxtPlugin({
96
+ name: 'gscdump-auth',
97
+ enforce: 'pre',
98
+ async setup() {
99
+ const creds = ref<{ apiKey: string, browserAnalyzerEnabled?: boolean } | null>(null)
100
+ creds.value = await $fetch('/api/me/gscdump-credentials').catch(() => null)
101
+ setGscAuth(() => ({
102
+ apiKey: creds.value?.apiKey ?? null,
103
+ browserAnalyzerEnabled: !!creds.value?.browserAnalyzerEnabled,
104
+ }))
104
105
  },
105
106
  })
106
107
  ```
107
108
 
108
- The helper is `enforce: 'pre'` and dedupes via an inflight promise; Nuxt
109
- blocks subsequent plugins until headers land, removing the auth-header race
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`.
109
+ `useGscQuery` mounts that race the credential fetch resolve once the
110
+ `'pre'` plugin's `await` settles Nuxt blocks subsequent plugins on it.
114
111
 
115
112
  Onboarding and lifecycle state stay outside this layer. A consumer app should
116
113
  use `@gscdump/sdk` from its host backend to register users/sites, read
@@ -7,7 +7,8 @@
7
7
 
8
8
  import type { _useGscAuthInternal } from './useGscAuth'
9
9
  import type { GscQueryDecisionReason, GscQueryEngine } from './useGscQuery'
10
- import { useGscEngine } from './useGscEngine'
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 `useGscEngine()` + `runtimeConfig.public.analytics.defaultEngine`. */
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` → `useGscEngine()` → runtimeConfig →
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 = useGscEngine().value
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
@@ -171,14 +171,22 @@ function createContext(): GscAnalyticsContext {
171
171
  // Lazy-load: defer refreshSites until something actually reads `sites`.
172
172
  // Eager kick-off used to race host plugins that set auth via `setGscAuth`;
173
173
  // first call would 401 and the rest of the session would silently degrade.
174
- // Triggering on first read defers the request to a microtask after plugin
175
- // setup, so auth state is in place.
174
+ // Triggering on first read still needs to wait until hydration is done:
175
+ // `refreshSites()` flips `sitesLoading` synchronously, and doing that during
176
+ // the client's first render makes the sidebar disagree with SSR markup.
176
177
  let kickedOff = false
177
178
  function maybeKickOff(): void {
178
179
  if (kickedOff || !import.meta.client)
179
180
  return
180
181
  kickedOff = true
181
- refreshSites()
182
+ const start = (): void => {
183
+ void refreshSites()
184
+ }
185
+ const nuxtApp = useNuxtApp()
186
+ if (nuxtApp.isHydrating)
187
+ onNuxtReady(start)
188
+ else
189
+ queueMicrotask(start)
182
190
  }
183
191
  const lazySites = computed<SiteListItem[] | null>(() => {
184
192
  maybeKickOff()
@@ -16,7 +16,7 @@ export interface GscAnalyzerBatchEntry<TResult> {
16
16
  }
17
17
 
18
18
  export interface GscAnalyzerBatchRunner {
19
- analyze: (params: { type: string, dateStart?: string, dateEnd?: string }) => Promise<unknown>
19
+ analyze: (params: { type: string, startDate?: string, endDate?: string }) => Promise<unknown>
20
20
  }
21
21
 
22
22
  export interface UseGscAnalyzerBatchOptions {
@@ -72,7 +72,7 @@ export function useGscAnalyzerBatch<TResult = unknown>(
72
72
  return
73
73
  states.value = { ...states.value, [id]: { status: 'running', result: null, error: null } }
74
74
  try {
75
- const result = await runner.analyze({ type: id, dateStart: range.start, dateEnd: range.end })
75
+ const result = await runner.analyze({ type: id, startDate: range.start, endDate: range.end })
76
76
  if (token !== myToken)
77
77
  return
78
78
  states.value = { ...states.value, [id]: { status: 'done', result: result as TResult, error: null } }
@@ -6,11 +6,18 @@
6
6
  // `/analyze` provides these once; panels inject. Type-only — runtime values
7
7
  // are the InjectionKey Symbols themselves.
8
8
 
9
+ import type { AnalysisParams, AnalysisResult } from '@gscdump/analysis'
9
10
  import type { InjectionKey } from '@vue/runtime-core'
10
- import type { GscAnalyzerInstance } from './useGscAnalyzer'
11
+
12
+ export interface GscPanelRunner {
13
+ /** Positional-form SQL runner — `{ rows, queryMs }`. */
14
+ query: (sql: string, params?: readonly unknown[]) => Promise<{ rows: Record<string, unknown>[], queryMs: number }>
15
+ /** Registry-driven analyzer dispatch over the same per-site DuckDB-WASM runtime. */
16
+ analyze: (params: AnalysisParams, opts?: { signal?: AbortSignal }) => Promise<AnalysisResult & { queryMs: number }>
17
+ }
11
18
 
12
19
  export interface GscPanelRunnerContext {
13
- runner: GscAnalyzerInstance
20
+ runner: GscPanelRunner
14
21
  ready: Ref<boolean>
15
22
  }
16
23
 
@@ -1,10 +1,9 @@
1
1
  // Parquet-attached DuckDB table view: fuzzy-search + sortable metrics +
2
2
  // pagination over a single attached parquet (`main.<table>`), date-filtered.
3
3
  //
4
- // Sibling of `useGscRollupTable` the rollup variant fetches a precomputed
5
- // JSON payload; this variant fires SQL against a parquet view in the
6
- // browser-attached DuckDB-WASM runtime. Two callers today (analyze.vue's
7
- // `pages` and `keywords` raw tabs); third+ callers are mechanical.
4
+ // Fires SQL against a parquet view in the browser-attached DuckDB-WASM
5
+ // runtime. Two callers today (analyze.vue's `pages` and `keywords` raw
6
+ // tabs); third+ callers are mechanical.
8
7
  //
9
8
  // Caller passes the `query` fn from `useGscAnalyzer(siteId)`; the composable
10
9
  // owns the SQL builder, the debounce, the watcher, and the count+page
@@ -1,122 +1,14 @@
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
- }
1
+ // Public re-export. The implementation lives in `_useGscQueryDispatcher.ts`;
2
+ // the module's `imports:extend` hook strips `_use*` from host auto-imports so
3
+ // consumers see the wrapper at `useGscQueryDispatcher` and the layer's own
4
+ // composables import the internal file directly. Named re-exports because
5
+ // Nuxt's auto-import scanner doesn't traverse `export *`.
6
+ export {
7
+ type CreateDefaultDispatcherOpts,
8
+ createDefaultGscQueryDispatcher,
9
+ type GscEngineDecision,
10
+ type GscFallbackEvent,
11
+ type GscQueryDispatcher,
12
+ type PickEngineOpts,
13
+ useGscQueryDispatcher,
14
+ } from './_useGscQueryDispatcher'
@@ -1,114 +1,11 @@
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'
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
- }
1
+ // Public re-export. The implementation lives in `_useGscResource.ts`; the
2
+ // module's `imports:extend` hook strips `_use*` from host auto-imports so
3
+ // consumers see the wrapper at `useGscResource` and the layer's own
4
+ // composables import the internal file directly. Named re-exports because
5
+ // Nuxt's auto-import scanner doesn't traverse `export *`.
6
+ export {
7
+ type GscResourceStatus,
8
+ useGscResource,
9
+ type UseGscResourceOptions,
10
+ type UseGscResourceReturn,
11
+ } from './_useGscResource'