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' }, +});