mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
ui-next: endpoint support
This commit is contained in:
10 files changed
+134
-60
No files matched your search
@@ -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);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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> {
|
export function useRouteMap() {
|
||||||
const data = usePageData();
|
return useSyncExternalStore(routeMapStore.subscribe, routeMapStore.getSnapshot);
|
||||||
return data.routeMap;
|
|
||||||
}
|
}
|
||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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