feat(ssr): load Pokemon lists and forward auth cookies on server

Update auth middleware to pass incoming request cookies to api.me()
Refactor API service to support custom headers via ApiRequestOptions
Use useAsyncData in PokemonList to fetch initial data during SSR
Ensure graceful fallback to client-side fetching on SSR failure
This commit is contained in:
2026-05-06 10:50:51 +08:00
parent 35ee164794
commit 425f2f4d5f
4 changed files with 105 additions and 25 deletions

View File

@@ -17,7 +17,7 @@ export default defineNuxtRouteMiddleware(async (to) => {
}
try {
const response = await api.me();
const response = await api.me(import.meta.server ? { headers: useRequestHeaders(['cookie']) } : undefined);
if (requiresVerified && !response.user.emailVerified) {
return navigateTo({ path: '/login', query: { redirect: to.fullPath } });
}

View File

@@ -5,6 +5,11 @@ let serverApiBaseUrl = 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change';
export interface ApiRequestOptions {
signal?: AbortSignal;
headers?: HeadersInit;
}
export type TranslationField = 'name' | 'title' | 'details' | 'genus' | 'effect' | 'mosslaxEffect';
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
@@ -1104,12 +1109,15 @@ export function notifyAuthChange(): void {
}
}
function requestHeaders(): HeadersInit {
function requestHeaders(extraHeaders?: HeadersInit): Headers {
const headers = new Headers(extraHeaders);
const token = getAuthToken();
return {
'X-Locale': getCurrentLocale(),
...(token ? { Authorization: `Bearer ${token}` } : {})
};
headers.set('X-Locale', headers.get('X-Locale') ?? getCurrentLocale());
if (token && !headers.has('Authorization')) {
headers.set('Authorization', `Bearer ${token}`);
}
return headers;
}
export function notificationWebSocketUrl(ticket: string): string {
@@ -1134,11 +1142,24 @@ async function getErrorMessage(response: Response): Promise<string> {
return `Request failed (${response.status})`;
}
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
function normalizeRequestOptions(options?: AbortSignal | ApiRequestOptions): ApiRequestOptions {
if (!options) {
return {};
}
if ('aborted' in options && 'addEventListener' in options) {
return { signal: options };
}
return options;
}
async function getJson<T>(path: string, options?: AbortSignal | ApiRequestOptions): Promise<T> {
const requestOptions = normalizeRequestOptions(options);
const response = await fetch(apiUrl(path), {
credentials: 'include',
headers: requestHeaders(),
signal
headers: requestHeaders(requestOptions.headers),
signal: requestOptions.signal
});
if (!response.ok) {
@@ -1149,13 +1170,13 @@ async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
}
async function sendJson<T>(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise<T> {
const headers = requestHeaders();
headers.set('Content-Type', 'application/json');
const response = await fetch(apiUrl(path), {
credentials: 'include',
method,
headers: {
'Content-Type': 'application/json',
...requestHeaders()
},
headers,
body: JSON.stringify(body)
});
@@ -1261,7 +1282,7 @@ export const api = {
sendJson<{ message: string }>('/api/auth/request-password-reset', 'POST', payload),
resetPassword: (payload: { token: string; password: string }) =>
sendJson<{ message: string }>('/api/auth/reset-password', 'POST', payload),
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
me: (options?: ApiRequestOptions) => getJson<{ user: AuthUser }>('/api/auth/me', options),
updateMe: (payload: UserProfilePayload) => sendJson<{ user: AuthUser }>('/api/auth/me', 'PATCH', payload),
changePassword: (payload: ChangePasswordPayload) =>
sendJson<{ message: string }>('/api/auth/me/password', 'PATCH', payload),

View File

@@ -10,22 +10,15 @@ import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { iconAdd } from '../icons';
import { api, getAuthToken, type AuthUser, type Options, type Pokemon } from '../services/api';
import { api, getAuthToken, type AuthUser, type ListPage, type Options, type Pokemon } from '../services/api';
import PokemonEdit from './PokemonEdit.vue';
const props = defineProps<{
eventOnly?: boolean;
}>();
const options = ref<Options | null>(null);
const route = useRoute();
const { t } = useI18n();
const pokemon = ref<Pokemon[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
const loadingMore = ref(false);
const nextCursor = ref<string | null>(null);
const hasMorePokemon = ref(false);
const { t, locale } = useI18n();
const search = ref('');
const environmentId = ref('');
const skillIds = ref<string[]>([]);
@@ -37,6 +30,11 @@ const skeletonCardCount = 6;
const listPageSize = 24;
let loadRequestId = 0;
type PokemonListInitialData = {
options: Options | null;
page: ListPage<Pokemon> | null;
};
const query = computed(() => ({
search: search.value,
isEventItem: props.eventOnly ? 'true' : 'false',
@@ -46,6 +44,36 @@ const query = computed(() => ({
favoriteThingIds: favoriteThingIds.value.join(','),
favoriteThingMode: favoriteThingMode.value
}));
const { data: initialData } = await useAsyncData<PokemonListInitialData>(
`${props.eventOnly ? 'event-pokemon-list-initial' : 'pokemon-list-initial'}:${locale.value}`,
async () => {
const [optionsResult, pokemonResult] = await Promise.allSettled([
api.options(),
api.pokemonPage({
...query.value,
cursor: null,
limit: listPageSize
})
]);
return {
options: optionsResult.status === 'fulfilled' ? optionsResult.value : null,
page: pokemonResult.status === 'fulfilled' ? pokemonResult.value : null
};
},
{ default: () => ({ options: null, page: null }) }
);
const initialPage = initialData.value?.page ?? null;
const options = ref<Options | null>(initialData.value?.options ?? null);
const pokemon = ref<Pokemon[]>(initialPage?.items ?? []);
const currentUser = ref<AuthUser | null>(null);
const initialPageLoaded = ref(initialPage !== null);
const loading = ref(!initialPageLoaded.value);
const loadingMore = ref(false);
const nextCursor = ref<string | null>(initialPage?.nextCursor ?? null);
const hasMorePokemon = ref(initialPage?.hasMore ?? false);
const showEditor = computed(() => route.name === 'pokemon-new' || route.name === 'event-pokemon-new');
const canCreatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.create') === true);
const pageTitle = computed(() => t(props.eventOnly ? 'pages.eventPokemon.title' : 'pages.pokemon.title'));
@@ -88,6 +116,14 @@ async function loadPokemon(reset = true) {
}
nextCursor.value = page.nextCursor;
hasMorePokemon.value = page.hasMore;
initialPageLoaded.value = true;
} catch {
if (requestId === loadRequestId && reset) {
pokemon.value = [];
nextCursor.value = null;
hasMorePokemon.value = false;
initialPageLoaded.value = true;
}
} finally {
if (requestId === loadRequestId) {
loading.value = false;
@@ -112,8 +148,16 @@ onMounted(async () => {
currentUser.value = null;
}
}
options.value = await api.options();
await loadPokemon();
if (!options.value) {
try {
options.value = await api.options();
} catch {
options.value = null;
}
}
if (!initialPageLoaded.value) {
await loadPokemon();
}
});
watch(query, () => {