@gscdump/nuxt 0.21.3 → 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.
@@ -1,559 +0,0 @@
1
- /**
2
- * useGscSnapshotAnalyzer — the single consolidated GSC analyzer composable.
3
- *
4
- * Replaces `useGscAnalyzer.ts` (pkg) and `useBrowserAnalyzer.ts` (app). One
5
- * OPFS-backed, attach-once, browser-eligibility-aware analyzer.
6
- *
7
- * Implements the frozen contract in `useGscSnapshotAnalyzer.contract.ts`.
8
- *
9
- * Flow per `(site, searchType, range)`:
10
- * 1. RESOLVE — call the file-resolution endpoint (`/analysis-sources`). It
11
- * returns per-table `mode: browser | server` and, for browser tables, the
12
- * compacted Iceberg parquet files (content hash + signed URL).
13
- * 2. BOOT — DuckDB-WASM boots once.
14
- * 3. DOWNLOAD + ATTACH — browser-mode files download into OPFS (content-hash
15
- * verified) and attach as views. ATTACH-ONCE: filter / range changes
16
- * inside the attached span re-query, never re-attach.
17
- * 4. QUERY — `query(archetype)` routes per-table: browser-mode tables run
18
- * locally against OPFS; server-mode tables POST to the server tail.
19
- * Identical queries replay from an in-memory LRU keyed by
20
- * `snapshotVersion + queryHash`.
21
- *
22
- * Quota: a `QuotaExceededError` during OPFS download degrades the affected
23
- * table to the server tail (`storage.degraded = true`); it never crashes.
24
- *
25
- * The instance is cached + refcounted per `(site, searchType, range)` at module
26
- * scope (keyed by NuxtApp) so panels on the same site share one DuckDB-WASM
27
- * boot and one OPFS attach.
28
- */
29
-
30
- import type { OpfsFileProgress } from '@gscdump/engine-duckdb-wasm'
31
- import type {
32
- ArchetypeQuery,
33
- ArchetypeResult,
34
- ArchetypeResultRow,
35
- ArchetypeResultSource,
36
- FileResolutionResponse,
37
- GscSearchType,
38
- ResolvedTable,
39
- } from '@gscdump/sdk'
40
- import type { NuxtApp } from 'nuxt/app'
41
- import type {
42
- AnalyzerProgress,
43
- AnalyzerRange,
44
- AnalyzerStorageState,
45
- AnalyzerTableRouting,
46
- GscSnapshotAnalyzer,
47
- GscSnapshotAnalyzerOptions,
48
- UseGscSnapshotAnalyzer,
49
- } from './useGscSnapshotAnalyzer.contract'
50
- import { createResultLru, resultCacheKey, routeArchetype } from './useGscSnapshotAnalyzer.routing'
51
-
52
- const DEFAULT_SEARCH_TYPE: GscSearchType = 'web'
53
- const DEFAULT_RESULT_CACHE_SIZE = 64
54
- const DEFAULT_ATTACH_FETCH_CONCURRENCY = 2
55
-
56
- const IDLE_PROGRESS: AnalyzerProgress = Object.freeze({
57
- phase: 'idle',
58
- filesReady: 0,
59
- filesTotal: 0,
60
- bytesReady: 0,
61
- bytesTotal: 0,
62
- })
63
-
64
- const IDLE_STORAGE: AnalyzerStorageState = Object.freeze({
65
- persisted: false,
66
- degraded: false,
67
- })
68
-
69
- // ── instance cache (per NuxtApp, refcounted) ────────────────────────────────
70
-
71
- interface CacheEntry {
72
- instance: GscSnapshotAnalyzer
73
- refs: number
74
- }
75
- const instanceBags = new WeakMap<NuxtApp, Map<string, CacheEntry>>()
76
-
77
- function instanceCacheFor(app: NuxtApp): Map<string, CacheEntry> {
78
- let bag = instanceBags.get(app)
79
- if (!bag) {
80
- bag = new Map()
81
- instanceBags.set(app, bag)
82
- }
83
- return bag
84
- }
85
-
86
- function instanceKey(siteId: string, searchType: GscSearchType, range: AnalyzerRange | null): string {
87
- return JSON.stringify([siteId, searchType, range?.start ?? null, range?.end ?? null])
88
- }
89
-
90
- function normalizeRange(range: AnalyzerRange | null | undefined): AnalyzerRange | null {
91
- return range?.start && range?.end ? { start: range.start, end: range.end } : null
92
- }
93
-
94
- // ── the composable ──────────────────────────────────────────────────────────
95
-
96
- export const useGscSnapshotAnalyzer: UseGscSnapshotAnalyzer = (
97
- siteId,
98
- searchType,
99
- range,
100
- options,
101
- ) => {
102
- const app = useNuxtApp()
103
- const cache = instanceCacheFor(app)
104
-
105
- const key = computed(() => {
106
- const id = toValue(siteId)
107
- if (!id)
108
- return null
109
- return instanceKey(
110
- id,
111
- toValue(searchType) ?? DEFAULT_SEARCH_TYPE,
112
- normalizeRange(toValue(range)),
113
- )
114
- })
115
-
116
- /** Acquire (or create) + refcount the cached instance for the current key. */
117
- function acquire(k: string): GscSnapshotAnalyzer {
118
- let entry = cache.get(k)
119
- if (!entry) {
120
- const [id, st, start, end] = JSON.parse(k) as [string, GscSearchType, string | null, string | null]
121
- entry = {
122
- instance: createSnapshotAnalyzerInstance(
123
- id,
124
- st,
125
- start && end ? { start, end } : null,
126
- options ?? {},
127
- ),
128
- refs: 0,
129
- }
130
- cache.set(k, entry)
131
- }
132
- entry.refs++
133
- return entry.instance
134
- }
135
-
136
- function release(k: string): void {
137
- const entry = cache.get(k)
138
- if (!entry)
139
- return
140
- entry.refs--
141
- if (entry.refs <= 0) {
142
- cache.delete(k)
143
- entry.instance.dispose().catch((e) => {
144
- console.error('[useGscSnapshotAnalyzer] dispose failed', e)
145
- })
146
- }
147
- }
148
-
149
- // Bind to the current key; rebind (release old, acquire new) on change.
150
- let boundKey: string | null = null
151
- const bound = shallowRef<GscSnapshotAnalyzer | null>(null)
152
-
153
- watch(key, (next, prev) => {
154
- if (next === prev)
155
- return
156
- if (boundKey)
157
- release(boundKey)
158
- boundKey = next
159
- bound.value = next ? acquire(next) : null
160
- }, { immediate: true })
161
-
162
- tryOnScopeDispose(() => {
163
- if (boundKey)
164
- release(boundKey)
165
- boundKey = null
166
- })
167
-
168
- // Reactive views over the bound instance — track both site switch and inner
169
- // ref updates. A null-bound analyzer reports a safe idle state.
170
- const ready = computed(() => bound.value?.ready.value ?? false) as Ref<boolean>
171
- const initializing = computed(() => bound.value?.initializing.value ?? false) as Ref<boolean>
172
- const error = computed(() => bound.value?.error.value ?? null) as Ref<Error | null>
173
- const progress = computed<AnalyzerProgress>(() => bound.value?.progress.value ?? IDLE_PROGRESS) as Ref<AnalyzerProgress>
174
- const storage = computed<AnalyzerStorageState>(() => bound.value?.storage.value ?? IDLE_STORAGE) as Ref<AnalyzerStorageState>
175
- const routing = computed<AnalyzerTableRouting>(() => bound.value?.routing.value ?? {}) as Ref<AnalyzerTableRouting>
176
- const snapshotVersion = computed(() => bound.value?.snapshotVersion.value) as Ref<string | undefined>
177
- const currentSiteId = computed(() => bound.value?.currentSiteId.value ?? null) as Ref<string | null>
178
-
179
- return {
180
- ready,
181
- initializing,
182
- error,
183
- progress,
184
- storage,
185
- routing,
186
- snapshotVersion,
187
- currentSiteId,
188
- query: async <R extends ArchetypeResultRow = ArchetypeResultRow>(
189
- q: ArchetypeQuery,
190
- opts?: { signal?: AbortSignal },
191
- ): Promise<ArchetypeResult<R>> => {
192
- const inst = bound.value
193
- if (!inst)
194
- throw new Error('useGscSnapshotAnalyzer: no site bound')
195
- return inst.query<R>(q, opts)
196
- },
197
- refresh: () => bound.value?.refresh() ?? Promise.resolve(false),
198
- clearCache: () => bound.value?.clearCache(),
199
- // Lifecycle owned by the refcounted cache; kept for API compatibility.
200
- dispose: async () => {},
201
- }
202
- }
203
-
204
- // ── the per-instance implementation ─────────────────────────────────────────
205
-
206
- function createSnapshotAnalyzerInstance(
207
- siteId: string,
208
- searchType: GscSearchType,
209
- range: AnalyzerRange | null,
210
- options: GscSnapshotAnalyzerOptions,
211
- ): GscSnapshotAnalyzer {
212
- const ready = ref(false)
213
- const initializing = ref(true)
214
- const error = ref<Error | null>(null)
215
- const progress = ref<AnalyzerProgress>({ ...IDLE_PROGRESS })
216
- const storage = ref<AnalyzerStorageState>({ ...IDLE_STORAGE })
217
- const routing = ref<AnalyzerTableRouting>({})
218
- const snapshotVersion = ref<string | undefined>(undefined)
219
- const currentSiteId = ref<string | null>(siteId)
220
-
221
- const resultLru = createResultLru<ArchetypeResult>(
222
- options.resultCacheSize ?? DEFAULT_RESULT_CACHE_SIZE,
223
- )
224
-
225
- const lifetime = new AbortController()
226
-
227
- // DuckDB-WASM runtime + OPFS handle — populated by `boot`. Typed loosely
228
- // because the runtime types come from a dynamically-imported package.
229
- let db: { conn: unknown, db: unknown } | null = null
230
- let conn: any = null
231
- let attachedHandle: { detach: () => Promise<void>, tables: string[] } | null = null
232
- // The server-tail directive endpoint, when any table routed server-side.
233
- let serverEndpoint: string | null = null
234
-
235
- function patchProgress(p: Partial<AnalyzerProgress>): void {
236
- progress.value = { ...progress.value, ...p }
237
- }
238
-
239
- /** Call the file-resolution endpoint. */
240
- async function resolveFiles(signal: AbortSignal): Promise<FileResolutionResponse> {
241
- patchProgress({ phase: 'resolving' })
242
- const qs = new URLSearchParams({ searchType })
243
- if (range) {
244
- qs.set('start', range.start)
245
- qs.set('end', range.end)
246
- }
247
- // Hit the `__gsc` layer surface (session-backed, credentialed) on the
248
- // gscdump data origin — not a bare relative path, which would resolve
249
- // against the embedding app's own origin and 404.
250
- const apiBase = (useGscAnalyticsConfig().apiBase ?? '').replace(/\/+$/, '')
251
- return $fetch<FileResolutionResponse>(`${apiBase}/api/__gsc/sites/${siteId}/analysis-sources?${qs}`, {
252
- headers: { 'cache-control': 'no-cache' },
253
- credentials: 'include',
254
- signal,
255
- })
256
- }
257
-
258
- /** Apply a resolution response to `routing` + return the browser tables. */
259
- function applyResolution(res: FileResolutionResponse): ResolvedTable[] {
260
- snapshotVersion.value = res.snapshotVersion
261
- serverEndpoint = res.serverTail?.endpoint ?? null
262
- const nextRouting: AnalyzerTableRouting = {}
263
- for (const t of res.tables)
264
- nextRouting[t.table] = t.mode
265
- routing.value = nextRouting
266
- return res.tables.filter(t => t.mode === 'browser' && t.files.length > 0)
267
- }
268
-
269
- const boot = (async () => {
270
- const signal = lifetime.signal
271
- patchProgress({ phase: 'resolving', startedAt: Date.now() })
272
-
273
- const resolution = await resolveFiles(signal)
274
- const browserTables = applyResolution(resolution)
275
-
276
- // Fully server-tail-routed site — nothing to attach. Mark ready; queries
277
- // proxy to the server.
278
- if (browserTables.length === 0) {
279
- patchProgress({ phase: 'ready', endedAt: Date.now() })
280
- ready.value = true
281
- return
282
- }
283
-
284
- // ---- boot DuckDB-WASM -------------------------------------------------
285
- patchProgress({ phase: 'booting' })
286
- const cfg = useGscAnalyticsConfig()
287
- const bundleBase = cfg.duckdbBundleBase as string | undefined
288
- const { bootDuckDBWasm } = await import('@gscdump/engine-duckdb-wasm')
289
- db = await bootDuckDBWasm(bundleBase
290
- ? {
291
- bundles: {
292
- mvp: { mainModule: `${bundleBase}/duckdb-mvp.wasm`, mainWorker: `${bundleBase}/duckdb-browser-mvp.worker.js` },
293
- eh: { mainModule: `${bundleBase}/duckdb-eh.wasm`, mainWorker: `${bundleBase}/duckdb-browser-eh.worker.js` },
294
- },
295
- }
296
- : undefined)
297
- conn = db.conn
298
-
299
- // ---- download + attach OPFS parquet ----------------------------------
300
- const { attachOpfsParquetTables, estimateOpfsStorage, requestPersistentStorage } = await import('@gscdump/engine-duckdb-wasm')
301
-
302
- const persisted = await requestPersistentStorage()
303
- const est = await estimateOpfsStorage()
304
- storage.value = { persisted, degraded: false, usageBytes: est.usageBytes, quotaBytes: est.quotaBytes }
305
-
306
- const opfsTables = browserTables.map(t => ({
307
- table: t.table,
308
- files: t.files.map(f => ({
309
- url: f.url,
310
- bytes: f.bytes,
311
- contentHash: f.contentHash,
312
- rowCount: f.rowCount,
313
- })),
314
- }))
315
- const filesTotal = opfsTables.reduce((n, t) => n + t.files.length, 0)
316
- const bytesTotal = browserTables.reduce((n, t) => n + t.totalBytes, 0)
317
- patchProgress({ phase: 'downloading', filesTotal, bytesTotal, filesReady: 0, bytesReady: 0 })
318
-
319
- let filesReady = 0
320
- let bytesReady = 0
321
- const handle = await attachOpfsParquetTables({
322
- db: db.db as any,
323
- conn: conn as any,
324
- tables: opfsTables,
325
- schema: 'main',
326
- version: resolution.snapshotVersion,
327
- fetchInit: { credentials: 'same-origin' },
328
- fetchConcurrency: DEFAULT_ATTACH_FETCH_CONCURRENCY,
329
- signal,
330
- onFileProgress: (info: OpfsFileProgress) => {
331
- filesReady++
332
- bytesReady += info.bytes
333
- patchProgress({ phase: 'downloading', filesReady, bytesReady })
334
- },
335
- })
336
- attachedHandle = { detach: handle.detach, tables: handle.tables }
337
-
338
- patchProgress({ phase: 'attaching' })
339
-
340
- // A table that hit a quota error degrades to the server tail. Flip its
341
- // routing + mark storage degraded so the UI can warn.
342
- if (handle.degradedTables.length > 0) {
343
- const next = { ...routing.value }
344
- for (const t of handle.degradedTables)
345
- next[t] = 'server'
346
- routing.value = next
347
- storage.value = { ...storage.value, degraded: true }
348
- }
349
-
350
- const estAfter = await estimateOpfsStorage()
351
- storage.value = { ...storage.value, usageBytes: estAfter.usageBytes, quotaBytes: estAfter.quotaBytes }
352
-
353
- patchProgress({ phase: 'ready', endedAt: Date.now(), filesReady, bytesReady })
354
- ready.value = true
355
- })()
356
- .catch((e) => {
357
- const err = e instanceof Error ? e : new Error(String(e))
358
- error.value = err
359
- patchProgress({ phase: 'error', error: err.message, endedAt: Date.now() })
360
- throw err
361
- })
362
- .finally(() => {
363
- initializing.value = false
364
- })
365
- boot.catch(() => {})
366
-
367
- // ---- query routing -----------------------------------------------------
368
-
369
- async function runBrowserQuery<R extends ArchetypeResultRow>(
370
- q: ArchetypeQuery,
371
- signal?: AbortSignal,
372
- ): Promise<ArchetypeResult<R>> {
373
- const { compileArchetypeSql } = await import('@gscdump/engine-duckdb-wasm')
374
- const compiled = compileArchetypeSql(q)
375
- const t0 = performance.now()
376
- signal?.throwIfAborted()
377
- // DuckDB-WASM connection is not concurrency-safe; the shared connection is
378
- // serialized by callers running queries one-at-a-time here.
379
- let result: any
380
- if (compiled.params.length === 0) {
381
- result = await conn.query(compiled.sql)
382
- }
383
- else {
384
- const stmt = await conn.prepare(compiled.sql)
385
- try {
386
- result = await stmt.query(...compiled.params)
387
- }
388
- finally {
389
- await stmt.close()
390
- }
391
- }
392
- signal?.throwIfAborted()
393
- const rows = arrowToRows<R>(result)
394
- return {
395
- archetype: q.archetype,
396
- rows,
397
- source: 'browser',
398
- meta: { rowCount: rows.length, queryMs: performance.now() - t0 },
399
- }
400
- }
401
-
402
- async function runServerQuery<R extends ArchetypeResultRow>(
403
- q: ArchetypeQuery,
404
- where: 'server' | 'cloud',
405
- signal?: AbortSignal,
406
- ): Promise<ArchetypeResult<R>> {
407
- const t0 = performance.now()
408
- // Server tail: POST the archetype to the directive endpoint, or fall back
409
- // to the conventional per-site analysis endpoint when no directive exists.
410
- const endpoint = where === 'cloud'
411
- ? `/api/sites/${siteId}/archetype-query`
412
- : (serverEndpoint ?? `/api/sites/${siteId}/archetype-query`)
413
- const res = await $fetch<ArchetypeResult<R>>(endpoint, {
414
- method: 'POST',
415
- body: { siteId, query: q },
416
- signal,
417
- })
418
- // Trust the server's `source` when present; otherwise tag from routing.
419
- const source: ArchetypeResultSource = res.source
420
- ?? (where === 'cloud' ? 'cloud' : 'server-r2-sql')
421
- return {
422
- ...res,
423
- source,
424
- meta: { rowCount: res.rows?.length ?? 0, queryMs: performance.now() - t0, ...res.meta },
425
- }
426
- }
427
-
428
- async function query<R extends ArchetypeResultRow = ArchetypeResultRow>(
429
- q: ArchetypeQuery,
430
- opts?: { signal?: AbortSignal },
431
- ): Promise<ArchetypeResult<R>> {
432
- await boot
433
- opts?.signal?.throwIfAborted()
434
-
435
- // ---- result LRU — keyed by snapshotVersion + queryHash ----------------
436
- const cacheKey = resultCacheKey(snapshotVersion.value, q)
437
- const cached = resultLru.get(cacheKey)
438
- if (cached)
439
- return cached as ArchetypeResult<R>
440
-
441
- const { tableForArchetype } = await import('@gscdump/engine-duckdb-wasm')
442
- const where = routeArchetype(q, routing.value, tableForArchetype)
443
-
444
- let result: ArchetypeResult<R>
445
- if (where === 'browser' && conn) {
446
- result = await runBrowserQuery<R>(q, opts?.signal)
447
- }
448
- else {
449
- result = await runServerQuery<R>(q, where === 'cloud' ? 'cloud' : 'server', opts?.signal)
450
- }
451
-
452
- resultLru.set(cacheKey, result as ArchetypeResult)
453
- return result
454
- }
455
-
456
- async function refresh(): Promise<boolean> {
457
- await boot.catch(() => {})
458
- if (!db)
459
- return false // fully server-tail-routed — nothing to re-attach.
460
- const resolution = await resolveFiles(lifetime.signal)
461
- if (resolution.snapshotVersion === snapshotVersion.value)
462
- return false
463
- // Snapshot moved — detach the stale views, re-attach the fresher parquet.
464
- // The DuckDB-WASM runtime (db + conn) stays alive.
465
- const browserTables = applyResolution(resolution)
466
- await attachedHandle?.detach().catch((e) => {
467
- console.warn('[useGscSnapshotAnalyzer] detach during refresh failed', e)
468
- })
469
- attachedHandle = null
470
- resultLru.clear()
471
- if (browserTables.length === 0)
472
- return true
473
- const { attachOpfsParquetTables } = await import('@gscdump/engine-duckdb-wasm')
474
- const handle = await attachOpfsParquetTables({
475
- db: (db as any).db,
476
- conn: conn as any,
477
- tables: browserTables.map(t => ({
478
- table: t.table,
479
- files: t.files.map(f => ({ url: f.url, bytes: f.bytes, contentHash: f.contentHash, rowCount: f.rowCount })),
480
- })),
481
- schema: 'main',
482
- version: resolution.snapshotVersion,
483
- fetchInit: { credentials: 'same-origin' },
484
- fetchConcurrency: DEFAULT_ATTACH_FETCH_CONCURRENCY,
485
- signal: lifetime.signal,
486
- })
487
- attachedHandle = { detach: handle.detach, tables: handle.tables }
488
- if (handle.degradedTables.length > 0) {
489
- const next = { ...routing.value }
490
- for (const t of handle.degradedTables)
491
- next[t] = 'server'
492
- routing.value = next
493
- storage.value = { ...storage.value, degraded: true }
494
- }
495
- return true
496
- }
497
-
498
- function clearCache(): void {
499
- resultLru.clear()
500
- }
501
-
502
- async function dispose(): Promise<void> {
503
- lifetime.abort()
504
- await attachedHandle?.detach().catch((e) => {
505
- console.error('[useGscSnapshotAnalyzer] detach on dispose failed', e)
506
- })
507
- if (conn) {
508
- await conn.close().catch(() => {})
509
- }
510
- if (db && (db.db as any)?.terminate) {
511
- await (db.db as any).terminate().catch(() => {})
512
- }
513
- attachedHandle = null
514
- conn = null
515
- db = null
516
- resultLru.clear()
517
- ready.value = false
518
- routing.value = {}
519
- }
520
-
521
- return {
522
- ready,
523
- initializing,
524
- error,
525
- progress,
526
- storage,
527
- routing,
528
- snapshotVersion,
529
- currentSiteId,
530
- query,
531
- refresh,
532
- clearCache,
533
- dispose,
534
- }
535
- }
536
-
537
- /**
538
- * Convert an Apache Arrow result (DuckDB-WASM `conn.query` return) to plain
539
- * row objects. Kept local so the composable has no Arrow type dependency.
540
- */
541
- function arrowToRows<R extends ArchetypeResultRow>(result: unknown): R[] {
542
- if (!result || typeof result !== 'object')
543
- return []
544
- const table = result as { toArray?: () => unknown[] }
545
- if (typeof table.toArray !== 'function')
546
- return []
547
- return table.toArray().map((row) => {
548
- // Arrow rows expose `toJSON()`; fall back to a shallow copy.
549
- const r = row as { toJSON?: () => Record<string, unknown> }
550
- const obj = typeof r.toJSON === 'function' ? r.toJSON() : { ...(row as Record<string, unknown>) }
551
- // Normalise BigInt (DuckDB SUM returns BigInt) to number for JSON safety.
552
- for (const k of Object.keys(obj)) {
553
- const v = obj[k]
554
- if (typeof v === 'bigint')
555
- obj[k] = Number(v)
556
- }
557
- return obj as R
558
- })
559
- }
@@ -1,62 +0,0 @@
1
- // Plugin factory for consumer-mode auth wiring.
2
- //
3
- // In consumer mode, the host app authenticates the viewer against its own
4
- // origin and exchanges that for a gscdump.com api key. `setupGscFetchAuth`
5
- // collapses the boilerplate of "fetch credentials, call setGscAuth, dedupe
6
- // via inflight promise, enforce: 'pre'" into a single call. The returned
7
- // value is a plugin definition; consumers re-export it from a client plugin
8
- // file (`plugins/00.gscdump-auth.client.ts`).
9
- //
10
- // `enforce: 'pre'` + the returned promise make Nuxt block subsequent plugins
11
- // until headers land, which avoids the auth-header race where a query mounts
12
- // before credentials resolve.
13
-
14
- import { setGscAuth } from '../composables/useGscAuth'
15
-
16
- interface SetupGscFetchAuthOptions<TCreds extends Record<string, any> = { apiKey: string }> {
17
- /** Host endpoint returning credentials. Called once per page load. */
18
- credentialsEndpoint: string
19
- /** Field on the response holding the api key. Default: `apiKey`. */
20
- tokenField?: keyof TCreds & string
21
- /** Header name to send to gscdump.com. Default: `x-api-key`. Set this only when the host uses a custom header name; the layer maps `apiKey` → `x-api-key` by default. */
22
- headerName?: string
23
- /**
24
- * Optional hook fired after auth is set. Use for host-specific state
25
- * preloading (e.g. user settings flags). Runs inside `nuxtApp.runWithContext`.
26
- */
27
- onReady?: (ctx: { credentials: TCreds }) => void | Promise<void>
28
- }
29
-
30
- export function setupGscFetchAuth<TCreds extends Record<string, any> = { apiKey: string }>(
31
- options: SetupGscFetchAuthOptions<TCreds>,
32
- ): ReturnType<typeof defineNuxtPlugin> {
33
- const { credentialsEndpoint, tokenField = 'apiKey' as keyof TCreds & string, headerName = 'x-api-key', onReady } = options
34
- let inflight: Promise<void> | null = null
35
-
36
- return defineNuxtPlugin({
37
- name: 'gscdump-analytics-auth',
38
- enforce: 'pre',
39
- async setup(nuxtApp) {
40
- if (inflight)
41
- return inflight
42
- inflight = (async () => {
43
- const credentials = await $fetch<TCreds>(credentialsEndpoint).catch(() => null)
44
- const apiKey = credentials?.[tokenField] as string | undefined
45
- if (!apiKey)
46
- return
47
- // Default header name = `x-api-key` is already handled by the layer
48
- // when `apiKey` is set on auth state. A custom `headerName` is routed
49
- // through the opaque `headers` bag.
50
- setGscAuth({
51
- apiKey: headerName === 'x-api-key' ? apiKey : null,
52
- browserAnalyzerEnabled: false,
53
- headers: headerName === 'x-api-key' ? undefined : { [headerName]: apiKey },
54
- })
55
- if (onReady) {
56
- await nuxtApp.runWithContext(() => onReady({ credentials: credentials! }))
57
- }
58
- })()
59
- return inflight
60
- },
61
- })
62
- }