diff --git a/SSR_MIGRATION_TASKLIST.md b/SSR_MIGRATION_TASKLIST.md index e95fb70..3011847 100644 --- a/SSR_MIGRATION_TASKLIST.md +++ b/SSR_MIGRATION_TASKLIST.md @@ -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. diff --git a/frontend/middleware/auth.global.ts b/frontend/middleware/auth.global.ts index 4560fb2..c38fc25 100644 --- a/frontend/middleware/auth.global.ts +++ b/frontend/middleware/auth.global.ts @@ -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 } }); } diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 8d24ed8..4b5f718 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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>>; @@ -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 { return `Request failed (${response.status})`; } -async function getJson(path: string, signal?: AbortSignal): Promise { +function normalizeRequestOptions(options?: AbortSignal | ApiRequestOptions): ApiRequestOptions { + if (!options) { + return {}; + } + + if ('aborted' in options && 'addEventListener' in options) { + return { signal: options }; + } + + return options; +} + +async function getJson(path: string, options?: AbortSignal | ApiRequestOptions): Promise { + 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(path: string, signal?: AbortSignal): Promise { } async function sendJson(path: string, method: 'PATCH' | 'POST' | 'PUT', body: unknown): Promise { + 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), diff --git a/frontend/src/views/PokemonList.vue b/frontend/src/views/PokemonList.vue index ef88010..14611bb 100644 --- a/frontend/src/views/PokemonList.vue +++ b/frontend/src/views/PokemonList.vue @@ -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(null); const route = useRoute(); -const { t } = useI18n(); -const pokemon = ref([]); -const currentUser = ref(null); -const loading = ref(true); -const loadingMore = ref(false); -const nextCursor = ref(null); -const hasMorePokemon = ref(false); +const { t, locale } = useI18n(); const search = ref(''); const environmentId = ref(''); const skillIds = ref([]); @@ -37,6 +30,11 @@ const skeletonCardCount = 6; const listPageSize = 24; let loadRequestId = 0; +type PokemonListInitialData = { + options: Options | null; + page: ListPage | 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( + `${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(initialData.value?.options ?? null); +const pokemon = ref(initialPage?.items ?? []); +const currentUser = ref(null); +const initialPageLoaded = ref(initialPage !== null); +const loading = ref(!initialPageLoaded.value); +const loadingMore = ref(false); +const nextCursor = ref(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, () => {