import { QueryClient, QueryClientProvider, dehydrate, skipToken } from '@tanstack/react-query' import { PassThrough } from 'node:stream' import { renderToPipeableStream } from 'react-dom/server' import { HelmetProvider } from 'react-helmet-async' import { I18nextProvider } from 'react-i18next' import { createStaticHandler, createStaticRouter, StaticRouterProvider } from 'react-router-dom/server' import { AppFrame, appRoutes, resolvePageRoute } from '@/App' import { apiGet, isApiError } from '@/lib/api' import { createUII18n } from '@/lib/i18n' import { fetchLocales, isLocaleCode, legacyLocalePath } from '@/lib/locales' import { localesKeys, sessionKeys } from '@/lib/queryKeys' import { withRenderContext } from '@/lib/renderContext.server' import type { HelmetServerState } from 'react-helmet-async' import type { ReactNode } from 'react' import type { QueryObserverOptions } from '@tanstack/react-query' import type { RenderContext } from '@/lib/renderContext' import type { User } from '@/types' export { renderSitemap } from '@/lib/sitemap.server' export { isCountsPath } from '@/lib/routeLocales' type RenderedPage = { status: number html: string head: string locale: string bootstrap: string setCookies?: string[] } type RouteResponse = { status: 404 | 308; location?: string } const renderDocument = (element: ReactNode): Promise => new Promise ((resolve, reject) => { const output = new PassThrough () const chunks: Buffer[] = [] output.on ('data', chunk => chunks.push (Buffer.from (chunk))) output.on ('error', reject) output.on ('end', () => resolve (Buffer.concat (chunks).toString ('utf8'))) const timeout = setTimeout (() => { stream.abort () reject (new Error ('SSR rendering timed out')) }, 30_000) const stream = renderToPipeableStream (element, { onAllReady: () => { clearTimeout (timeout) stream.pipe (output) }, onShellError: error => { clearTimeout (timeout) reject (error) }, onError: error => { clearTimeout (timeout) reject (error) } }) }) export const render = async ( address: string, origin: string, apiBaseURL: string, authentication: Pick = { }, ): Promise => { const url = new URL (address, origin) const legacy = legacyLocalePath ( { pathname: url.pathname, search: url.search, hash: url.hash }, pathname => resolvePageRoute (pathname) != null) const pathname = legacy == null ? url.pathname : new URL (legacy, origin).pathname const segment = pathname.split ('/')[1] const known = resolvePageRoute (pathname) if (legacy != null && known != null) return { status: 308, location: legacy } const locale = isLocaleCode (segment) ? segment : 'ja' if (known?.route.handle?.redirectTo != null) return { status: 308, location: `/${locale}${known.route.handle.redirectTo}${url.search}` } const requestContext: RenderContext & { status: number } = { pathname: url.pathname, origin, apiBaseURL, ...authentication, status: 200 } return withRenderContext (requestContext, async () => { const client = new QueryClient ({ defaultOptions: { queries: { retry: false, gcTime: Infinity, staleTime: Infinity } } }) try { const locales = await client.fetchQuery ({ queryKey: localesKeys.index, queryFn: fetchLocales }) const active = locales.locales.some (entry => entry.code === locale) if (authentication.cookie != null) { try { const user = await apiGet> ('/users/session') client.setQueryData (sessionKeys.current, { id: user.id, name: user.name, role: user.role, inheritanceCode: '' }) } catch (error) { if (!isApiError (error) || error.response?.status !== 401) throw error delete requestContext.cookie } } const prepare = known?.route.handle?.prepare let status = known == null || !active || !isLocaleCode (segment) ? 404 : 200 if (status === 200 && known?.route.handle?.authentication != null && client.getQueryData (sessionKeys.current) == null) status = 401 if (prepare != null && known != null && status === 200) status = await prepare ({ client, params: known.params, locale, searchParams: url.searchParams }) const instance = createUII18n (locale) const handler = createStaticHandler (appRoutes) const context = await handler.query (new Request (url)) if (context instanceof Response) throw new Error (`Unexpected router response: ${context.status}`) const router = createStaticRouter (handler.dataRoutes, context) let helmetContext: { helmet?: HelmetServerState } = { } const page = () => ( ) let html = '' const deadline = Date.now () + 30_000 // Each pass discovers the queries used by the actual page, including dependent queries. // Streaming resolves lazy components; only the last, fully prepared pass is returned. for (;;) { if (Date.now () >= deadline) throw new Error ('SSR data preparation timed out') helmetContext = { } requestContext.status = status html = await renderDocument (page ()) // Client-only queries must neither run here nor enter the bootstrap below. const pending = client.getQueryCache ().getAll ().filter (query => { const options = query.options as QueryObserverOptions return query.state.status === 'pending' && query.meta?.clientOnly !== true && options.queryFn != null && options.queryFn !== skipToken && options.enabled !== false && (typeof options.enabled !== 'function' || options.enabled (query)) }) if (pending.length === 0) { status = requestContext.status break } await Promise.all (pending.map (async query => { try { await client.fetchQuery ({ ...query.options, queryKey: query.queryKey }) } catch (error) { const code = isApiError (error) ? error.response?.status : null if (code === 404 || code === 401 || code === 403) status = code else throw error } })) } const helmet = helmetContext.helmet! const head = [helmet.title, helmet.meta, helmet.link, helmet.style] .map (entry => entry.toString ()).join ('') const dehydrated = dehydrate (client, { shouldDehydrateQuery: query => query.state.status !== 'pending' && query.meta?.clientOnly !== true && !(query.queryKey[0] === 'help' && query.queryKey[1] === 'edition') }) const state = { ...dehydrated, queries: dehydrated.queries.map (query => ({ ...query, state: { ...query.state, fetchFailureReason: null, error: query.state.error == null ? null : { isAxiosError: true, response: { status: isApiError (query.state.error) ? query.state.error.response?.status : 500 } } } })) } const bootstrap = JSON.stringify ({ state, status, locale, url: url.pathname + url.search }) .replace (/