ui-next: endpoint support

This commit is contained in:
Baoshuo committed 2026-05-10 01:12:47 +08:00
1 parent 504b9639b5
commit 871b4c2723
10 files changed
+134 -60

No files matched your search

+7 -7
View File
@@ -5,10 +5,9 @@ import c2k from 'koa2-connect/ts';
import { createServer, type Plugin } from 'vite'; import { createServer, type Plugin } from 'vite';
import { serializer } from '@hydrooj/framework'; import { serializer } from '@hydrooj/framework';
import type { Context } from 'hydrooj'; import type { Context } from 'hydrooj';
import type { PageData } from './src/context/page-data';
const INJECT_MARKER = '<!-- __HYDRO_INJECTION__DO_NOT_REMOVE_THIS__ -->'; const INJECT_MARKER = '<!-- __HYDRO_INJECTION__DO_NOT_REMOVE_THIS__ -->';
const buildInject = (str: string) => `<script id="__HYDRO_INJECTION__" type="application/json">${str}</script>`; const buildInject = (data: string) => `<script id="__HYDRO_INJECTION__" type="application/json">${data}</script>`;
function hydroPlugins(): Plugin { function hydroPlugins(): Plugin {
const virtualModuleId = 'virtual:hydro-plugins'; const virtualModuleId = 'virtual:hydro-plugins';
@@ -75,15 +74,16 @@ export async function apply(ctx: Context) {
output: 'html', output: 'html',
asFallback: true, asFallback: true,
priority: 100, priority: 100,
async render(name, args, context) { async render(_name, args, context) {
const data: PageData = { const serialized = JSON.stringify({
HYDRO_INJECTED: true, HYDRO_INJECTED: true,
name: context.handler.context._matchedRouteName, name: context.handler.context._matchedRouteName,
args, args,
url: context.handler.context.req.url!, url: context.handler.context.req.url!,
routeMap: ctx.server.routeMap, route_map: ctx.server.routeMap,
}; endpoint: ctx.setting.get('server.url') || undefined,
const serialized = JSON.stringify(data, serializer(false, context.handler)); // hydro_domains: [],
}, serializer(false, context.handler));
const htmlToRender = html.replace(INJECT_MARKER, buildInject(serialized)); const htmlToRender = html.replace(INJECT_MARKER, buildInject(serialized));
return await vite.transformIndexHtml(context.handler.context.req.url!, htmlToRender); return await vite.transformIndexHtml(context.handler.context.req.url!, htmlToRender);
}, },
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"is-relative-url": "^4.1.0",
"path-to-regexp": "^8.4.2", "path-to-regexp": "^8.4.2",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5" "react-dom": "^19.2.5"
-2
View File
@@ -1,7 +1,6 @@
import React, { useCallback, useMemo } from 'react'; import React, { useCallback, useMemo } from 'react';
import { useNavigate } from '../context/router'; import { useNavigate } from '../context/router';
import { useUrl } from '../hooks/use-url'; import { useUrl } from '../hooks/use-url';
import { isSameOrigin } from '../utils/url';
export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> { export interface LinkProps extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
/** Pre-built href. Use this or `to`, not both. */ /** Pre-built href. Use this or `to`, not both. */
@@ -28,7 +27,6 @@ export const Link: React.FC<React.PropsWithChildren<LinkProps>> = ({ href, to, p
if (e.defaultPrevented || isModifiedEvent(e)) return; if (e.defaultPrevented || isModifiedEvent(e)) return;
if (target && target !== '_self') return; if (target && target !== '_self') return;
if (download) return; if (download) return;
if (!isSameOrigin(resolvedHref)) return;
if (resolvedHref.startsWith('#')) return; if (resolvedHref.startsWith('#')) return;
const resolved = new URL(resolvedHref, window.location.href); const resolved = new URL(resolvedHref, window.location.href);
if (resolved.pathname === window.location.pathname && resolved.hash) return; if (resolved.pathname === window.location.pathname && resolved.hash) return;
@@ -3,12 +3,9 @@
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'; import { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
export interface PageData { export interface PageData {
HYDRO_INJECTED?: boolean;
name: string; name: string;
args: Record<string, any>; args: Record<string, any>;
url: string; url: string;
routeMap: Record<string, string>;
[key: string]: any;
} }
interface PageDataContextValue { interface PageDataContextValue {
+49 -16
View File
@@ -1,7 +1,7 @@
/* eslint-disable react-refresh/only-export-components */ /* eslint-disable react-refresh/only-export-components */
import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; 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'; import { useSetPageData } from './page-data';
interface InternalState { interface InternalState {
@@ -43,8 +43,16 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
const genRef = useRef(0); const genRef = useRef(0);
const setData = useSetPageData(); const setData = useSetPageData();
const isSameOrigin = useCallback((url: string) => {
try {
return endpointOrigins.has(new URL(url, endpoints[0]).origin);
} catch {
return false;
}
}, []);
const fetchPage = useCallback( const fetchPage = useCallback(
async (url: string) => { async (url: string, init = false) => {
abortRef.current?.abort(); abortRef.current?.abort();
const gen = ++genRef.current; const gen = ++genRef.current;
const controller = new AbortController(); const controller = new AbortController();
@@ -52,12 +60,21 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
dispatch({ type: 'FETCH_START' }); dispatch({ type: 'FETCH_START' });
let lastError: Error | null = null;
for (const ep of endpoints) {
try { try {
const res = await fetch(url, { const signal = endpoints.length > 1
signal: controller.signal, ? AbortSignal.any([controller.signal, AbortSignal.timeout(10000)])
: controller.signal;
const reqUrl = new URL(url, ep).href;
const res = await fetch(reqUrl, {
signal,
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'x-hydro-inject': 'uicontext,usercontext,pagename', 'x-hydro-inject': [
'uicontext', 'usercontext', 'pagename',
...(init ? ['routemap'] : []),
].join(','),
}, },
}); });
if (res.redirected) { if (res.redirected) {
@@ -67,11 +84,13 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
if (!res.ok) throw new Error(`Navigation failed: ${res.status} ${res.statusText}`); if (!res.ok) throw new Error(`Navigation failed: ${res.status} ${res.statusText}`);
const body = await res.json(); const body = await res.json();
const pageName = res.headers.get('x-hydro-page') || ''; const pageName = res.headers.get('x-hydro-page') || '';
console.log('[Hydro] data from', url, 'received:', body, 'pageName:', pageName); console.log('[Hydro] data from', reqUrl, 'received:', body, 'pageName:', pageName);
// If a newer fetch has started, ignore this result
if (gen !== genRef.current) return false; if (gen !== genRef.current) return false;
if (init && body.routeMap && typeof body.routeMap === 'object') {
routeMapStore.set(body.routeMap);
}
setData((prev) => ({ setData((prev) => ({
...prev, ...prev,
args: body, args: body,
@@ -86,16 +105,21 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
console.log('[Hydro] navigation to', url, 'aborted'); console.log('[Hydro] navigation to', url, 'aborted');
return false; return false;
} }
lastError = e instanceof Error ? e : new Error(String(e));
const err = e instanceof Error ? e : new Error(String(e)); console.warn('[Hydro] endpoint', ep, 'failed:', lastError.message);
console.error('[Hydro] navigation error:', err); if (controller.signal.aborted) {
// User-initiated abort propagated through AbortSignal.any
if (gen !== genRef.current) return false; dispatch({ type: 'FETCH_ABORT' });
dispatch({ type: 'FETCH_ERROR', error: err });
window.location.href = url;
return false; 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], [setData],
); );
@@ -111,10 +135,19 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
return () => window.removeEventListener('popstate', handler); return () => window.removeEventListener('popstate', handler);
}, [fetchPage]); }, [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( const stateValue = useMemo(
() => ({ loading: state.status === 'loading', error: state.error }), () => ({ loading: state.status === 'loading', error: state.error }),
[state.status, state.error], [state.status, state.error],
); );
const navigate = useCallback(async (url: string) => { const navigate = useCallback(async (url: string) => {
if (!isSameOrigin(url)) { if (!isSameOrigin(url)) {
window.location.href = url; window.location.href = url;
@@ -122,7 +155,7 @@ export const RouterProvider: React.FC<React.PropsWithChildren> = ({ children })
} }
const ok = await fetchPage(url); const ok = await fetchPage(url);
if (ok) history.pushState({ url }, '', url); if (ok) history.pushState({ url }, '', url);
}, [fetchPage]); }, [fetchPage, isSameOrigin]);
const navigateValue = useMemo<RouterNavigateContextValue>(() => ({ navigate }), [navigate]); const navigateValue = useMemo<RouterNavigateContextValue>(() => ({ navigate }), [navigate]);
+69
View File
@@ -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<string, any> = {};
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<string, string>;
_listeners: Set<() => void>;
getSnapshot: () => Record<string, string>;
subscribe: (listener: () => void) => () => void;
set: (map: Record<string, string>) => void;
}
function createRouteMapStore(initial: Record<string, string>): 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<string, string>) => {
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<string, any>) || {},
url: (injectionData.url as string) || (window.location.pathname + window.location.search),
};
+4 -4
View File
@@ -1,6 +1,6 @@
import { usePageData } from '../context/page-data'; import { useSyncExternalStore } from 'react';
import { routeMapStore } from '../globals';
export function useRouteMap(): Record<string, string> { export function useRouteMap() {
const data = usePageData(); return useSyncExternalStore(routeMapStore.subscribe, routeMapStore.getSnapshot);
return data.routeMap;
} }
+1 -3
View File
@@ -14,7 +14,7 @@ function buildUrl(pattern: string, params: Record<string, string> = {}): string
export function useUrl() { export function useUrl() {
const routeMap = useRouteMap(); const routeMap = useRouteMap();
const url = useCallback((name: string, params: Record<string, string> = {}): string => { return useCallback((name: string, params: Record<string, string> = {}): string => {
const pattern = routeMap[name]; const pattern = routeMap[name];
if (!pattern) { if (!pattern) {
console.warn(`[Hydro] Unknown route: ${name}`); console.warn(`[Hydro] Unknown route: ${name}`);
@@ -22,6 +22,4 @@ export function useUrl() {
} }
return buildUrl(pattern, params); return buildUrl(pattern, params);
}, [routeMap]); }, [routeMap]);
return url;
} }
+3 -18
View File
@@ -2,33 +2,18 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import plugins from 'virtual:hydro-plugins'; import plugins from 'virtual:hydro-plugins';
import App from './app'; import App from './app';
import { type PageData, PageDataProvider } from './context/page-data'; import { PageDataProvider } from './context/page-data';
import { RouterProvider } from './context/router'; import { RouterProvider } from './context/router';
import { initialPage } from './globals';
import { installPlugin } from './registry'; import { installPlugin } from './registry';
for (const plugin of plugins) { for (const plugin of plugins) {
installPlugin(plugin); 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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<PageDataProvider initial={initialData}> <PageDataProvider initial={initialPage}>
<RouterProvider> <RouterProvider>
<App /> <App />
</RouterProvider> </RouterProvider>
-7
View File
@@ -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;
}
}