refactor(vue): make Nuxt session fetching type-safe (#11085)

This commit is contained in:
Taesu
2026-09-01 15:34:53 +00:00
committed by GitHub
parent 2d5c63d512
commit e16b40aead
5 changed files with 139 additions and 64 deletions
@@ -0,0 +1,5 @@
---
"better-auth": patch
---
Provide type-safe Nuxt `useFetch` integration for the Vue client's `useSession` hook.
@@ -0,0 +1,12 @@
import type { UseFetchOptions } from "nuxt/app";
import type { authClient } from "../../lib/auth-client";
type SessionFetch = Parameters<typeof authClient.useSession>[0];
type SessionFetchOptions = Parameters<SessionFetch>[1];
type NuxtCompatibleOptions<Options extends UseFetchOptions<unknown>> = Options;
type NuxtCompatibleFetch<Fetch extends SessionFetch> = Fetch;
export type SessionFetchOptionsContract =
NuxtCompatibleOptions<SessionFetchOptions>;
export type NuxtUseFetchContract = NuxtCompatibleFetch<typeof useFetch>;
@@ -26,23 +26,64 @@ it("should call '/api/auth' for vue client", async () => {
/**
* @see https://github.com/better-auth/better-auth/issues/5358
*/
it("uses stable Nuxt options for a session fetch", async () => {
const headers = { cookie: "better-auth.session_token=session" };
const useFetch = vi.fn(async () => ({
data: { value: null },
error: { value: null },
}));
it("uses stable Nuxt options for a session fetch", () => {
const headers = {
cookie: "better-auth.session_token=session",
"x-optional": undefined,
};
const pendingFetch = new Promise<never>(() => undefined);
const useFetch = vi.fn(() => pendingFetch);
const client = createVueClient({
baseURL: "http://localhost:3000",
fetchOptions: { headers },
});
await client.useSession(useFetch);
void client.useSession(useFetch);
expect(useFetch).toHaveBeenCalledWith(
"http://localhost:3000/api/auth/get-session",
{
headers,
headers: { cookie: "better-auth.session_token=session" },
key: "better-auth:session:http://localhost:3000:/api/auth",
watch: [expect.anything()],
},
);
});
/**
* @see https://github.com/better-auth/better-auth/issues/5358
*/
it.each([
[
"Headers",
new Headers({ cookie: "better-auth.session_token=session" }),
new Headers({ cookie: "better-auth.session_token=session" }),
],
[
"tuple array",
[["cookie", "better-auth.session_token=session"]] satisfies [
string,
string,
][],
[["cookie", "better-auth.session_token=session"]] satisfies [
string,
string,
][],
],
])("preserves %s for a Nuxt session fetch", (_name, headers, expectedHeaders) => {
const pendingFetch = new Promise<never>(() => undefined);
const useFetch = vi.fn(() => pendingFetch);
const client = createVueClient({
baseURL: "http://localhost:3000",
fetchOptions: { headers },
});
void client.useSession(useFetch);
expect(useFetch).toHaveBeenCalledWith(
"http://localhost:3000/api/auth/get-session",
{
headers: expectedHeaders,
key: "better-auth:session:http://localhost:3000:/api/auth",
watch: [expect.anything()],
},
@@ -191,6 +191,7 @@ export const getClientConfig = (
pluginPathMethods,
atomListeners,
hydrateSession,
$sessionSignal,
$fetch,
$store,
};
+72 -56
View File
@@ -1,11 +1,14 @@
import type { BetterAuthClientOptions } from "@better-auth/core";
import type {
BetterAuthClientOptions,
ClientFetchOption,
} from "@better-auth/core";
import type { BASE_ERROR_CODES } from "@better-auth/core/error";
import { capitalizeFirstLetter } from "@better-auth/core/utils/string";
import type {
BetterFetchError,
BetterFetchResponse,
} from "@better-fetch/fetch";
import type { DeepReadonly, Ref } from "vue";
import type { DeepReadonly, Ref, WatchSource } from "vue";
import type { PrettifyDeep, UnionToIntersection } from "../../types/helper";
import { getClientConfig } from "../config";
import { createDynamicPathProxy } from "../proxy";
@@ -22,6 +25,20 @@ function getAtomKey(str: string) {
return `use${capitalizeFirstLetter(str)}`;
}
/** Preserves standard `HeadersInit` values and removes undefined record entries. */
function toHeadersInit(
headers: ClientFetchOption["headers"],
): HeadersInit | undefined {
if (!headers) return undefined;
if (headers instanceof Headers || Array.isArray(headers)) return headers;
const normalizedHeaders: Record<string, string> = {};
for (const [name, value] of Object.entries(headers)) {
if (value !== undefined) normalizedHeaders[name] = value;
}
return normalizedHeaders;
}
type InferResolvedHooks<O extends BetterAuthClientOptions> = O extends {
plugins: Array<infer Plugin>;
}
@@ -56,28 +73,41 @@ type ClientSession<Option extends BetterAuthClientOptions> =
: Res
: never;
type VueUseSession<Option extends BetterAuthClientOptions> = {
(): DeepReadonly<
Ref<{
data: ClientSession<Option>;
isPending: boolean;
isRefetching: boolean;
error: BetterFetchError | null;
refetch: (
queryParams?: { query?: SessionQueryParams } | undefined,
) => Promise<void>;
}>
>;
<F extends (...args: any) => any>(
useFetch: F,
): Promise<{
data: Ref<ClientSession<Option>>;
isPending: false;
error: Ref<{
message?: string | undefined;
status: number;
statusText: string;
}>;
/**
* Minimal Nuxt-compatible fetch contract for `useSession(useFetch)`.
* Compatibility with Nuxt's `useFetch` and `UseFetchOptions` is checked by the Nuxt fixture type test.
*/
type SessionFetch = (
url: string,
options: {
headers?: HeadersInit;
key: string;
watch: WatchSource<unknown>[];
},
) => Promise<{
data: Ref<unknown>;
error: Ref<unknown>;
}>;
type VueSessionState<Option extends BetterAuthClientOptions> = DeepReadonly<
Ref<{
data: ClientSession<Option>;
isPending: boolean;
isRefetching: boolean;
error: BetterFetchError | null;
refetch: (
queryParams?: { query?: SessionQueryParams } | undefined,
) => Promise<void>;
}>
>;
type SessionFetchResult<Option extends BetterAuthClientOptions> = {
data: Ref<ClientSession<Option>>;
isPending: false;
error: Ref<{
message?: string | undefined;
status: number;
statusText: string;
}>;
};
@@ -89,7 +119,8 @@ export type VueAuthClient<Option extends BetterAuthClientOptions> =
InferClientAPI<Option> &
InferActions<Option> & {
hydrateSession(session: NonNullable<ClientSession<Option>> | null): void;
useSession: VueUseSession<Option>;
useSession(): VueSessionState<Option>;
useSession(useFetch: SessionFetch): Promise<SessionFetchResult<Option>>;
$Infer: {
Session: NonNullable<ClientSession<Option>>;
};
@@ -109,6 +140,7 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
pluginsActions,
pluginsAtoms,
hydrateSession,
$sessionSignal,
$fetch,
$store,
atomListeners,
@@ -126,45 +158,29 @@ export function createAuthClient<Option extends BetterAuthClientOptions>(
resolvedHooks[getAtomKey(key)] = () => useStore(value);
}
function useSession(): DeepReadonly<
Ref<{
data: ClientSession<Option>;
isPending: boolean;
isRefetching: boolean;
error: BetterFetchError | null;
refetch: (
queryParams?: { query?: SessionQueryParams } | undefined,
) => Promise<void>;
}>
>;
function useSession<F extends (...args: any) => any>(
useFetch: F,
): Promise<{
data: Ref<ClientSession<Option>>;
isPending: false; //this is just to be consistent with the default hook
error: Ref<{
message?: string | undefined;
status: number;
statusText: string;
}>;
}>;
function useSession<UseFetch extends <_T>(...args: any) => any>(
useFetch?: UseFetch | undefined,
) {
function useSession(): VueSessionState<Option>;
function useSession(
useFetch: SessionFetch,
): Promise<SessionFetchResult<Option>>;
function useSession(useFetch?: SessionFetch | undefined) {
// Passing `useFetch` opts into Nuxt-managed session fetching and hydration.
if (useFetch) {
const ref = useStore(pluginsAtoms.$sessionSignal!);
const sessionSignal = useStore($sessionSignal);
return useFetch(`${baseURL}/get-session`, {
headers: options?.fetchOptions?.headers,
headers: toHeadersInit(options?.fetchOptions?.headers),
key: sessionCacheKey,
watch: [ref],
}).then((res: any) => {
watch: [sessionSignal],
}).then((result) => {
const data = result.data as Ref<ClientSession<Option>>;
const error = result.error as SessionFetchResult<Option>["error"];
return {
data: res.data,
data,
isPending: false,
error: res.error,
error,
};
});
}
// Otherwise, return Better Auth's session state as a reactive Vue ref.
return resolvedHooks.useSession();
}