From 871b4c2723f0903d9bfe0215744f0818b27c3188 Mon Sep 17 00:00:00 2001 From: Baoshuo Date: Wed, 6 May 2026 21:31:56 +0800 Subject: [PATCH] ui-next: endpoint support --- packages/ui-next/index.ts | 14 +-- packages/ui-next/package.json | 1 + packages/ui-next/src/components/link.tsx | 2 - packages/ui-next/src/context/page-data.tsx | 3 - packages/ui-next/src/context/router.tsx | 123 +++++++++++++------- packages/ui-next/src/globals.ts | 69 +++++++++++ packages/ui-next/src/hooks/use-route-map.ts | 8 +- packages/ui-next/src/hooks/use-url.ts | 4 +- packages/ui-next/src/main.tsx | 21 +--- packages/ui-next/src/utils/url.ts | 7 -- 10 files changed, 163 insertions(+), 89 deletions(-) create mode 100644 packages/ui-next/src/globals.ts delete mode 100644 packages/ui-next/src/utils/url.ts diff --git a/packages/ui-next/index.ts b/packages/ui-next/index.ts index 04db9e7fe..97f0bdfac 100644 --- a/packages/ui-next/index.ts +++ b/packages/ui-next/index.ts @@ -5,10 +5,9 @@ import c2k from 'koa2-connect/ts'; import { createServer, type Plugin } from 'vite'; import { serializer } from '@hydrooj/framework'; import type { Context } from 'hydrooj'; -import type { PageData } from './src/context/page-data'; const INJECT_MARKER = ''; -const buildInject = (str: string) => ``; +const buildInject = (data: string) => ``; function hydroPlugins(): Plugin { const virtualModuleId = 'virtual:hydro-plugins'; @@ -75,15 +74,16 @@ export async function apply(ctx: Context) { output: 'html', asFallback: true, priority: 100, - async render(name, args, context) { - const data: PageData = { + async render(_name, args, context) { + const serialized = JSON.stringify({ HYDRO_INJECTED: true, name: context.handler.context._matchedRouteName, args, url: context.handler.context.req.url!, - routeMap: ctx.server.routeMap, - }; - const serialized = JSON.stringify(data, serializer(false, context.handler)); + route_map: ctx.server.routeMap, + endpoint: ctx.setting.get('server.url') || undefined, + // hydro_domains: [], + }, serializer(false, context.handler)); const htmlToRender = html.replace(INJECT_MARKER, buildInject(serialized)); return await vite.transformIndexHtml(context.handler.context.req.url!, htmlToRender); }, diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json index a1d9cc923..a485c277d 100644 --- a/packages/ui-next/package.json +++ b/packages/ui-next/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "is-relative-url": "^4.1.0", "path-to-regexp": "^8.4.2", "react": "^19.2.5", "react-dom": "^19.2.5" diff --git a/packages/ui-next/src/components/link.tsx b/packages/ui-next/src/components/link.tsx index e0ecce006..7f4b5ff4f 100644 --- a/packages/ui-next/src/components/link.tsx +++ b/packages/ui-next/src/components/link.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useMemo } from 'react'; import { useNavigate } from '../context/router'; import { useUrl } from '../hooks/use-url'; -import { isSameOrigin } from '../utils/url'; export interface LinkProps extends Omit, 'href'> { /** Pre-built href. Use this or `to`, not both. */ @@ -28,7 +27,6 @@ export const Link: React.FC> = ({ href, to, p if (e.defaultPrevented || isModifiedEvent(e)) return; if (target && target !== '_self') return; if (download) return; - if (!isSameOrigin(resolvedHref)) return; if (resolvedHref.startsWith('#')) return; const resolved = new URL(resolvedHref, window.location.href); if (resolved.pathname === window.location.pathname && resolved.hash) return; diff --git a/packages/ui-next/src/context/page-data.tsx b/packages/ui-next/src/context/page-data.tsx index 2f6cd4f58..54d9d07bd 100644 --- a/packages/ui-next/src/context/page-data.tsx +++ b/packages/ui-next/src/context/page-data.tsx @@ -3,12 +3,9 @@ import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'; export interface PageData { - HYDRO_INJECTED?: boolean; name: string; args: Record; url: string; - routeMap: Record; - [key: string]: any; } interface PageDataContextValue { diff --git a/packages/ui-next/src/context/router.tsx b/packages/ui-next/src/context/router.tsx index 7fc29899e..5253552f2 100644 --- a/packages/ui-next/src/context/router.tsx +++ b/packages/ui-next/src/context/router.tsx @@ -1,7 +1,7 @@ /* eslint-disable react-refresh/only-export-components */ import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; -import { isSameOrigin } from '../utils/url'; +import { endpointOrigins, endpoints, isInjected, routeMapStore } from '../globals'; import { useSetPageData } from './page-data'; interface InternalState { @@ -43,8 +43,16 @@ export const RouterProvider: React.FC = ({ children }) const genRef = useRef(0); const setData = useSetPageData(); + const isSameOrigin = useCallback((url: string) => { + try { + return endpointOrigins.has(new URL(url, endpoints[0]).origin); + } catch { + return false; + } + }, []); + const fetchPage = useCallback( - async (url: string) => { + async (url: string, init = false) => { abortRef.current?.abort(); const gen = ++genRef.current; const controller = new AbortController(); @@ -52,50 +60,66 @@ export const RouterProvider: React.FC = ({ children }) dispatch({ type: 'FETCH_START' }); - try { - const res = await fetch(url, { - signal: controller.signal, - headers: { - Accept: 'application/json', - 'x-hydro-inject': 'uicontext,usercontext,pagename', - }, - }); - if (res.redirected) { - window.location.href = res.url; - return false; + let lastError: Error | null = null; + for (const ep of endpoints) { + try { + const signal = endpoints.length > 1 + ? AbortSignal.any([controller.signal, AbortSignal.timeout(10000)]) + : controller.signal; + const reqUrl = new URL(url, ep).href; + const res = await fetch(reqUrl, { + signal, + headers: { + Accept: 'application/json', + 'x-hydro-inject': [ + 'uicontext', 'usercontext', 'pagename', + ...(init ? ['routemap'] : []), + ].join(','), + }, + }); + if (res.redirected) { + window.location.href = res.url; + return false; + } + if (!res.ok) throw new Error(`Navigation failed: ${res.status} ${res.statusText}`); + const body = await res.json(); + const pageName = res.headers.get('x-hydro-page') || ''; + console.log('[Hydro] data from', reqUrl, 'received:', body, 'pageName:', pageName); + + if (gen !== genRef.current) return false; + + if (init && body.routeMap && typeof body.routeMap === 'object') { + routeMapStore.set(body.routeMap); + } + setData((prev) => ({ + ...prev, + args: body, + name: pageName, + url, + })); + dispatch({ type: 'FETCH_SUCCESS' }); + return true; + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') { + dispatch({ type: 'FETCH_ABORT' }); + console.log('[Hydro] navigation to', url, 'aborted'); + return false; + } + lastError = e instanceof Error ? e : new Error(String(e)); + console.warn('[Hydro] endpoint', ep, 'failed:', lastError.message); + if (controller.signal.aborted) { + // User-initiated abort propagated through AbortSignal.any + dispatch({ type: 'FETCH_ABORT' }); + return false; + } } - if (!res.ok) throw new Error(`Navigation failed: ${res.status} ${res.statusText}`); - const body = await res.json(); - const pageName = res.headers.get('x-hydro-page') || ''; - console.log('[Hydro] data from', url, 'received:', body, 'pageName:', pageName); - - // If a newer fetch has started, ignore this result - if (gen !== genRef.current) return false; - - setData((prev) => ({ - ...prev, - args: body, - name: pageName, - url, - })); - dispatch({ type: 'FETCH_SUCCESS' }); - return true; - } catch (e) { - if (e instanceof DOMException && e.name === 'AbortError') { - dispatch({ type: 'FETCH_ABORT' }); - console.log('[Hydro] navigation to', url, 'aborted'); - return false; - } - - const err = e instanceof Error ? e : new Error(String(e)); - console.error('[Hydro] navigation error:', err); - - if (gen !== genRef.current) return false; - - dispatch({ type: 'FETCH_ERROR', error: err }); - window.location.href = url; - return false; } + + console.error('[Hydro] all endpoints failed:', lastError); + if (gen !== genRef.current) return false; + dispatch({ type: 'FETCH_ERROR', error: lastError! }); + window.location.href = url; + return false; }, [setData], ); @@ -111,10 +135,19 @@ export const RouterProvider: React.FC = ({ children }) return () => window.removeEventListener('popstate', handler); }, [fetchPage]); + // If no server-side injection, fetch initial page data from the API + useEffect(() => { + if (!isInjected) { + console.log('[Hydro] no initial data injection found, fetching page data for current URL'); + fetchPage(window.location.pathname + window.location.search, true); + } + }, [fetchPage]); + const stateValue = useMemo( () => ({ loading: state.status === 'loading', error: state.error }), [state.status, state.error], ); + const navigate = useCallback(async (url: string) => { if (!isSameOrigin(url)) { window.location.href = url; @@ -122,7 +155,7 @@ export const RouterProvider: React.FC = ({ children }) } const ok = await fetchPage(url); if (ok) history.pushState({ url }, '', url); - }, [fetchPage]); + }, [fetchPage, isSameOrigin]); const navigateValue = useMemo(() => ({ navigate }), [navigate]); diff --git a/packages/ui-next/src/globals.ts b/packages/ui-next/src/globals.ts new file mode 100644 index 000000000..a986e1433 --- /dev/null +++ b/packages/ui-next/src/globals.ts @@ -0,0 +1,69 @@ +import isRelativeUrl from 'is-relative-url'; +import type { PageData } from './context/page-data'; + +const injectionEl = document.getElementById('__HYDRO_INJECTION__'); +let injectionData: Record = {}; +if (injectionEl) { + try { + injectionData = JSON.parse(injectionEl.textContent!); + console.log('[Hydro] initial data:', injectionData); + } catch (e) { + console.error('[Hydro] Failed to parse injection data:', e); + } +} + +export const isInjected: boolean = !!injectionData.HYDRO_INJECTED; +export const hydroDomains: string[] = injectionData.hydro_domains ?? []; + +// routeMap as an external store for useSyncExternalStore, with HMR state preservation +interface RouteMapStore { + _routeMap: Record; + _listeners: Set<() => void>; + getSnapshot: () => Record; + subscribe: (listener: () => void) => () => void; + set: (map: Record) => void; +} + +function createRouteMapStore(initial: Record): RouteMapStore { + const store: RouteMapStore = { + _routeMap: initial, + _listeners: new Set(), + getSnapshot: () => store._routeMap, + subscribe: (listener: () => void) => { + store._listeners.add(listener); + return () => { store._listeners.delete(listener); }; + }, + set: (map: Record) => { + store._routeMap = { ...store._routeMap, ...map }; + store._listeners.forEach((l) => l()); + }, + }; + return store; +} + +export const routeMapStore: RouteMapStore = import.meta.hot?.data?.routeMapStore + ?? createRouteMapStore(injectionData.route_map || {}); +if (import.meta.hot) import.meta.hot.data.routeMapStore = routeMapStore; + +export const endpoints: string[] = (() => { + if (hydroDomains.length) { + return hydroDomains + .map((d) => d.includes('://') ? d : `${window.location.protocol}//${d}`) + .map((d) => d.replace(/\/$/, '')); + } + if (typeof injectionData.endpoint === 'string') { + const ep = injectionData.endpoint; + if (isRelativeUrl(ep, { allowProtocolRelative: false })) { + return [new URL(ep, window.location.href).href.replace(/\/$/, '')]; + } + return [ep.replace(/\/$/, '')]; + } + return [window.location.origin]; +})(); +export const endpointOrigins = new Set(endpoints.map((ep) => new URL(ep).origin)); + +export const initialPage: PageData = { + name: (injectionData.name as string) || '', + args: (injectionData.args as Record) || {}, + url: (injectionData.url as string) || (window.location.pathname + window.location.search), +}; diff --git a/packages/ui-next/src/hooks/use-route-map.ts b/packages/ui-next/src/hooks/use-route-map.ts index f51c0743a..885db4fa5 100644 --- a/packages/ui-next/src/hooks/use-route-map.ts +++ b/packages/ui-next/src/hooks/use-route-map.ts @@ -1,6 +1,6 @@ -import { usePageData } from '../context/page-data'; +import { useSyncExternalStore } from 'react'; +import { routeMapStore } from '../globals'; -export function useRouteMap(): Record { - const data = usePageData(); - return data.routeMap; +export function useRouteMap() { + return useSyncExternalStore(routeMapStore.subscribe, routeMapStore.getSnapshot); } diff --git a/packages/ui-next/src/hooks/use-url.ts b/packages/ui-next/src/hooks/use-url.ts index a505d6867..795bff72c 100644 --- a/packages/ui-next/src/hooks/use-url.ts +++ b/packages/ui-next/src/hooks/use-url.ts @@ -14,7 +14,7 @@ function buildUrl(pattern: string, params: Record = {}): string export function useUrl() { const routeMap = useRouteMap(); - const url = useCallback((name: string, params: Record = {}): string => { + return useCallback((name: string, params: Record = {}): string => { const pattern = routeMap[name]; if (!pattern) { console.warn(`[Hydro] Unknown route: ${name}`); @@ -22,6 +22,4 @@ export function useUrl() { } return buildUrl(pattern, params); }, [routeMap]); - - return url; } diff --git a/packages/ui-next/src/main.tsx b/packages/ui-next/src/main.tsx index 93b5e6d83..540e19322 100644 --- a/packages/ui-next/src/main.tsx +++ b/packages/ui-next/src/main.tsx @@ -2,33 +2,18 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import plugins from 'virtual:hydro-plugins'; import App from './app'; -import { type PageData, PageDataProvider } from './context/page-data'; +import { PageDataProvider } from './context/page-data'; import { RouterProvider } from './context/router'; +import { initialPage } from './globals'; import { installPlugin } from './registry'; for (const plugin of plugins) { installPlugin(plugin); } -const injectionEl = document.getElementById('__HYDRO_INJECTION__'); -let initialData: PageData = { - name: '', - args: {}, - url: window.location.pathname, - routeMap: {}, -}; -if (injectionEl) { - try { - initialData = JSON.parse(injectionEl.textContent!); - console.log('[Hydro] initial data:', initialData); - } catch (e) { - console.error('[Hydro] Failed to parse injection data:', e); - } -} - createRoot(document.getElementById('root')!).render( - + diff --git a/packages/ui-next/src/utils/url.ts b/packages/ui-next/src/utils/url.ts deleted file mode 100644 index b0e7a8de0..000000000 --- a/packages/ui-next/src/utils/url.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function isSameOrigin(url: string): boolean { - try { - return new URL(url, window.location.origin).origin === window.location.origin; - } catch { - return false; - } -}