import fs from 'fs'; import path from 'path'; import esbuild from 'esbuild'; import c2k from 'koa2-connect/ts'; import { createServer, type Plugin } from 'vite'; import { serializer } from '@hydrooj/framework'; import { Context, Handler, Logger, NotFoundError, param, sha1, 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) => ``; function getAddonEntries(): Record { const entries: Record = {}; for (const [name, addon] of Object.entries(global.addons)) { const uiEntry = ['ui/index.ts', 'ui/index.tsx', 'ui/index.js', 'ui/index.jsx'] .map((f) => path.resolve(addon as string, f)) .find((f) => fs.existsSync(f)); if (uiEntry) { logger.info('UI entry for addon %s: %s', name, uiEntry); entries[name] = uiEntry; } } return entries; } function hydroPlugins(): Plugin { const virtualModuleId = 'virtual:hydro-plugins'; const resolvedVirtualModuleId = `\0${virtualModuleId}`; return { name: 'hydro-plugins', resolveId(id) { if (id === virtualModuleId) { return resolvedVirtualModuleId; } return undefined; }, load(id) { if (id === resolvedVirtualModuleId) { const entries = getAddonEntries(); if (!Object.keys(entries).length) return 'export default [];'; const imports = Object.entries(entries).map(([_, e], i) => `import * as plugin${i} from '${e}';`).join('\n'); const exports = `export default [${Object.entries(entries).map(([addon, _], i) => { return `{ name: '${addon}', ...plugin${i} }`; }).join(', ')}];`; return `${imports}\n${exports}`; } return undefined; }, }; } 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 = {}; const applyCss = (css: string) => ` (() => { const style = document.createElement('style'); style.textContent = ${JSON.stringify(css)}; document.head.appendChild(style); })(); `; function addFile(name: string, content: string) { vfs[name] = content; hashes[name] = sha1(content).substring(0, 8); } class UiNextConstantHandler extends Handler { noCheckPermView = true; @param('name', Types.Filename) async all(domainId: string, name: string) { if (!(name in vfs)) 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 = getAddonEntries(); const newPluginFiles = new Set(); const emit = (name: string, content: string) => { addFile(name, content); newPluginFiles.add(name); }; const purge = () => { for (const key of Object.keys(vfs)) { if (!newPluginFiles.has(key)) { delete vfs[key]; delete hashes[key]; } } }; if (!Object.keys(entries).length) { emit('plugins.js', 'window.__hydroPlugins = [];'); purge(); logger.info('No plugins to build'); return; } try { 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', 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); const cssText = new Map(); for (const f of result.outputFiles) { if (f.path.endsWith('.css')) cssText.set(f.path, f.text); } const cssForJs = new Map(); const claimed = new Set(); for (const [rel, meta] of Object.entries(result.metafile.outputs)) { if (!meta.cssBundle) continue; const css = path.resolve(meta.cssBundle); cssForJs.set(path.resolve(rel), css); claimed.add(css); } let unclaimedCss = ''; for (const [abs, text] of cssText) { if (!claimed.has(abs)) unclaimedCss += text; } for (const f of result.outputFiles) { if (f.path.endsWith('.css')) continue; const name = path.basename(f.path); let content = f.text; const css = cssText.get(cssForJs.get(f.path) ?? ''); if (css) content = applyCss(css) + content; if (name === 'plugins.js' && unclaimedCss) content = applyCss(unclaimedCss) + content; totalSize += content.length; emit(name, content); } purge(); logger.success('Plugins built in %dms (%d entries, %s)', Date.now() - start, Object.keys(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; if (process.env.DEV) { const vite = await createServer({ root: __dirname, clearScreen: false, server: { middlewareMode: true, hmr: { port: 3010, }, headers: { 'Cross-Origin-Opener-Policy': 'same-origin', 'Cross-Origin-Embedder-Policy': 'require-corp', }, }, appType: 'custom', plugins: [hydroPlugins()], }); 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, template: context.handler.response.template || '', args: { UserContext: context.UserContext, UiContext: context.handler.UiContext, ...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, template: context.handler.response.template || '', args: { UserContext: context.UserContext, UiContext: context.handler.UiContext, ...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()); } }