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

@@ -37,6 +37,7 @@ Keep this file aligned with implementation progress while the SSR migration is i
- [x] Add a small SSR-safe fetch wrapper or adapt `frontend/src/services/api.ts` so public reads can be called from server-side setup without depending on `window`, storage, or DOM APIs.
- [x] Keep frontend API response types consistent with `frontend/src/services/api.ts`.
- [ ] Ensure API errors used for SSR public routes degrade to intended empty/error states without leaking stack traces or internal fields into rendered HTML.
- [x] Pokemon and Event Pokemon list SSR reads degrade to null initial data and existing skeleton/empty UI without rendering raw backend errors.
## Phase 3: Authentication And Session Model
@@ -47,7 +48,9 @@ Keep this file aligned with implementation progress while the SSR migration is i
- [x] Preserve email verification as the base requirement for protected writes.
- [ ] Ensure current-user SSR reads expose only the allowed current-user fields defined in `DESIGN.md`.
- [ ] Update route middleware so server-side redirects for authenticated and permissioned routes match current client-side behavior.
- [x] Server-side route middleware forwards the incoming HTTP-only session cookie to `api.me()` for authenticated, verified, and permissioned route checks.
- [ ] Ensure public SSR pages never render private current-user data into HTML meant for anonymous users.
- [x] Pokemon and Event Pokemon list SSR reads do not call `api.me()` or forward cookies; create actions remain client-hydrated after mount.
- [x] Add a clear logout flow that clears both server cookies and any legacy client storage during the transition.
### Phase 3 Auth Notes
@@ -56,6 +59,7 @@ Keep this file aligned with implementation progress while the SSR migration is i
- Protected backend reads and writes accept the HTTP-only cookie first and remain compatible with `Authorization: Bearer` tokens.
- Frontend API requests use `credentials: 'include'` so browser requests can carry the cookie without exposing it to JavaScript.
- Login still stores the legacy token according to Remember me semantics; logout deletes the server session, clears the cookie, and clears legacy frontend storage.
- Server-side auth middleware forwards the incoming SSR request cookie only for `api.me()` checks, allowing HTTP-only session cookies to participate in SSR route redirects without adding private auth headers to public page data requests.
## Phase 4: Nuxt SSR Enablement
@@ -88,6 +92,7 @@ Keep this file aligned with implementation progress while the SSR migration is i
## Phase 5: Server-Side Data And SEO
- [ ] Implement SSR data loading for stable public routes in small groups, starting with low-risk public pages.
- [x] Pokemon and Event Pokemon list routes SSR-load shared options and the first public list page.
- [ ] For each SSR-enabled public route, render title, description, canonical URL, robots value, Open Graph, Twitter card, and structured data from public business data and system wording only.
- [ ] For detail pages, use entity names, public images, localized public fields, and canonical detail URLs after public API data loads server-side.
- [ ] Preserve `noindex` on auth, admin, new, edit, and in-development routes.
@@ -95,6 +100,12 @@ Keep this file aligned with implementation progress while the SSR migration is i
- [ ] Avoid serializing private auth state, raw permissions, internal audit payloads, or unneeded API payload fields into Nuxt payloads.
- [ ] Confirm localized reads follow the fallback order in `DESIGN.md`: requested locale, default-language translation, base field.
### Phase 5 Public Data Notes
- Pokemon and Event Pokemon list routes now SSR-load the shared options payload and first public list page through `useAsyncData`; filter changes, infinite loading, and route-backed create modals continue to use the existing client behavior.
- Pokemon list SSR API failures are contained to null initial data so rendered HTML falls back to the existing skeleton/empty behavior without exposing backend stack traces, raw errors, or internal fields.
- Public Pokemon list SSR data does not request `api.me()` or forward cookies; create actions remain client-hydrated from the current user after mount.
## Phase 6: Browser-Only UI Isolation
- [ ] Move DOM event listeners, resize/scroll handlers, focus traps, modal body locking, clipboard behavior, and `window.confirm` calls into client-only lifecycle paths.
@@ -134,6 +145,10 @@ Keep this file aligned with implementation progress while the SSR migration is i
- [ ] Verify generated HTML and Nuxt payloads do not contain forbidden internal data.
- [ ] Verify `robots.txt`, `sitemap.xml`, canonical URLs, noindex routes, and public detail metadata.
### Phase 8 Validation Notes
- 2026-05-06: After SSR auth cookie forwarding and Pokemon/Event Pokemon first-page SSR data, `pnpm --filter @pokopia/frontend typecheck`, `pnpm --filter @pokopia/frontend lint`, and `pnpm --filter @pokopia/frontend build` passed. The current `lint` script runs `nuxt typecheck`.
## Phase 9: Cleanup
- [ ] Remove legacy SPA-only compatibility paths once SSR behavior is stable and no longer needed.

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, () => {