Files
Hydro/packages/ui-default/backendlib/builder.ts
T
2025-09-16 11:29:39 +08:00

180 lines
5.9 KiB
TypeScript

import { tmpdir } from 'os';
import {
basename, join, relative, resolve,
} from 'path';
import {
Context, fs, Handler, Logger, NotFoundError, param, SettingModel, sha1,
size, SystemModel, Types, UiContextBase,
} from 'hydrooj';
import esbuild from 'esbuild';
declare module 'hydrooj' {
interface UI {
esbuildPlugins?: esbuild.Plugin[];
}
interface SystemKeys {
'ui-default.nav_logo_dark': string;
}
interface UiContextBase {
constantVersion?: string;
}
}
const vfs: Record<string, string> = {};
const hashes: Record<string, string> = {};
const logger = new Logger('ui');
const tmp = tmpdir();
const federationPlugin: esbuild.Plugin = {
name: 'federation',
setup(b) {
const packages = {
react: 'React',
'react-dom': 'ReactDOM',
jquery: '$',
};
b.onResolve({ filter: /^@hydrooj\/ui-default/ }, () => ({
path: 'api',
namespace: 'ui-default',
}));
for (const key in packages) {
b.onResolve({ filter: new RegExp(`^${key}($|\\/)`) }, () => ({
path: packages[key],
namespace: 'ui-default',
}));
}
b.onLoad({ filter: /.*/, namespace: 'ui-default' }, (args) => {
if (args.path === 'api') {
return {
contents: 'module.exports = window.HydroExports;',
loader: 'tsx',
};
}
return {
contents: `module.exports = window.HydroExports['${args.path}'];`,
loader: 'tsx',
};
});
},
};
const build = async (contents: string) => {
const res = await esbuild.build({
tsconfigRaw: '{"compilerOptions":{"experimentalDecorators":true}}',
format: 'iife' as 'iife',
bundle: true,
outdir: tmp,
sourcemap: SystemModel.get('ui-default.nosourcemap') ? false : 'external',
splitting: false,
write: false,
target: ['chrome65'],
plugins: [
...(global.Hydro.ui.esbuildPlugins || []),
federationPlugin,
],
minify: !process.env.DEV,
stdin: {
contents,
sourcefile: 'stdin.ts',
resolveDir: tmp,
loader: 'ts',
},
});
if (res.errors.length) console.error(res.errors);
if (res.warnings.length) console.warn(res.warnings);
return res;
};
const applyCss = (css: string) => `
const style = document.createElement('style');
style.textContent = ${JSON.stringify(css)};
document.head.appendChild(style);
`;
export async function buildUI() {
const start = Date.now();
let totalSize = 0;
const entryPoints: string[] = [];
const lazyModules: string[] = [];
const newFiles = ['entry.js'];
for (const addon of Object.values(global.addons) as string[]) {
let publicPath = resolve(addon, 'frontend');
if (!fs.existsSync(publicPath)) publicPath = resolve(addon, 'public');
if (!fs.existsSync(publicPath)) continue;
const targets = fs.readdirSync(publicPath);
for (const target of targets) {
if (/\.page\.[jt]sx?$/.test(target)) entryPoints.push(join(publicPath, target));
if (/\.lazy\.[jt]sx?$/.test(target)) lazyModules.push(join(publicPath, target));
}
}
function addFile(name: string, content: string) {
vfs[name] = content;
hashes[name] = sha1(content).substring(0, 8);
logger.info('+ %s-%s: %s', name, hashes[name].substring(0, 6), size(content.length));
newFiles.push(name);
totalSize += content.length;
}
for (const m of lazyModules) {
const name = basename(m).split('.')[0];
const { outputFiles } = await build(`window.lazyModuleResolver['${name}'](require('${relative(tmp, m).replace(/\\/g, '\\\\')}'))`);
const css = outputFiles.filter((i) => i.path.endsWith('.css')).map((i) => i.text).join('\n');
for (const file of outputFiles) {
if (file.path.endsWith('.css')) continue;
addFile(basename(m).replace(/\.[tj]sx?$/, '.js'), (css ? applyCss(css) : '') + file.text);
}
}
for (const lang in global.Hydro.locales) {
if (!/^[a-zA-Z_]+$/.test(lang)) continue;
if (!global.Hydro.locales[lang].__interface) continue;
const str = `window.LOCALES=${JSON.stringify(global.Hydro.locales[lang][Symbol.for('iterate')])};`;
addFile(`lang-${lang}.js`, str);
}
const entry = await build([
`window.lazyloadMetadata = ${JSON.stringify(hashes)};`,
`window.LANGS=${JSON.stringify(SettingModel.langs)};`,
...entryPoints.map((i) => `import '${relative(tmp, i).replace(/\\/g, '\\\\')}';`),
].join('\n'));
const pages = entry.outputFiles.filter((i) => i.path.endsWith('.js')).map((i) => i.text);
const css = entry.outputFiles.filter((i) => i.path.endsWith('.css')).map((i) => i.text);
addFile('entry.js', `window._hydroLoad=()=>{
${css.length ? applyCss(css.join('\n')) : ''}
${pages.join('\n')}
};`);
UiContextBase.constantVersion = hashes['entry.js'];
for (const key in vfs) {
if (newFiles.includes(key)) continue;
delete vfs[key];
delete hashes[key];
}
logger.success('UI addons built in %d ms (%s)', Date.now() - start, size(totalSize));
}
class UiConstantsHandler extends Handler {
noCheckPermView = true;
@param('name', Types.Filename)
async all(domainId: string, name: string) {
this.response.type = 'application/javascript';
if (!vfs[name]) throw new NotFoundError(name);
this.response.addHeader('ETag', hashes[name]);
this.response.body = vfs[name];
this.response.addHeader('Cache-Control', 'public, max-age=86400');
}
}
export async function apply(ctx: Context) {
ctx.Route('constant', '/lazy/:version/:name', UiConstantsHandler);
ctx.Route('constant', '/resource/:version/:name', UiConstantsHandler);
ctx.on('app/started', buildUI);
const debouncedBuildUI = ctx.debounce(buildUI, 2000);
const triggerHotUpdate = (path?: string) => {
if (path && !path.includes('/ui-default/') && !path.includes('/public/') && !path.includes('/frontend/')) return;
debouncedBuildUI();
};
ctx.on('system/setting', () => triggerHotUpdate());
ctx.on('app/watch/change', triggerHotUpdate);
ctx.on('app/watch/unlink', triggerHotUpdate);
ctx.on('app/i18n/update', debouncedBuildUI);
debouncedBuildUI();
}