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
+163 -89

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 { serializer } from '@hydrooj/framework';
import type { Context } from 'hydrooj';
import type { PageData } from './src/context/page-data';
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 {
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);
},
+1
View File
@@ -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"
-2
View File
@@ -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<React.AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
/** 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 (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;
@@ -3,12 +3,9 @@
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
export interface PageData {
HYDRO_INJECTED?: boolean;
name: string;
args: Record<string, any>;
url: string;
routeMap: Record<string, string>;
[key: string]: any;
}
interface PageDataContextValue {
+78 -45
View File
@@ -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<React.PropsWithChildren> = ({ 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<React.PropsWithChildren> = ({ 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<React.PropsWithChildren> = ({ 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<React.PropsWithChildren> = ({ children })
}
const ok = await fetchPage(url);
if (ok) history.pushState({ url }, '', url);
}, [fetchPage]);
}, [fetchPage, isSameOrigin]);
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> {
const data = usePageData();
return data.routeMap;
export function useRouteMap() {
return useSyncExternalStore(routeMapStore.subscribe, routeMapStore.getSnapshot);
}
+1 -3
View File
@@ -14,7 +14,7 @@ function buildUrl(pattern: string, params: Record<string, string> = {}): string
export function useUrl() {
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];
if (!pattern) {
console.warn(`[Hydro] Unknown route: ${name}`);
@@ -22,6 +22,4 @@ export function useUrl() {
}
return buildUrl(pattern, params);
}, [routeMap]);
return url;
}
+3 -18
View File
@@ -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(
<StrictMode>
<PageDataProvider initial={initialData}>
<PageDataProvider initial={initialPage}>
<RouterProvider>
<App />
</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;
}
}