diff --git a/.gitignore b/.gitignore
index 6cb592a6a..0a908c760 100644
--- a/.gitignore
+++ b/.gitignore
@@ -48,6 +48,7 @@ packages/**/*.js
packages/ui-default/public
packages/ui-default/misc/.iconfont
packages/ui-default/static/locale
+packages/ui-next/public
plugins/
modules/
diff --git a/build/prepare.js b/build/prepare.js
index ba42f9a0f..f0bc79eb7 100644
--- a/build/prepare.js
+++ b/build/prepare.js
@@ -137,7 +137,6 @@ const UINextConfig = {
module: 'ESNext',
skipLibCheck: true,
allowSyntheticDefaultImports: true,
- baseUrl: '.',
jsx: 'react-jsx',
outDir: path.join(baseOutDir, 'ui-next'),
diff --git a/package.json b/package.json
index ecce9f070..53cbce46d 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"build:ui:dev:https": "node packages/ui-default/build --iconfont && node --trace-deprecation packages/ui-default/build --dev --https",
"build:ui:production": "cross-env-shell NODE_OPTIONS=--max_old_space_size=8192 \"node packages/ui-default/build --iconfont && node packages/ui-default/build --production\"",
"build:ui:production:webpack": "cross-env NODE_OPTIONS=--max_old_space_size=8192 node packages/ui-default/build --production",
+ "build:ui-next": "tsc -p tsconfig.ui-next.json --noEmit && yarn workspace @hydrooj/ui-next vite build",
"test": "node -r @hydrooj/register packages/common/tests/subtask.spec.ts && node test/entry.js",
"benchmark": "cross-env BENCHMARK=true node test/entry.js",
"oxlint": "cd framework/eslint-config && node -r @hydrooj/register build.ts && cd ../../ && oxlint --config framework/eslint-config/.oxlintrc.json",
diff --git a/packages/ui-next/api.ts b/packages/ui-next/api.ts
index f7499b65f..893c061ee 100644
--- a/packages/ui-next/api.ts
+++ b/packages/ui-next/api.ts
@@ -1,15 +1 @@
-// Components
-export { Link, type LinkProps } from './src/components/link';
-
-// Context
-export { type PageData, usePageData } from './src/context/page-data';
-export { type RouterState, useNavigate, useRouterState } from './src/context/router';
-export { useUrl } from './src/hooks/use-url';
-
-// Registry
-export type {
- Interceptor, InterceptorEntry, InterceptorOptions,
- PluginAPI, PluginDefinition,
- SlotName,
-} from './src/registry';
-export { defineSlot } from './src/registry';
+export * from './src/api';
diff --git a/packages/ui-next/index.ts b/packages/ui-next/index.ts
index 97f0bdfac..0e604d241 100644
--- a/packages/ui-next/index.ts
+++ b/packages/ui-next/index.ts
@@ -1,10 +1,29 @@
+import crypto from 'crypto';
import fs from 'fs';
import path from 'path';
import react from '@vitejs/plugin-react';
+import esbuild from 'esbuild';
import c2k from 'koa2-connect/ts';
import { createServer, type Plugin } from 'vite';
import { serializer } from '@hydrooj/framework';
-import type { Context } from 'hydrooj';
+import {
+ Context, Handler, Logger,
+ NotFoundError, param, size, Types,
+} from 'hydrooj';
+
+const logger = new Logger('ui-next');
+
+const PENDING_HTML = `
+
+
+
+ Hydro
+
+
+
+ Hydro UI is building, please wait and refresh...
+
+`;
const INJECT_MARKER = '';
const buildInject = (data: string) => ``;
@@ -38,59 +57,186 @@ function hydroPlugins(): Plugin {
};
}
+const federationPlugin: esbuild.Plugin = {
+ name: 'federation',
+ setup(b) {
+ const mappings: Record = {
+ react: 'React',
+ 'react-dom/client': 'ReactDOM',
+ 'react/jsx-runtime': 'jsxRuntime',
+ };
+
+ b.onResolve({ filter: /^@hydrooj\/ui-next/ }, () => ({
+ path: 'ui-next',
+ namespace: 'hydro-federation',
+ }));
+ for (const mod of Object.keys(mappings)) {
+ b.onResolve({ filter: new RegExp(`^${mod.replaceAll('\\', '\\\\').replaceAll('/', '\\/')}$`) }, () => ({
+ path: mod,
+ namespace: 'hydro-federation',
+ }));
+ }
+ b.onLoad({ filter: /.*/, namespace: 'hydro-federation' }, (args) => {
+ if (args.path === 'ui-next') {
+ return { contents: 'module.exports = window.__hydroExports;', loader: 'js' };
+ }
+ const key = mappings[args.path];
+ return { contents: `module.exports = window.__hydroExports['${key}'];`, loader: 'js' };
+ });
+ },
+};
+
+const vfs: Record = {};
+const hashes: Record = {};
+
+class UiNextConstantHandler extends Handler {
+ noCheckPermView = true;
+
+ @param('name', Types.Filename)
+ async all(domainId: string, name: string) {
+ if (!vfs[name]) throw new NotFoundError(name);
+ this.response.type = 'application/javascript';
+ this.response.body = vfs[name];
+ this.response.addHeader('ETag', hashes[name]);
+ this.response.addHeader('Cache-Control', 'public, max-age=86400');
+ }
+}
+
+export async function buildPlugins() {
+ const start = Date.now();
+ let totalSize = 0;
+ const entries: string[] = [];
+ for (const addon of Object.values(global.addons)) {
+ const uiEntry = path.resolve(addon as string, 'ui', 'index.ts');
+ if (fs.existsSync(uiEntry)) entries.push(uiEntry);
+ }
+ if (!entries.length) {
+ vfs['plugins.js'] = 'window.__hydroPlugins = [];';
+ hashes['plugins.js'] = '00000000';
+ logger.info('No plugins to build');
+ return;
+ }
+
+ try {
+ const result = await esbuild.build({
+ stdin: {
+ contents: [
+ ...entries.map((e, i) => `import * as plugin${i} from '${e}';`),
+ `window.__hydroPlugins = [${entries.map((e, i) => {
+ const addonName = path.basename(path.resolve(e, '..', '..'));
+ return `{ name: '${addonName}', ...plugin${i} }`;
+ }).join(', ')}];`,
+ ].join('\n'),
+ resolveDir: process.cwd(),
+ loader: 'ts',
+ },
+ bundle: true,
+ format: 'iife',
+ write: false,
+ target: ['chrome90'],
+ plugins: [federationPlugin],
+ minify: true,
+ jsx: 'automatic',
+ jsxImportSource: 'react',
+ });
+ if (result.errors.length) logger.error('Plugin build errors: %o', result.errors);
+ const content = result.outputFiles?.[0]?.text || 'window.__hydroPlugins = [];';
+ vfs['plugins.js'] = content;
+ hashes['plugins.js'] = crypto.createHash('sha1').update(content).digest('hex').substring(0, 8);
+ totalSize += content.length;
+ logger.success('Plugins built in %dms (%d entries, %s)', Date.now() - start, entries.length, size(totalSize));
+ } catch (e) {
+ logger.error('Plugin build failed: %o', e);
+ }
+}
+
export async function apply(ctx: Context) {
if (process.env.HYDRO_CLI) return;
- // 现在只是开发环境的实现,生产环境的实现还未完成
- const vite = await createServer({
- clearScreen: false,
- server: {
- middlewareMode: true,
- hmr: {
- port: 3010,
- },
- headers: {
- 'Cross-Origin-Opener-Policy': 'same-origin',
- 'Cross-Origin-Embedder-Policy': 'require-corp',
- },
- // allowedHosts: ['beta.hydro.ac'],
- },
- appType: 'custom',
- root: __dirname,
- base: '/',
- plugins: [react(), hydroPlugins()],
- worker: {
- format: 'es',
- },
- });
- const middleware = c2k(vite.middlewares);
- const capture = ['/@vite/', '/src/', '/node_modules/', '/@react-refresh', '/@fs', '/@id/'];
- for (const route of capture) {
- ctx.server.addCaptureRoute(route, middleware);
- }
- const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf-8');
- ctx.server.registerRenderer('next', {
- name: 'next',
- accept: [],
- output: 'html',
- asFallback: true,
- priority: 100,
- async render(_name, args, context) {
- const serialized = JSON.stringify({
- HYDRO_INJECTED: true,
- name: context.handler.context._matchedRouteName,
- args,
- url: context.handler.context.req.url!,
- 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);
- },
- });
- // eslint-disable-next-line consistent-return
- return async () => {
- await vite.close().catch((e) => console.error(e));
- };
+ if (process.env.DEV) {
+ const vite = await createServer({
+ configFile: false,
+ clearScreen: false,
+ server: {
+ middlewareMode: true,
+ hmr: {
+ port: 3010,
+ },
+ headers: {
+ 'Cross-Origin-Opener-Policy': 'same-origin',
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
+ },
+ },
+ appType: 'custom',
+ root: __dirname,
+ base: '/',
+ plugins: [react(), hydroPlugins()],
+ worker: {
+ format: 'es',
+ },
+ });
+ const middleware = c2k(vite.middlewares);
+ const capture = ['/@vite/', '/src/', '/node_modules/', '/@react-refresh', '/@fs', '/@id/'];
+ for (const route of capture) {
+ ctx.server.addCaptureRoute(route, middleware);
+ }
+ const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf-8');
+ ctx.server.registerRenderer('next', {
+ name: 'next',
+ accept: [],
+ output: 'html',
+ asFallback: true,
+ priority: 100,
+ async render(_name, args, context) {
+ const serialized = JSON.stringify({
+ HYDRO_INJECTED: true,
+ name: context.handler.context._matchedRouteName,
+ args,
+ url: context.handler.context.req.url!,
+ route_map: ctx.server.routeMap,
+ endpoint: ctx.setting.get('server.url') || undefined,
+ }, serializer(false, context.handler));
+ const htmlToRender = html.replace(INJECT_MARKER, buildInject(serialized));
+ return await vite.transformIndexHtml(context.handler.context.req.url!, htmlToRender);
+ },
+ });
+
+ // eslint-disable-next-line consistent-return
+ return async () => {
+ await vite.close().catch((e) => console.error(e));
+ };
+ } else {
+ ctx.Route('ui_next_constants', '/plugins/:version/:name', UiNextConstantHandler);
+ ctx.server.registerRenderer('next', {
+ name: 'next',
+ accept: [],
+ output: 'html',
+ asFallback: true,
+ priority: 100,
+ async render(_name, args, context) {
+ const indexHtml = path.join(__dirname, 'public', 'index.html');
+ if (!fs.existsSync(indexHtml)) return PENDING_HTML;
+ const html = fs.readFileSync(indexHtml, 'utf-8');
+ const serialized = JSON.stringify({
+ HYDRO_INJECTED: true,
+ name: context.handler.context._matchedRouteName,
+ args,
+ url: context.handler.context.req.url!,
+ route_map: ctx.server.routeMap,
+ endpoint: ctx.setting.get('server.url') || undefined,
+ plugins_url: `/plugins/${hashes['plugins.js'] || '00000000'}/plugins.js`,
+ }, serializer(false, context.handler));
+ return html.replace(INJECT_MARKER, buildInject(serialized));
+ },
+ });
+ ctx.on('app/started', buildPlugins);
+ const debouncedBuild = ctx.debounce(buildPlugins, 2000);
+ const triggerHotUpdate = (filePath?: string) => {
+ if (filePath && !filePath.includes('/ui/')) return;
+ debouncedBuild();
+ };
+ ctx.on('app/watch/change', triggerHotUpdate);
+ ctx.on('app/watch/unlink', triggerHotUpdate);
+ ctx.on('system/setting', () => debouncedBuild());
+ }
}
diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json
index a485c277d..9049d33bf 100644
--- a/packages/ui-next/package.json
+++ b/packages/ui-next/package.json
@@ -1,15 +1,31 @@
{
"name": "@hydrooj/ui-next",
"version": "0.0.0",
+ "author": "Baoshuo ",
+ "license": "AGPL-3.0",
"type": "module",
"main": "index.ts",
"types": "api.ts",
+ "preferUnplugged": true,
+ "hydro": {
+ "cli": false
+ },
+ "files": [
+ "index.ts",
+ "api.ts",
+ "src/",
+ "index.html",
+ "public/",
+ "tsconfig.json",
+ "vite.config.ts"
+ ],
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview"
},
"dependencies": {
+ "esbuild": "0.25.2",
"is-relative-url": "^4.1.0",
"path-to-regexp": "^8.4.2",
"react": "^19.2.5",
diff --git a/packages/ui-next/src/api.ts b/packages/ui-next/src/api.ts
new file mode 100644
index 000000000..3c6799e8a
--- /dev/null
+++ b/packages/ui-next/src/api.ts
@@ -0,0 +1,20 @@
+// Components
+export { Link, type LinkProps } from './components/link';
+
+// Context
+export { type PageData, usePageData } from './context/page-data';
+export { type RouterState, useNavigate, useRouterState } from './context/router';
+export { useUrl } from './hooks/use-url';
+
+// Registry
+export type {
+ Interceptor, InterceptorEntry, InterceptorOptions,
+ PluginAPI, PluginDefinition,
+ SlotName,
+} from './registry';
+export { defineSlot } from './registry';
+
+// Shared dependencies
+export { default as React } from 'react';
+export { default as ReactDOM } from 'react-dom/client';
+export { default as jsxRuntime } from 'react/jsx-runtime';
diff --git a/packages/ui-next/src/globals.ts b/packages/ui-next/src/globals.ts
index a986e1433..8a45b3ab5 100644
--- a/packages/ui-next/src/globals.ts
+++ b/packages/ui-next/src/globals.ts
@@ -14,6 +14,7 @@ if (injectionEl) {
export const isInjected: boolean = !!injectionData.HYDRO_INJECTED;
export const hydroDomains: string[] = injectionData.hydro_domains ?? [];
+export const pluginsUrl: string | undefined = injectionData.plugins_url;
// routeMap as an external store for useSyncExternalStore, with HMR state preservation
interface RouteMapStore {
diff --git a/packages/ui-next/src/main.tsx b/packages/ui-next/src/main.tsx
index 540e19322..96e576748 100644
--- a/packages/ui-next/src/main.tsx
+++ b/packages/ui-next/src/main.tsx
@@ -1,16 +1,44 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
-import plugins from 'virtual:hydro-plugins';
+import * as api from './api';
import App from './app';
import { PageDataProvider } from './context/page-data';
import { RouterProvider } from './context/router';
-import { initialPage } from './globals';
+import { initialPage, pluginsUrl } from './globals';
import { installPlugin } from './registry';
-for (const plugin of plugins) {
- installPlugin(plugin);
+declare global {
+ interface Window {
+ __hydroExports: typeof api;
+ __hydroPlugins?: api.PluginDefinition[];
+ }
}
+window.__hydroExports = api;
+
+async function loadPlugins() {
+ let plugins: api.PluginDefinition[] = [];
+ if (import.meta.env.DEV) {
+ const mod = await import('virtual:hydro-plugins');
+ plugins = mod.default || [];
+ } else {
+ try {
+ await import(/* @vite-ignore */ pluginsUrl || '/plugins.js');
+ plugins = window.__hydroPlugins || [];
+ } catch (e) {
+ console.warn('[Hydro] Failed to load plugins:', e);
+ }
+ }
+
+ for (const plugin of plugins) {
+ console.log(`[Hydro] Installing plugin: ${plugin.name}`);
+ installPlugin(plugin);
+ }
+}
+
+// eslint-disable-next-line antfu/no-top-level-await
+await loadPlugins();
+
createRoot(document.getElementById('root')!).render(
diff --git a/packages/ui-next/vite.config.ts b/packages/ui-next/vite.config.ts
new file mode 100644
index 000000000..769d4e522
--- /dev/null
+++ b/packages/ui-next/vite.config.ts
@@ -0,0 +1,18 @@
+import react from '@vitejs/plugin-react';
+import { defineConfig } from 'vite';
+
+export default defineConfig({
+ base: '/',
+ plugins: [react()],
+ publicDir: 'pub',
+ build: {
+ outDir: 'public',
+ emptyOutDir: true,
+ rolldownOptions: {
+ output: {
+ codeSplitting: true,
+ },
+ },
+ },
+ worker: { format: 'es' },
+});