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());
}
}