diff --git a/packages/ui-default/api.ts b/packages/ui-default/api.ts index 2eae5c6c..b445aab2 100644 --- a/packages/ui-default/api.ts +++ b/packages/ui-default/api.ts @@ -12,11 +12,25 @@ export { default as React } from 'react'; export { default as ReactDOM } from 'react-dom/client'; export * from './misc/Page'; +const lazyModules = {}; export default async function load(name: string) { if (window.node_modules[name]) return window.node_modules[name]; if (name === 'echarts') return import('echarts'); if (name === 'moment') return import('moment'); - throw new Error(`Module ${name} not found`); + if (!window.lazyloadMetadata?.[`${name}.lazy.js`]) throw new Error(`Module ${name} not found`); + if (lazyModules[name]) return lazyModules[name]; + const tag = document.createElement('script'); + tag.src = `/lazy/${window.lazyloadMetadata[`${name}.lazy.js`]}/${name}.lazy.js`; + lazyModules[name] = new Promise((resolve, reject) => { + tag.onerror = reject; + const timeout = setTimeout(reject, 30000); + window.lazyModuleResolver[name] = (item) => { + clearTimeout(timeout); + resolve(item); + }; + }); + document.body.appendChild(tag); + return lazyModules[name]; } import AutoComplete from './components/autocomplete'; @@ -35,6 +49,8 @@ export function addPage(page: import('./misc/Page').Page | (() => Promise declare global { interface Window { LANGS: Record; + lazyloadMetadata: Record; + lazyModuleResolver: Record; } let UserContext: Record; diff --git a/packages/ui-default/backendlib/builder.ts b/packages/ui-default/backendlib/builder.ts new file mode 100644 index 00000000..86864137 --- /dev/null +++ b/packages/ui-default/backendlib/builder.ts @@ -0,0 +1,149 @@ +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 = {}; +const hashes: Record = {}; +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) { + const 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)); + } + } + 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) { + const key = basename(m).replace(/\.[tj]sx?$/, '.js'); + vfs[key] = file.text; + hashes[key] = sha1(file.text).substring(0, 8); + logger.info('+ %s-%s: %s', key.split('.lazy.')[0], hashes[key].substring(0, 6), size(file.text.length)); + newFiles.push(key); + totalSize += file.text.length; + } + } + 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 payload = [`window.LANGS=${JSON.stringify(SettingModel.langs)};`, ...pages]; + const str = JSON.stringify(payload); + vfs['entry.js'] = str; + UiContextBase.constantVersion = hashes['entry.js'] = sha1(str).substring(0, 8); + logger.info('+ %s-%s: %s', 'entry', hashes['entry.js'].substring(0, 6), size(str.length)); + totalSize += str.length; + 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.String, true) + async all(domainId: string, name: string) { + this.response.type = name ? 'application/javascript' : 'application/json'; + name ||= 'entry.js'; + console.log([name, vfs[name], vfs]); + 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) { + buildUI(); + ctx.Route('constant', '/constant/:version', UiConstantsHandler); + ctx.Route('constant', '/lazy/: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); +} diff --git a/packages/ui-default/entry.js b/packages/ui-default/entry.js index 6c05947f..a64fc4ff 100644 --- a/packages/ui-default/entry.js +++ b/packages/ui-default/entry.js @@ -11,6 +11,7 @@ window.Hydro = { bus, }; window.externalModules = {}; +window.lazyModuleResolver = {}; console.log( '%c%s%c%s', diff --git a/packages/ui-default/index.ts b/packages/ui-default/index.ts index ec910690..3c6077ee 100644 --- a/packages/ui-default/index.ts +++ b/packages/ui-default/index.ts @@ -1,90 +1,11 @@ -/* eslint-disable no-return-await */ -/* eslint-disable camelcase */ -import crypto from 'crypto'; -import esbuild from 'esbuild'; +/* eslint-disable global-require */ import { - ContestModel, Context, fs, Handler, Logger, ObjectID, param, PERM, PRIV, ProblemModel, Schema, - SettingModel, size, SystemModel, SystemSettings, Types, UiContextBase, UserModel, + ContestModel, Context, Handler, ObjectID, param, PERM, PRIV, ProblemModel, Schema, + SettingModel, SystemModel, SystemSettings, Types, UserModel, } from 'hydrooj'; -import { debounce } from 'lodash'; -import { tmpdir } from 'os'; -import { join, resolve } from 'path'; import convert from 'schemastery-jsonschema'; import markdown from './backendlib/markdown'; -declare module 'hydrooj' { - interface UI { - esbuildPlugins?: esbuild.Plugin[] - } - interface SystemKeys { - 'ui-default.nav_logo_dark': string; - } - interface UiContextBase { - nav_logo_dark?: string; - constantVersion?: string; - } -} - -let constant = ''; -let hash = ''; -const logger = new Logger('ui'); - -export async function buildUI() { - const start = Date.now(); - const entryPoints: string[] = []; - for (const addon of global.addons) { - const publicPath = resolve(addon, 'public'); - if (fs.existsSync(publicPath)) { - const targets = fs.readdirSync(publicPath); - for (const target of targets) { - if (/\.page\.[jt]sx?$/.test(target)) entryPoints.push(join(publicPath, target)); - } - } - } - const build = await esbuild.build({ - format: 'iife', - entryPoints, - bundle: true, - outdir: tmpdir(), - splitting: false, - write: false, - target: [ - 'chrome60', - ], - plugins: [ - ...(global.Hydro.ui.esbuildPlugins || []), - { - 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', - })); - }, - }, - ], - minify: !process.env.DEV, - }); - if (build.errors.length) console.error(build.errors); - if (build.warnings.length) console.warn(build.warnings); - const pages = build.outputFiles.map((i) => i.text); - const payload = [`window.LANGS=${JSON.stringify(SettingModel.langs)};`, ...pages]; - - const c = crypto.createHash('sha1'); - c.update(JSON.stringify(payload)); - const version = c.digest('hex'); - constant = JSON.stringify(payload); - UiContextBase.constantVersion = hash = version; - logger.success('UI addons built in %d ms (%s)', Date.now() - start, size(constant.length)); -} -function updateLogo() { - UiContextBase.nav_logo_dark = SystemModel.get('ui-default.nav_logo_dark'); -} - class WikiHelpHandler extends Handler { noCheckPermView = true; @@ -165,14 +86,6 @@ class ResourceHandler extends Handler { } } -class UiConstantsHandler extends ResourceHandler { - async all() { - this.response.addHeader('ETag', hash); - this.response.body = constant; - this.response.type = 'application/json'; - } -} - class LanguageHandler extends ResourceHandler { async all({ lang }) { if (!global.Hydro.locales[lang]) lang = SystemModel.get('server.language'); @@ -252,29 +165,19 @@ class RichMediaHandler extends Handler { } } +// eslint-disable-next-line import/prefer-default-export export function apply(ctx: Context) { if (process.env.HYDRO_CLI) return; ctx.Route('wiki_help', '/wiki/help', WikiHelpHandler); ctx.Route('wiki_about', '/wiki/about', WikiAboutHandler); ctx.Route('set_theme', '/set_theme/:theme', SetThemeHandler); ctx.Route('set_legacy', '/legacy', LegacyModeHandler); - ctx.Route('constant', '/constant/:version', UiConstantsHandler); ctx.Route('markdown', '/markdown', MarkdownHandler); ctx.Route('config_schema', '/manage/config/schema.json', SystemConfigSchemaHandler, PRIV.PRIV_EDIT_SYSTEM); ctx.Route('lang', '/l/:lang', LanguageHandler); ctx.Route('sw_config', '/sw-config', SWConfigHandler); ctx.Route('media', '/media', RichMediaHandler); - ctx.on('app/started', buildUI); - ctx.on('app/started', updateLogo); - const debouncedBuildUI = debounce(buildUI, 1000); - 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); + ctx.plugin(require('./backendlib/builder')); ctx.on('handler/after/DiscussionRaw', async (that) => { if (that.args.render && that.response.type === 'text/markdown') { that.response.type = 'text/html'; diff --git a/packages/ui-default/jsconfig.json b/packages/ui-default/jsconfig.json index 236c55a3..36346645 100644 --- a/packages/ui-default/jsconfig.json +++ b/packages/ui-default/jsconfig.json @@ -7,6 +7,7 @@ "jsx": "react", "allowSyntheticDefaultImports": true, "target": "ESNext", + "experimentalDecorators": true, "baseUrl": ".", "module": "ESNext", "lib": [