Skip to content

Commit 976219c

Browse files
committed
chore: extract useUserSession internals, dedupe isRecord, merge tests
- Split useUserSession.ts (518→260L) into redirect-helpers, session-fetch, wrap-auth-method - Extract shared isRecord() to internal/utils.ts (was duplicated 3x) - Merge secondary storage unit tests into single file - Remove stale agents field and skills from package.json
1 parent b0ceef8 commit 976219c

File tree

10 files changed

+281
-257
lines changed

10 files changed

+281
-257
lines changed

package.json

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,6 @@
1111
"type": "git",
1212
"url": "https://github.com/nuxt-modules/better-auth"
1313
},
14-
"agents": {
15-
"skills": [
16-
{
17-
"name": "nuxt-better-auth",
18-
"path": "./skills/nuxt-better-auth"
19-
}
20-
]
21-
},
2214
"exports": {
2315
".": {
2416
"types": "./dist/types.d.mts",
@@ -41,8 +33,7 @@
4133
}
4234
},
4335
"files": [
44-
"dist",
45-
"skills"
36+
"dist"
4637
],
4738
"scripts": {
4839
"prepack": "nuxt-module-build build",

src/runtime/app/composables/useUserSession.ts

Lines changed: 22 additions & 207 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
import type { AppAuthClient, AuthSession, AuthUser } from '#nuxt-better-auth'
22
import type { ComputedRef, Ref } from 'vue'
33
import createAppAuthClient from '#auth/client'
4-
import { computed, navigateTo, nextTick, useNuxtApp, useRequestFetch, useRequestHeaders, useRequestURL, useRuntimeConfig, useState, watch } from '#imports'
4+
import { computed, navigateTo, useNuxtApp, useRequestURL, useRuntimeConfig, useState, watch } from '#imports'
55
import { normalizeAuthActionError } from '../internal/auth-action-error'
6+
import { resolvePostAuthSuccessRedirect, withFallbackSocialCallbackURL } from '../internal/redirect-helpers'
7+
import { fetchSessionClient, fetchSessionServer, stripToken } from '../internal/session-fetch'
8+
import { isRecord } from '../internal/utils'
9+
import { wrapAuthMethod } from '../internal/wrap-auth-method'
610

711
export interface SignOutOptions { onSuccess?: () => void | Promise<void> }
812
interface RuntimeFlags { client: boolean, server: boolean }
9-
interface SessionResponse { session: AuthSession & { token?: string }, user: AuthUser }
1013

1114
let _sessionSignalListenerBound = false
1215
let _signOutPromise: Promise<void> | null = null
@@ -29,23 +32,12 @@ export interface UseUserSessionReturn {
2932
let _client: AppAuthClient | null = null
3033
interface UpdateUserResponse { error?: unknown }
3134

32-
function isRecord(value: unknown): value is Record<string, unknown> {
33-
return Boolean(value && typeof value === 'object')
34-
}
35-
3635
function getClient(baseURL: string): AppAuthClient {
3736
if (!_client)
3837
_client = createAppAuthClient(baseURL)
3938
return _client
4039
}
4140

42-
function isExpectedSignedOutSessionError(error: unknown): boolean {
43-
const normalizedError = normalizeAuthActionError(error)
44-
if (normalizedError.status === 401)
45-
return true
46-
return normalizedError.code === 'UNAUTHORIZED'
47-
}
48-
4941
function getRuntimeFlags(): RuntimeFlags {
5042
const globalFlags = (globalThis as { __NUXT_BETTER_AUTH_TEST_FLAGS__?: RuntimeFlags }).__NUXT_BETTER_AUTH_TEST_FLAGS__
5143
if (globalFlags)
@@ -81,7 +73,6 @@ export function useUserSession(): UseUserSessionReturn {
8173
const requestURL = useRequestURL()
8274
const nuxtApp = useNuxtApp()
8375

84-
// Client only - create better-auth client for client-side operations (singleton)
8576
const client: AppAuthClient | null = runtimeFlags.client
8677
? getClient(runtimeConfig.public.siteUrl || requestURL.origin)
8778
: null
@@ -114,10 +105,8 @@ export function useUserSession(): UseUserSessionReturn {
114105
return false
115106
if (!nuxtApp.payload.serverRendered)
116107
return false
117-
// Don't skip for prerendered/cached payloads; we want a real session check after mount.
118108
if (isPrerenderedPayload.value)
119109
return false
120-
// SSR already hydrated state: avoid duplicate /api/auth/get-session on first paint.
121110
return Boolean(session.value && user.value)
122111
})
123112

@@ -142,6 +131,13 @@ export function useUserSession(): UseUserSessionReturn {
142131
user.value = null
143132
}
144133

134+
async function fetchSession(options: { headers?: HeadersInit, force?: boolean } = {}) {
135+
if (runtimeFlags.server)
136+
return fetchSessionServer(session, user, authReady, options)
137+
if (client)
138+
return fetchSessionClient(client, session, user, authReady, options)
139+
}
140+
145141
async function updateUser(updates: Partial<AuthUser>) {
146142
if (!user.value)
147143
return
@@ -173,11 +169,9 @@ export function useUserSession(): UseUserSessionReturn {
173169
}
174170

175171
// On client, subscribe to better-auth's reactive session store
176-
// This auto-updates when signIn/signUp/signOut triggers the session signal
177172
if (runtimeFlags.client && client && !shouldSkipInitialClientSessionFetch.value) {
178173
const clientSession = client.useSession()
179174

180-
// Sync better-auth's reactive session to our useState
181175
watch(
182176
() => clientSession.value,
183177
(newSession) => {
@@ -190,9 +184,7 @@ export function useUserSession(): UseUserSessionReturn {
190184
return
191185

192186
if (newSession?.data?.session && newSession?.data?.user) {
193-
// Filter out sensitive token field
194-
const { token: _, ...safeSession } = newSession.data.session as AuthSession & { token?: string }
195-
session.value = safeSession as AuthSession
187+
session.value = stripToken(newSession.data.session as AuthSession & { token?: string })
196188
user.value = newSession.data.user as AuthUser
197189
}
198190
else if (!newSession?.isPending && !newSession?.isRefetching) {
@@ -235,142 +227,22 @@ export function useUserSession(): UseUserSessionReturn {
235227
resolve()
236228
}
237229
})
238-
// Timeout fallback to prevent hanging
239230
setTimeout(() => {
240231
unwatch()
241232
resolve()
242233
}, 5000)
243234
})
244235
}
245236

246-
function isSafeLocalRedirect(redirect: unknown): string | undefined {
247-
if (typeof redirect !== 'string')
248-
return
249-
if (!redirect.startsWith('/') || redirect.startsWith('//'))
250-
return
251-
return redirect
252-
}
253-
254-
function resolvePostAuthRedirect(): string | undefined {
255-
const authConfig = runtimeConfig.public.auth as { redirects?: { authenticated?: string }, redirectQueryKey?: string } | undefined
256-
const redirectQueryKey = authConfig?.redirectQueryKey ?? 'redirect'
257-
const queryRedirect = requestURL.searchParams?.get(redirectQueryKey)
258-
const safeQueryRedirect = isSafeLocalRedirect(queryRedirect)
259-
if (safeQueryRedirect)
260-
return safeQueryRedirect
261-
return isSafeLocalRedirect(authConfig?.redirects?.authenticated)
262-
}
263-
264-
function resolvePostAuthSuccessRedirect(): (() => Promise<void>) | undefined {
265-
const target = resolvePostAuthRedirect()
266-
if (!target)
267-
return
268-
return async () => {
269-
await navigateTo(target)
270-
}
271-
}
272-
273-
function withFallbackSocialCallbackURL(data: unknown): unknown {
274-
const callbackURL = resolvePostAuthRedirect()
275-
if (!callbackURL)
276-
return data
277-
278-
if (!isRecord(data))
279-
return { callbackURL }
280-
if (typeof data.callbackURL === 'string')
281-
return data
282-
283-
return {
284-
...data,
285-
callbackURL,
286-
}
287-
}
288-
289-
// Wrap signIn methods to wait for session sync before calling onSuccess
237+
// Wrap signIn/signUp methods to sync session before executing onSuccess
290238
type SignIn = NonNullable<AppAuthClient>['signIn']
291239
type SignUp = NonNullable<AppAuthClient>['signUp']
292240

293-
// Wraps onSuccess callback to sync session before executing
294-
function wrapOnSuccess(cb: (ctx: unknown) => void | Promise<void>) {
295-
return async (ctx: unknown) => {
296-
await fetchSession({ force: true })
297-
if (!loggedIn.value)
298-
await waitForSession()
299-
await nextTick()
300-
await cb(ctx)
301-
}
302-
}
303-
304-
function wrapAuthMethod<T extends (...args: unknown[]) => Promise<unknown>>(
305-
method: T,
306-
wrapOptions: {
307-
shouldSkipSessionSync?: (data: unknown, options: unknown) => boolean
308-
transformData?: (data: unknown, options: unknown) => unknown
309-
} = {},
310-
): T {
311-
return (async (...args: unknown[]) => {
312-
const originalData = args[0]
313-
const options = args[1]
314-
const data = wrapOptions.transformData?.(originalData, options) ?? originalData
315-
const dataRecord = isRecord(data) ? data : undefined
316-
const optionsRecord = isRecord(options) ? options : undefined
317-
318-
if (wrapOptions.shouldSkipSessionSync?.(data, options))
319-
return method(data, options)
320-
321-
type OnSuccess = (ctx: unknown) => void | Promise<void>
322-
const fetchOptions = isRecord(dataRecord?.fetchOptions) ? dataRecord.fetchOptions : undefined
323-
const nestedOnSuccess = fetchOptions?.onSuccess
324-
const topLevelOnSuccess = optionsRecord?.onSuccess
325-
326-
const fallbackOnSuccess = resolvePostAuthSuccessRedirect()
327-
const wrappedFallbackOnSuccess = fallbackOnSuccess && wrapOnSuccess(async () => {
328-
if (!loggedIn.value)
329-
return
330-
await fallbackOnSuccess()
331-
})
332-
333-
// Passkey pattern: onSuccess in data.fetchOptions
334-
if (typeof nestedOnSuccess === 'function') {
335-
const nextData = {
336-
...dataRecord,
337-
fetchOptions: {
338-
...fetchOptions,
339-
onSuccess: wrapOnSuccess(nestedOnSuccess as OnSuccess),
340-
},
341-
}
342-
return method(nextData as unknown as Parameters<T>[0], options as unknown as Parameters<T>[1])
343-
}
344-
// Email/social pattern: onSuccess in options
345-
if (typeof topLevelOnSuccess === 'function') {
346-
const nextOptions = {
347-
...optionsRecord,
348-
onSuccess: wrapOnSuccess(topLevelOnSuccess as OnSuccess),
349-
}
350-
return method(data as unknown as Parameters<T>[0], nextOptions as unknown as Parameters<T>[1])
351-
}
352-
353-
if (wrappedFallbackOnSuccess) {
354-
if (fetchOptions) {
355-
const nextData = {
356-
...dataRecord,
357-
fetchOptions: {
358-
...fetchOptions,
359-
onSuccess: wrappedFallbackOnSuccess,
360-
},
361-
}
362-
return method(nextData as unknown as Parameters<T>[0], options as unknown as Parameters<T>[1])
363-
}
364-
365-
const nextOptions = {
366-
...optionsRecord,
367-
onSuccess: wrappedFallbackOnSuccess,
368-
}
369-
return method(data as unknown as Parameters<T>[0], nextOptions as unknown as Parameters<T>[1])
370-
}
371-
372-
return method(data, options)
373-
}) as T
241+
const wrapDeps = {
242+
fetchSession,
243+
loggedIn,
244+
waitForSession,
245+
resolvePostAuthSuccessRedirect: () => resolvePostAuthSuccessRedirect(requestURL),
374246
}
375247

376248
const signIn: SignIn = client?.signIn
@@ -386,10 +258,10 @@ export function useUserSession(): UseUserSessionReturn {
386258
return socialData?.disableRedirect !== true
387259
}
388260
: undefined
389-
const transformData = prop === 'social' ? withFallbackSocialCallbackURL : undefined
390-
// Don't bind - call through target to preserve better-auth's Proxy context
261+
const transformData = prop === 'social' ? (data: unknown) => withFallbackSocialCallbackURL(data, requestURL) : undefined
391262
return wrapAuthMethod(
392263
(...args: unknown[]) => (targetRecord[prop] as (...a: unknown[]) => Promise<unknown>)(...args),
264+
wrapDeps,
393265
{ shouldSkipSessionSync, transformData },
394266
)
395267
},
@@ -405,70 +277,13 @@ export function useUserSession(): UseUserSessionReturn {
405277
const method = targetRecord[prop]
406278
if (typeof method !== 'function')
407279
return method
408-
// Don't bind - call through target to preserve better-auth's Proxy context
409-
return wrapAuthMethod((...args: unknown[]) => (targetRecord[prop] as (...a: unknown[]) => Promise<unknown>)(...args))
280+
return wrapAuthMethod((...args: unknown[]) => (targetRecord[prop] as (...a: unknown[]) => Promise<unknown>)(...args), wrapDeps)
410281
},
411282
})
412283
: new Proxy({} as SignUp, {
413284
get: (_, prop) => { throw new Error(`signUp.${String(prop)}() can only be called on client-side`) },
414285
})
415286

416-
async function fetchSession(options: { headers?: HeadersInit, force?: boolean } = {}) {
417-
if (runtimeFlags.server) {
418-
try {
419-
const headers = options.headers || useRequestHeaders(['cookie'])
420-
const requestFetch = useRequestFetch()
421-
const data = await requestFetch<SessionResponse | null>('/api/auth/get-session', { headers })
422-
423-
if (data?.session && data?.user) {
424-
const { token: _, ...safeSession } = data.session
425-
session.value = safeSession as AuthSession
426-
user.value = data.user
427-
}
428-
else {
429-
clearSession()
430-
}
431-
}
432-
catch {
433-
clearSession()
434-
}
435-
finally {
436-
if (!authReady.value)
437-
authReady.value = true
438-
}
439-
return
440-
}
441-
442-
if (client) {
443-
try {
444-
const headers = options.headers || useRequestHeaders(['cookie'])
445-
const fetchOptions = headers ? { headers } : undefined
446-
const query = options.force ? { disableCookieCache: true } : undefined
447-
const result = await client.getSession({ query }, fetchOptions)
448-
const data = result.data as SessionResponse | null
449-
450-
if (data?.session && data?.user) {
451-
// Filter out sensitive token field
452-
const { token: _, ...safeSession } = data.session
453-
session.value = safeSession as AuthSession
454-
user.value = data.user
455-
}
456-
else {
457-
clearSession()
458-
}
459-
}
460-
catch (error) {
461-
clearSession()
462-
if (!isExpectedSignedOutSessionError(error))
463-
console.error('[nuxt-better-auth] Failed to fetch session:', error)
464-
}
465-
finally {
466-
if (!authReady.value)
467-
authReady.value = true
468-
}
469-
}
470-
}
471-
472287
if (runtimeFlags.client && client && shouldSkipInitialClientSessionFetch.value) {
473288
ensureSessionSignalListener(client, () => fetchSession({ force: true }))
474289
}

src/runtime/app/internal/auth-action-error.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,8 @@
11
import type { AuthActionError } from '../../types'
2+
import { isRecord } from './utils'
23

34
export const DEFAULT_AUTH_ACTION_ERROR_MESSAGE = 'Request failed. Please try again.'
45

5-
function isRecord(value: unknown): value is Record<string, unknown> {
6-
return Boolean(value && typeof value === 'object')
7-
}
8-
96
function getMessage(value: unknown): string {
107
if (value instanceof Error)
118
return value.message

src/runtime/app/internal/auth-action-handles.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { Ref } from 'vue'
22
import type { AuthActionError } from '../../types'
33
import { ref } from '#imports'
44
import { normalizeAuthActionError } from './auth-action-error'
5+
import { isRecord } from './utils'
56

67
export type UserAuthActionStatus = 'idle' | 'pending' | 'success' | 'error'
78

@@ -24,10 +25,6 @@ export type ActionHandleMap<T> = {
2425
[K in keyof T]: ActionHandleFor<T[K]>
2526
}
2627

27-
function isRecord(value: unknown): value is Record<string, unknown> {
28-
return Boolean(value && typeof value === 'object')
29-
}
30-
3128
function isErrorResult(value: unknown): value is { error: unknown } {
3229
if (!isRecord(value))
3330
return false

0 commit comments

Comments
 (0)