mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-20 01:38:19 +08:00
ui: add experimental lazy module loader
This commit is contained in:
1 parent
6cc0cd4e95
commit
46eb761079
5 files changed
+173
-103
No files matched your search
@@ -12,11 +12,25 @@ export { default as React } from 'react';
|
|||||||
export { default as ReactDOM } from 'react-dom/client';
|
export { default as ReactDOM } from 'react-dom/client';
|
||||||
export * from './misc/Page';
|
export * from './misc/Page';
|
||||||
|
|
||||||
|
const lazyModules = {};
|
||||||
export default async function load(name: string) {
|
export default async function load(name: string) {
|
||||||
if (window.node_modules[name]) return window.node_modules[name];
|
if (window.node_modules[name]) return window.node_modules[name];
|
||||||
if (name === 'echarts') return import('echarts');
|
if (name === 'echarts') return import('echarts');
|
||||||
if (name === 'moment') return import('moment');
|
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';
|
import AutoComplete from './components/autocomplete';
|
||||||
@@ -35,6 +49,8 @@ export function addPage(page: import('./misc/Page').Page | (() => Promise<void>
|
|||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
LANGS: Record<string, any>;
|
LANGS: Record<string, any>;
|
||||||
|
lazyloadMetadata: Record<string, string>;
|
||||||
|
lazyModuleResolver: Record<string, any>;
|
||||||
}
|
}
|
||||||
|
|
||||||
let UserContext: Record<string, any>;
|
let UserContext: Record<string, any>;
|
||||||
|
|||||||
@@ -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<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) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ window.Hydro = {
|
|||||||
bus,
|
bus,
|
||||||
};
|
};
|
||||||
window.externalModules = {};
|
window.externalModules = {};
|
||||||
|
window.lazyModuleResolver = {};
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
'%c%s%c%s',
|
'%c%s%c%s',
|
||||||
|
|||||||
@@ -1,90 +1,11 @@
|
|||||||
/* eslint-disable no-return-await */
|
/* eslint-disable global-require */
|
||||||
/* eslint-disable camelcase */
|
|
||||||
import crypto from 'crypto';
|
|
||||||
import esbuild from 'esbuild';
|
|
||||||
import {
|
import {
|
||||||
ContestModel, Context, fs, Handler, Logger, ObjectID, param, PERM, PRIV, ProblemModel, Schema,
|
ContestModel, Context, Handler, ObjectID, param, PERM, PRIV, ProblemModel, Schema,
|
||||||
SettingModel, size, SystemModel, SystemSettings, Types, UiContextBase, UserModel,
|
SettingModel, SystemModel, SystemSettings, Types, UserModel,
|
||||||
} from 'hydrooj';
|
} from 'hydrooj';
|
||||||
import { debounce } from 'lodash';
|
|
||||||
import { tmpdir } from 'os';
|
|
||||||
import { join, resolve } from 'path';
|
|
||||||
import convert from 'schemastery-jsonschema';
|
import convert from 'schemastery-jsonschema';
|
||||||
import markdown from './backendlib/markdown';
|
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 {
|
class WikiHelpHandler extends Handler {
|
||||||
noCheckPermView = true;
|
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 {
|
class LanguageHandler extends ResourceHandler {
|
||||||
async all({ lang }) {
|
async all({ lang }) {
|
||||||
if (!global.Hydro.locales[lang]) lang = SystemModel.get('server.language');
|
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) {
|
export function apply(ctx: Context) {
|
||||||
if (process.env.HYDRO_CLI) return;
|
if (process.env.HYDRO_CLI) return;
|
||||||
ctx.Route('wiki_help', '/wiki/help', WikiHelpHandler);
|
ctx.Route('wiki_help', '/wiki/help', WikiHelpHandler);
|
||||||
ctx.Route('wiki_about', '/wiki/about', WikiAboutHandler);
|
ctx.Route('wiki_about', '/wiki/about', WikiAboutHandler);
|
||||||
ctx.Route('set_theme', '/set_theme/:theme', SetThemeHandler);
|
ctx.Route('set_theme', '/set_theme/:theme', SetThemeHandler);
|
||||||
ctx.Route('set_legacy', '/legacy', LegacyModeHandler);
|
ctx.Route('set_legacy', '/legacy', LegacyModeHandler);
|
||||||
ctx.Route('constant', '/constant/:version', UiConstantsHandler);
|
|
||||||
ctx.Route('markdown', '/markdown', MarkdownHandler);
|
ctx.Route('markdown', '/markdown', MarkdownHandler);
|
||||||
ctx.Route('config_schema', '/manage/config/schema.json', SystemConfigSchemaHandler, PRIV.PRIV_EDIT_SYSTEM);
|
ctx.Route('config_schema', '/manage/config/schema.json', SystemConfigSchemaHandler, PRIV.PRIV_EDIT_SYSTEM);
|
||||||
ctx.Route('lang', '/l/:lang', LanguageHandler);
|
ctx.Route('lang', '/l/:lang', LanguageHandler);
|
||||||
ctx.Route('sw_config', '/sw-config', SWConfigHandler);
|
ctx.Route('sw_config', '/sw-config', SWConfigHandler);
|
||||||
ctx.Route('media', '/media', RichMediaHandler);
|
ctx.Route('media', '/media', RichMediaHandler);
|
||||||
ctx.on('app/started', buildUI);
|
ctx.plugin(require('./backendlib/builder'));
|
||||||
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.on('handler/after/DiscussionRaw', async (that) => {
|
ctx.on('handler/after/DiscussionRaw', async (that) => {
|
||||||
if (that.args.render && that.response.type === 'text/markdown') {
|
if (that.args.render && that.response.type === 'text/markdown') {
|
||||||
that.response.type = 'text/html';
|
that.response.type = 'text/html';
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"allowSyntheticDefaultImports": true,
|
"allowSyntheticDefaultImports": true,
|
||||||
"target": "ESNext",
|
"target": "ESNext",
|
||||||
|
"experimentalDecorators": true,
|
||||||
"baseUrl": ".",
|
"baseUrl": ".",
|
||||||
"module": "ESNext",
|
"module": "ESNext",
|
||||||
"lib": [
|
"lib": [
|
||||||
|
|||||||
Reference in new issue
Block a user