|
|
|
import esbuild from 'esbuild';
|
|
|
|
import {
|
|
|
|
Context, fs, Handler, Logger, NotFoundError, param, SettingModel, sha1,
|
|
|
|
size, SystemModel, Types, UiContextBase,
|
|
|
|
} from 'hydrooj';
|
|
|
|
import { debounce } from 'lodash';
|
|
|
|
import { tmpdir } from 'os';
|
|
|
|
import {
|
|
|
|
basename, join, relative, resolve,
|
|
|
|
} from 'path';
|
|
|
|
|
|
|
|
declare module 'hydrooj' {
|
|
|
|
interface UI {
|
|
|
|
esbuildPlugins?: esbuild.Plugin[]
|
|
|
|
}
|
|
|
|
interface SystemKeys {
|
|
|
|
'ui-default.nav_logo_dark': string;
|
|
|
|
}
|
|
|
|
interface UiContextBase {
|
|
|
|
nav_logo_dark?: string;
|
|
|
|
constantVersion?: string;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
function updateLogo() {
|
|
|
|
UiContextBase.nav_logo_dark = SystemModel.get('ui-default.nav_logo_dark');
|
|
|
|
}
|
|
|
|
|
|
|
|
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) {
|
|
|
|
b.onResolve({ filter: /^@hydrooj\/ui-default/ }, () => ({
|
|
|
|
path: 'api',
|
|
|
|
namespace: 'ui-default',
|
|
|
|
}));
|
|
|
|
b.onLoad({ filter: /.*/, namespace: 'ui-default' }, () => ({
|
|
|
|
contents: 'module.exports = window.HydroExports;',
|
|
|
|
loader: 'tsx',
|
|
|
|
}));
|
|
|
|
},
|
|
|
|
};
|
|
|
|
|
|
|
|
const build = async (contents: string) => {
|
|
|
|
const res = await esbuild.build({
|
|
|
|
format: 'iife' as 'iife',
|
|
|
|
bundle: true,
|
|
|
|
outdir: tmp,
|
|
|
|
splitting: false,
|
|
|
|
write: false,
|
|
|
|
target: ['chrome60'],
|
|
|
|
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;
|
|
|
|
};
|
|
|
|
|
|
|
|
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 global.addons) {
|
|
|
|
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)}'))`);
|
|
|
|
for (const file of outputFiles) {
|
|
|
|
addFile(basename(m).replace(/\.[tj]sx?$/, '.js'), file.text);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
for (const lang in global.Hydro.locales) {
|
|
|
|
if (!/^[a-zA-Z_]+$/.test(lang)) 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)};`,
|
|
|
|
...entryPoints.map((i) => `import '${relative(tmp, i)}';`),
|
|
|
|
].join('\n'));
|
|
|
|
const pages = entry.outputFiles.map((i) => i.text);
|
|
|
|
const str = `window.LANGS=${JSON.stringify(SettingModel.langs)};${pages.join('\n')}`;
|
|
|
|
addFile('entry.js', str);
|
|
|
|
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, true)
|
|
|
|
async all(domainId: string, name: string) {
|
|
|
|
this.response.type = 'application/javascript';
|
|
|
|
name ||= 'entry.js';
|
|
|
|
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', '/constant/:version', UiConstantsHandler);
|
|
|
|
ctx.Route('constant', '/lazy/:version/:name', UiConstantsHandler);
|
|
|
|
ctx.Route('constant', '/resource/:version/:name', UiConstantsHandler);
|
|
|
|
ctx.on('app/started', updateLogo);
|
|
|
|
ctx.on('app/started', buildUI);
|
|
|
|
const debouncedBuildUI = debounce(buildUI, 2000, { trailing: true });
|
|
|
|
const triggerHotUpdate = (path?: string) => {
|
|
|
|
if (path && !path.includes('/ui-default/') && !path.includes('/public/')) return;
|
|
|
|
debouncedBuildUI();
|
|
|
|
updateLogo();
|
|
|
|
};
|
|
|
|
ctx.on('system/setting', () => triggerHotUpdate());
|
|
|
|
ctx.on('app/watch/change', triggerHotUpdate);
|
|
|
|
ctx.on('app/watch/unlink', triggerHotUpdate);
|
|
|
|
debouncedBuildUI();
|
|
|
|
}
|