mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
ui-next: endpoint support
This commit is contained in:
10 files changed
+163
-89
No files matched your search
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user