From b2d323615bd60b4566514e852a533490f5538cac Mon Sep 17 00:00:00 2001 From: Baoshuo Date: Fri, 4 Sep 2026 16:52:06 +0800 Subject: [PATCH 1/3] ui-next: progressive migration --- framework/framework/base.ts | 3 +- framework/framework/server.ts | 20 +- packages/hydrooj/src/service/layers/base.ts | 4 + packages/ui-default/backendlib/template.ts | 8 +- packages/ui-next/index.ts | 192 ++++++++++++++++---- packages/ui-next/package.json | 5 +- packages/ui-next/src/app.tsx | 21 +-- packages/ui-next/src/context/router.tsx | 8 + packages/ui-next/src/hooks/use-build-url.ts | 7 +- packages/ui-next/src/pages/problem_main.tsx | 1 + packages/ui-next/src/registry/page.tsx | 16 ++ 11 files changed, 216 insertions(+), 69 deletions(-) diff --git a/framework/framework/base.ts b/framework/framework/base.ts index a70f62574c..7764d1f709 100644 --- a/framework/framework/base.ts +++ b/framework/framework/base.ts @@ -98,7 +98,7 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => { } response.type = 'application/json'; } else if (response.template) { - response.body = await handler.renderHTML(response.template, response.body || {}); + response.body = await handler.renderHTML(response.template, response.body || {}, { kind: 'page' }); response.type = 'text/html'; } } @@ -116,6 +116,7 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => { response.body = await ctx.handler.renderHTML( error instanceof UserFacingError ? 'error.html' : 'bsod.html', { UserFacingError, error }, + { kind: 'page' }, ); response.type = 'text/html'; } catch (e) { diff --git a/framework/framework/server.ts b/framework/framework/server.ts index f168e88a24..58ceb4a800 100644 --- a/framework/framework/server.ts +++ b/framework/framework/server.ts @@ -106,11 +106,12 @@ export type KoaContext = Koa.Context & { holdFiles: (string | File)[]; }; -interface RendererContext { +export interface RendererContext { handler: HandlerCommon; UserContext: UserModel; url: HandlerCommon['url']; _: HandlerCommon['translate']; + kind: 'page' | 'fragment'; } export interface TextRenderer { output: 'html' | 'json' | 'text'; @@ -123,6 +124,7 @@ export interface BinaryRenderer { export type Renderer = (BinaryRenderer | TextRenderer) & { name: string; accept: readonly string[]; + supports?: (name: string, args: Record, context: RendererContext) => boolean; priority: number; asFallback: boolean; }; @@ -205,17 +207,19 @@ export class HandlerCommon { return str; } - renderHTML(templateName: string, args: Record) { - const renderers = Object.values((this.ctx as any).server.renderers as Record) - .filter((r) => r.accept.includes(templateName) || r.asFallback); - const topPrio = renderers.sort((a, b) => b.priority - a.priority)[0]; - const engine = topPrio?.render || (() => JSON.stringify(args, serializer(false, this))); - return engine(templateName, args, { + renderHTML(templateName: string, args: Record, options: { kind?: RendererContext['kind'] } = {}) { + const context: RendererContext = { handler: this, UserContext: this.user, url: this.url, _: this.translate, - }); + kind: options.kind ?? 'fragment', + }; + const renderers = Object.values((this.ctx as any).server.renderers as Record) + .filter((r) => r.accept.includes(templateName) || r.supports?.(templateName, args, context) || r.asFallback); + const topPrio = renderers.sort((a, b) => b.priority - a.priority)[0]; + const engine = topPrio?.render || (() => JSON.stringify(args, serializer(false, this))); + return engine(templateName, args, context); } } diff --git a/packages/hydrooj/src/service/layers/base.ts b/packages/hydrooj/src/service/layers/base.ts index a8edce4ead..a71a0c1697 100644 --- a/packages/hydrooj/src/service/layers/base.ts +++ b/packages/hydrooj/src/service/layers/base.ts @@ -12,6 +12,8 @@ export interface UiContextBase { cdn_dynamic: boolean; url_prefix: string; ws_prefix: string; + domainId?: string; + domainHost?: string[]; } export const UiContextBase: UiContextBase = { cdn_prefix: '/', @@ -34,7 +36,9 @@ export default async (ctx: KoaContext, next: Next) => { if (UiContext.ws_prefix.includes(',')) UiContext.ws_prefix = randomPick(UiContext.ws_prefix.split(',')); UiContext.cdn_dynamic = system.get('server.cdn_dynamic'); } + const domainHost = Array.isArray(domainInfo.host) ? domainInfo.host : [domainInfo.host]; UiContext.domainId = domainId; + UiContext.domainHost = domainHost.filter(Boolean); UiContext.domainVersion = domain.getVersion(domainInfo); ctx.HydroContext.UiContext = UiContext; ctx.HydroContext.domain = domainInfo; diff --git a/packages/ui-default/backendlib/template.ts b/packages/ui-default/backendlib/template.ts index cceddf5a47..7e9cf9a6b7 100644 --- a/packages/ui-default/backendlib/template.ts +++ b/packages/ui-default/backendlib/template.ts @@ -175,11 +175,15 @@ export class TemplateService extends Service { h.translate = h.translate.bind(h); h.url = h.url.bind(h); h.ctx = h.ctx.extend({ domain: h.domain }); - h.renderHTML = ((orig) => function (name: string, args: Record) { + h.renderHTML = ((orig) => function ( + name: string, + args: Record, + options?: { kind?: 'page' | 'fragment' }, + ) { const s = name.split('.'); let templateName = `${s[0]}.${h.domain._id}.${s[1]}`; if (!that.registry[templateName]) templateName = name; - return orig(templateName, args); + return orig(templateName, args, options); })(h.renderHTML).bind(h); }); diff --git a/packages/ui-next/index.ts b/packages/ui-next/index.ts index 40bfc72799..7e619abe9f 100644 --- a/packages/ui-next/index.ts +++ b/packages/ui-next/index.ts @@ -2,6 +2,7 @@ import fs from 'fs'; import path from 'path'; import esbuild from 'esbuild'; import c2k from 'koa2-connect/ts'; +import ts from 'typescript'; import { createServer, type Plugin } from 'vite'; import { HandlerCommon, serializer } from '@hydrooj/framework'; import { @@ -26,6 +27,75 @@ const PENDING_HTML = ` const INJECT_MARKER = ''; const buildInject = (data: string) => ``; +const PAGE_SOURCE_FILTER = /\.[cm]?[jt]sx?$/; + +function getScriptKind(filename: string) { + if (/\.tsx$/i.test(filename)) return ts.ScriptKind.TSX; + if (/\.jsx$/i.test(filename)) return ts.ScriptKind.JSX; + if (/\.[cm]?ts$/i.test(filename)) return ts.ScriptKind.TS; + return ts.ScriptKind.JS; +} + +function isRegisterPageCall(expression: ts.LeftHandSideExpression) { + if (ts.isIdentifier(expression)) return expression.text === 'registerPage'; + return ts.isPropertyAccessExpression(expression) && expression.name.text === 'registerPage'; +} + +function collectPageRegistrations(filename: string, source: string, pages: Set) { + const sourceFile = ts.createSourceFile(filename, source, ts.ScriptTarget.Latest, true, getScriptKind(filename)); + + const visit = (node: ts.Node) => { + if (ts.isCallExpression(node) && isRegisterPageCall(node.expression)) { + const name = node.arguments[0]; + if (!name || !ts.isStringLiteralLike(name)) { + const pos = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)); + throw new Error(`${filename}:${pos.line + 1}:${pos.character + 1}: registerPage() requires a static string literal`); + } + pages.add(name.text); + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); +} + +function collectBuiltinPages() { + const pages = new Set(); + const filename = ['index.ts', 'index.tsx', 'index.js', 'index.jsx'] + .map((name) => path.join(__dirname, 'src', 'pages', name)) + .find((name) => fs.existsSync(name)); + if (!filename) throw new Error('Cannot find the built-in ui-next page registry'); + collectPageRegistrations(filename, fs.readFileSync(filename, 'utf-8'), pages); + return pages; +} + +function isInside(file: string, root: string) { + const relative = path.relative(root, file); + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +function pageManifestPlugin(entries: Record, pages: Set): esbuild.Plugin { + const addonRoots = Object.values(entries).map((entry) => path.dirname(path.dirname(entry))); + return { + name: 'page-manifest', + setup(b) { + b.onLoad({ filter: PAGE_SOURCE_FILTER, namespace: 'file' }, async (args) => { + if (!addonRoots.some((root) => isInside(args.path, root))) return undefined; + const source = await fs.promises.readFile(args.path, 'utf-8'); + collectPageRegistrations(args.path, source, pages); + return undefined; + }); + }, + }; +} + +let registeredPages = new Set(); + +function supportsUiNextPage(routeName: string, templateName: string, args: Record) { + if (args.error) return registeredPages.has('error'); + const templateStem = templateName.replace(/\.html$/, ''); + return registeredPages.has(templateStem) || registeredPages.has(routeName); +} + function getAddonEntries(): Record { const entries: Record = {}; for (const [name, addon] of Object.entries(global.addons)) { @@ -40,7 +110,7 @@ function getAddonEntries(): Record { return entries; } -function hydroPlugins(): Plugin { +function hydroPlugins(onHotUpdate?: (file: string) => void): Plugin { const virtualModuleId = 'virtual:hydro-plugins'; const resolvedVirtualModuleId = `\0${virtualModuleId}`; @@ -64,6 +134,9 @@ function hydroPlugins(): Plugin { } return undefined; }, + hotUpdate(options) { + onHotUpdate?.(options.file); + }, }; } @@ -169,6 +242,45 @@ class UiNextConstantHandler extends Handler { } } +function getPluginBuildOptions(entries: Record, pages: Set): esbuild.BuildOptions { + return { + stdin: { + contents: [ + ...Object.entries(entries).map(([_, e], i) => `import * as plugin${i} from '${e}';`), + `window.__hydroPlugins = [${Object.entries(entries).map(([n], i) => `{ name: '${n}', ...plugin${i} }`).join(', ')}];`, + ].join('\n'), + sourcefile: 'plugins.ts', + resolveDir: process.cwd(), + loader: 'ts', + }, + bundle: true, + format: 'esm', + write: false, + target: ['chrome90'], + plugins: [pageManifestPlugin(entries, pages), federationPlugin], + jsx: 'automatic', + jsxImportSource: 'react', + }; +} + +async function scanPluginPages() { + const entries = getAddonEntries(); + const nextRegisteredPages = collectBuiltinPages(); + try { + if (Object.keys(entries).length) { + await esbuild.build({ + ...getPluginBuildOptions(entries, nextRegisteredPages), + outfile: 'plugins-scan.js', + logLevel: 'silent', + }); + } + registeredPages = nextRegisteredPages; + logger.info('Page manifest updated (%d ui-next pages)', registeredPages.size); + } catch (e) { + logger.error('Page manifest scan failed, keeping the previous manifest: %o', e); + } +} + export async function buildPlugins() { const start = Date.now(); let totalSize = 0; @@ -188,38 +300,25 @@ export async function buildPlugins() { } }; - if (!Object.keys(entries).length) { - emit('plugins.js', 'window.__hydroPlugins = [];'); - purge(); - logger.info('No plugins to build'); - return; - } - try { + const nextRegisteredPages = collectBuiltinPages(); + if (!Object.keys(entries).length) { + emit('plugins.js', 'window.__hydroPlugins = [];'); + purge(); + registeredPages = nextRegisteredPages; + logger.info('No plugins to build (%d ui-next pages)', registeredPages.size); + return; + } + const result = await esbuild.build({ - stdin: { - contents: [ - ...Object.entries(entries).map(([_, e], i) => `import * as plugin${i} from '${e}';`), - `window.__hydroPlugins = [${Object.entries(entries).map(([n], i) => `{ name: '${n}', ...plugin${i} }`).join(', ')}];`, - ].join('\n'), - sourcefile: 'plugins.ts', - resolveDir: process.cwd(), - loader: 'ts', - }, - bundle: true, - format: 'esm', + ...getPluginBuildOptions(entries, nextRegisteredPages), splitting: true, outdir: 'plugins-dist', entryNames: 'plugins', chunkNames: 'chunk-[hash]', assetNames: 'asset-[hash]', metafile: true, - write: false, - target: ['chrome90'], - plugins: [federationPlugin], minify: true, - jsx: 'automatic', - jsxImportSource: 'react', }); if (result.errors.length) logger.error('Plugin build errors: %o', result.errors); @@ -257,7 +356,14 @@ export async function buildPlugins() { } purge(); - logger.success('Plugins built in %dms (%d entries, %s)', Date.now() - start, Object.keys(entries).length, size(totalSize)); + registeredPages = nextRegisteredPages; + logger.success( + 'Plugins built in %dms (%d entries, %d ui-next pages, %s)', + Date.now() - start, + Object.keys(entries).length, + registeredPages.size, + size(totalSize), + ); } catch (e) { logger.error('Plugin build failed: %o', e); } @@ -277,14 +383,25 @@ const injectedScripts = (resolve: (name: string) => string, viewLang: string) => export async function apply(ctx: Context) { if (process.env.HYDRO_CLI) return; + registeredPages = collectBuiltinPages(); ctx.Route('ui_next_constants', '/plugins/:version/:name', UiNextConstantHandler); if (process.env.DEV) { - ctx.on('app/started', async () => { + const buildDev = async () => { + await scanPluginPages(); await buildI18n(); await buildCodeLangs(); await buildVersions(); - }); + }; + const debouncedPageScan = ctx.debounce(scanPluginPages, 200); + const triggerPageScan = (filePath?: string) => { + if (filePath && ((!filePath.includes('/ui/') && !filePath.includes('/ui-next/')) || !PAGE_SOURCE_FILTER.test(filePath))) return; + debouncedPageScan(); + }; + + ctx.on('app/started', buildDev); + ctx.on('app/watch/change', triggerPageScan); + ctx.on('app/watch/unlink', triggerPageScan); ctx.on('app/i18n/update', buildI18n); ctx.on('system/setting-loaded', buildCodeLangs); ctx.on('system/setting', buildCodeLangs); @@ -303,7 +420,7 @@ export async function apply(ctx: Context) { }, }, appType: 'custom', - plugins: [hydroPlugins()], + plugins: [hydroPlugins(triggerPageScan)], }); const middleware = c2k(vite.middlewares); const capture = ['/@vite/', '/src/', '/node_modules/', '/@react-refresh', '/@fs', '/@id/']; @@ -314,8 +431,10 @@ export async function apply(ctx: Context) { ctx.server.registerRenderer('next', { name: 'next', accept: [], + supports: (name, args, context) => context.kind === 'page' + && supportsUiNextPage(context.handler.context._matchedRouteName, name, args), output: 'html', - asFallback: true, + asFallback: false, priority: 100, async render(_name, args, context) { const serialized = JSON.stringify({ @@ -353,13 +472,21 @@ export async function apply(ctx: Context) { await buildCodeLangs(); await buildVersions(); }; + const debouncedBuild = ctx.debounce(build, 2000); + const triggerHotUpdate = (filePath?: string) => { + if (filePath && !filePath.includes('/ui/') && !filePath.includes('/ui-next/')) return; + debouncedBuild(); + }; + ctx.on('app/started', build); ctx.server.registerRenderer('next', { name: 'next', accept: [], + supports: (name, args, context) => context.kind === 'page' + && supportsUiNextPage(context.handler.context._matchedRouteName, name, args), output: 'html', - asFallback: true, + asFallback: false, priority: 100, async render(_name, args, context) { const indexHtml = path.join(__dirname, 'public', 'index.html'); @@ -387,11 +514,6 @@ export async function apply(ctx: Context) { return html.replace(INJECT_MARKER, injectHtml); }, }); - const debouncedBuild = ctx.debounce(build, 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-loaded', buildCodeLangs); diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json index 9f3c5261cc..0ff2feef92 100644 --- a/packages/ui-next/package.json +++ b/packages/ui-next/package.json @@ -29,9 +29,10 @@ "esbuild": "0.25.2", "is-relative-url": "^4.1.0", "path-to-regexp": "^8.4.2", - "simple-git": "^3.36.0", "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "simple-git": "^3.36.0", + "typescript": "5.8.3" }, "devDependencies": { "@types/react": "^19.2.17", diff --git a/packages/ui-next/src/app.tsx b/packages/ui-next/src/app.tsx index 5fdb3f7dcf..568861eed6 100644 --- a/packages/ui-next/src/app.tsx +++ b/packages/ui-next/src/app.tsx @@ -3,6 +3,7 @@ import DefaultLayout from './components/layout'; import { usePageData } from './context/page-data'; import { defineSlot } from './registry'; import { SlotErrorBoundary } from './registry/error-boundary'; +import { resolvePage } from './registry/page'; import { store } from './registry/store'; const App = defineSlot('app:root', () => { @@ -11,25 +12,11 @@ const App = defineSlot('app:root', () => { const isError = !!(args as Record).error; const [slotName, entry] = useMemo(() => { - if (isError) { - return ['page:error', store.getDefault('page:error')] as const; - } - const templateName = typeof template === 'string' ? template.replace(/\.html$/, '') : null; - if (templateName) { - const templateSlot = `page:${templateName}` as `page:${string}`; - const templateEntry = store.getDefault(templateSlot); - if (templateEntry) { - if (import.meta.env.DEV) { - console.log(`[ui-next] using template "${templateName}" for page "${name}"`); - } - return [templateSlot, templateEntry] as const; - } - } - const slot = `page:${name}` as `page:${string}`; + const resolved = resolvePage(name, template, isError); if (import.meta.env.DEV) { - console.log(`[ui-next] using page slot "${slot}"`); + console.log(`[ui-next] using page slot "${resolved[0]}"`); } - return [slot, store.getDefault(slot)] as const; + return resolved; }, [name, template, isError]); const [subscribe, getSnapshot] = useMemo(() => [ diff --git a/packages/ui-next/src/context/router.tsx b/packages/ui-next/src/context/router.tsx index f8f50aa71a..f03068dd9e 100644 --- a/packages/ui-next/src/context/router.tsx +++ b/packages/ui-next/src/context/router.tsx @@ -2,6 +2,7 @@ import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; import { endpointOrigins, endpoints, isInjected, routeMapStore } from '../globals'; +import { resolvePage } from '../registry/page'; import { useSetPageData } from './page-data'; interface InternalState { @@ -89,6 +90,13 @@ export const RouterProvider: React.FC = ({ children }) if (gen !== genRef.current) return false; + const isError = !!(body && typeof body === 'object' && body.error); + const [, page] = resolvePage(pageName, template, isError); + if (!page) { + window.location.assign(url); + return false; + } + if (init && body.routeMap && typeof body.routeMap === 'object') { routeMapStore.set(body.routeMap); } diff --git a/packages/ui-next/src/hooks/use-build-url.ts b/packages/ui-next/src/hooks/use-build-url.ts index 5b0ae15aad..fb502915d5 100644 --- a/packages/ui-next/src/hooks/use-build-url.ts +++ b/packages/ui-next/src/hooks/use-build-url.ts @@ -9,14 +9,13 @@ export interface UrlParams { export function useBuildUrl() { const routeMap = useRouteMap(); - const { domainId, domain } = useUiContext(); + const { domainId, domainHost = [] } = useUiContext(); const getPrefix = useCallback((id?: string) => { id ||= domainId; - const domainHost = Array.isArray(domain.host) ? domain.host : [domain.host]; const currentHost = window.location.host; - return id === (domainHost && domainHost.includes(currentHost) ? domainId : 'system') ? '' : `/d/${id}`; - }, [domainId, domain]); + return id === (domainHost.includes(currentHost) ? domainId : 'system') ? '' : `/d/${id}`; + }, [domainId, domainHost]); return useCallback((name: string, params: UrlParams = {}, searchParams: Record = {}): string => { const pattern = routeMap[name]; diff --git a/packages/ui-next/src/pages/problem_main.tsx b/packages/ui-next/src/pages/problem_main.tsx index 29a2c30fd3..4cd7616f18 100644 --- a/packages/ui-next/src/pages/problem_main.tsx +++ b/packages/ui-next/src/pages/problem_main.tsx @@ -5,6 +5,7 @@ export default function ProblemMain() {
problem_main
homepage + p2
); } diff --git a/packages/ui-next/src/registry/page.tsx b/packages/ui-next/src/registry/page.tsx index a67767ed0e..c4add10815 100644 --- a/packages/ui-next/src/registry/page.tsx +++ b/packages/ui-next/src/registry/page.tsx @@ -2,6 +2,22 @@ import { lazy } from 'react'; import { store } from './store'; import type { PageEntry, PageLoader, PageSlotName, RegisterPageOptions } from './types'; +export function resolvePage(name: string, template: string, isError = false): readonly [PageSlotName, PageEntry | undefined] { + if (isError) { + return ['page:error', store.getDefault('page:error')]; + } + + const templateName = template.replace(/\.html$/, ''); + if (templateName) { + const templateSlot = `page:${templateName}` as PageSlotName; + const templateEntry = store.getDefault(templateSlot); + if (templateEntry) return [templateSlot, templateEntry]; + } + + const routeSlot = `page:${name}` as PageSlotName; + return [routeSlot, store.getDefault(routeSlot)]; +} + export function registerPage

( name: string, loader: PageLoader

, From 68bd92c575fe36a3c0a1a36d8ba28acfb05ac6a1 Mon Sep 17 00:00:00 2001 From: Baoshuo Date: Sun, 13 Sep 2026 13:53:08 +0800 Subject: [PATCH 2/3] ui-next: progressive migration --- examples/plugins/ui-next-plugin/index.ts | 13 +- examples/plugins/ui-next-plugin/ui/index.tsx | 2 +- framework/framework/base.ts | 11 +- framework/framework/server.ts | 25 +- packages/ui-default/backendlib/template.ts | 8 +- packages/ui-next/index.ts | 258 ++++++------------- packages/ui-next/package.json | 5 +- packages/ui-next/src/app.tsx | 13 +- packages/ui-next/src/context/page-data.tsx | 1 - packages/ui-next/src/context/router.tsx | 11 +- packages/ui-next/src/globals.ts | 1 - packages/ui-next/src/pages/error.tsx | 6 + packages/ui-next/src/pages/index.ts | 1 + packages/ui-next/src/registry/page.tsx | 9 +- 14 files changed, 120 insertions(+), 244 deletions(-) create mode 100644 packages/ui-next/src/pages/error.tsx diff --git a/examples/plugins/ui-next-plugin/index.ts b/examples/plugins/ui-next-plugin/index.ts index 10384ad314..c40ad58f11 100644 --- a/examples/plugins/ui-next-plugin/index.ts +++ b/examples/plugins/ui-next-plugin/index.ts @@ -1,23 +1,18 @@ import { Context, Handler } from 'hydrooj'; -// Server side of the example plugin. It registers a couple of routes whose -// handlers set a `template`; with @hydrooj/ui-next installed the `next` renderer -// (priority 100) wins for every template, ignores the template file, and injects -// the route/template name into the SPA shell. The client then renders the page -// registered under `page: