mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
ui-next: prod build
This commit is contained in:
10 files changed
+289
-73
No files matched your search
@@ -48,6 +48,7 @@ packages/**/*.js
|
||||
packages/ui-default/public
|
||||
packages/ui-default/misc/.iconfont
|
||||
packages/ui-default/static/locale
|
||||
packages/ui-next/public
|
||||
plugins/
|
||||
modules/
|
||||
|
||||
|
||||
@@ -137,7 +137,6 @@ const UINextConfig = {
|
||||
module: 'ESNext',
|
||||
skipLibCheck: true,
|
||||
allowSyntheticDefaultImports: true,
|
||||
baseUrl: '.',
|
||||
jsx: 'react-jsx',
|
||||
outDir: path.join(baseOutDir, 'ui-next'),
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"build:ui:dev:https": "node packages/ui-default/build --iconfont && node --trace-deprecation packages/ui-default/build --dev --https",
|
||||
"build:ui:production": "cross-env-shell NODE_OPTIONS=--max_old_space_size=8192 \"node packages/ui-default/build --iconfont && node packages/ui-default/build --production\"",
|
||||
"build:ui:production:webpack": "cross-env NODE_OPTIONS=--max_old_space_size=8192 node packages/ui-default/build --production",
|
||||
"build:ui-next": "tsc -p tsconfig.ui-next.json --noEmit && yarn workspace @hydrooj/ui-next vite build",
|
||||
"test": "node -r @hydrooj/register packages/common/tests/subtask.spec.ts && node test/entry.js",
|
||||
"benchmark": "cross-env BENCHMARK=true node test/entry.js",
|
||||
"oxlint": "cd framework/eslint-config && node -r @hydrooj/register build.ts && cd ../../ && oxlint --config framework/eslint-config/.oxlintrc.json",
|
||||
|
||||
+1
-15
@@ -1,15 +1 @@
|
||||
// Components
|
||||
export { Link, type LinkProps } from './src/components/link';
|
||||
|
||||
// Context
|
||||
export { type PageData, usePageData } from './src/context/page-data';
|
||||
export { type RouterState, useNavigate, useRouterState } from './src/context/router';
|
||||
export { useUrl } from './src/hooks/use-url';
|
||||
|
||||
// Registry
|
||||
export type {
|
||||
Interceptor, InterceptorEntry, InterceptorOptions,
|
||||
PluginAPI, PluginDefinition,
|
||||
SlotName,
|
||||
} from './src/registry';
|
||||
export { defineSlot } from './src/registry';
|
||||
export * from './src/api';
|
||||
+199
-53
@@ -1,10 +1,29 @@
|
||||
import crypto from 'crypto';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import esbuild from 'esbuild';
|
||||
import c2k from 'koa2-connect/ts';
|
||||
import { createServer, type Plugin } from 'vite';
|
||||
import { serializer } from '@hydrooj/framework';
|
||||
import type { Context } from 'hydrooj';
|
||||
import {
|
||||
Context, Handler, Logger,
|
||||
NotFoundError, param, size, Types,
|
||||
} from 'hydrooj';
|
||||
|
||||
const logger = new Logger('ui-next');
|
||||
|
||||
const PENDING_HTML = `<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Hydro</title>
|
||||
<meta http-equiv="refresh" content="3">
|
||||
</head>
|
||||
<body>
|
||||
<p>Hydro UI is building, please wait and refresh...</p>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const INJECT_MARKER = '<!-- __HYDRO_INJECTION__DO_NOT_REMOVE_THIS__ -->';
|
||||
const buildInject = (data: string) => `<script id="__HYDRO_INJECTION__" type="application/json">${data}</script>`;
|
||||
@@ -38,59 +57,186 @@ function hydroPlugins(): Plugin {
|
||||
};
|
||||
}
|
||||
|
||||
const federationPlugin: esbuild.Plugin = {
|
||||
name: 'federation',
|
||||
setup(b) {
|
||||
const mappings: Record<string, string> = {
|
||||
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<string, string> = {};
|
||||
const hashes: Record<string, string> = {};
|
||||
|
||||
class UiNextConstantHandler extends Handler {
|
||||
noCheckPermView = true;
|
||||
|
||||
@param('name', Types.Filename)
|
||||
async all(domainId: string, name: string) {
|
||||
if (!vfs[name]) 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: string[] = [];
|
||||
for (const addon of Object.values(global.addons)) {
|
||||
const uiEntry = path.resolve(addon as string, 'ui', 'index.ts');
|
||||
if (fs.existsSync(uiEntry)) entries.push(uiEntry);
|
||||
}
|
||||
if (!entries.length) {
|
||||
vfs['plugins.js'] = 'window.__hydroPlugins = [];';
|
||||
hashes['plugins.js'] = '00000000';
|
||||
logger.info('No plugins to build');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await esbuild.build({
|
||||
stdin: {
|
||||
contents: [
|
||||
...entries.map((e, i) => `import * as plugin${i} from '${e}';`),
|
||||
`window.__hydroPlugins = [${entries.map((e, i) => {
|
||||
const addonName = path.basename(path.resolve(e, '..', '..'));
|
||||
return `{ name: '${addonName}', ...plugin${i} }`;
|
||||
}).join(', ')}];`,
|
||||
].join('\n'),
|
||||
resolveDir: process.cwd(),
|
||||
loader: 'ts',
|
||||
},
|
||||
bundle: true,
|
||||
format: 'iife',
|
||||
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 content = result.outputFiles?.[0]?.text || 'window.__hydroPlugins = [];';
|
||||
vfs['plugins.js'] = content;
|
||||
hashes['plugins.js'] = crypto.createHash('sha1').update(content).digest('hex').substring(0, 8);
|
||||
totalSize += content.length;
|
||||
logger.success('Plugins built in %dms (%d entries, %s)', Date.now() - start, 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;
|
||||
// 现在只是开发环境的实现,生产环境的实现还未完成
|
||||
const vite = await createServer({
|
||||
clearScreen: false,
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: {
|
||||
port: 3010,
|
||||
},
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
// allowedHosts: ['beta.hydro.ac'],
|
||||
},
|
||||
appType: 'custom',
|
||||
root: __dirname,
|
||||
base: '/',
|
||||
plugins: [react(), hydroPlugins()],
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
});
|
||||
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,
|
||||
args,
|
||||
url: context.handler.context.req.url!,
|
||||
route_map: ctx.server.routeMap,
|
||||
endpoint: ctx.setting.get('server.url') || undefined,
|
||||
// hydro_domains: [],
|
||||
}, 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));
|
||||
};
|
||||
if (process.env.DEV) {
|
||||
const vite = await createServer({
|
||||
configFile: false,
|
||||
clearScreen: false,
|
||||
server: {
|
||||
middlewareMode: true,
|
||||
hmr: {
|
||||
port: 3010,
|
||||
},
|
||||
headers: {
|
||||
'Cross-Origin-Opener-Policy': 'same-origin',
|
||||
'Cross-Origin-Embedder-Policy': 'require-corp',
|
||||
},
|
||||
},
|
||||
appType: 'custom',
|
||||
root: __dirname,
|
||||
base: '/',
|
||||
plugins: [react(), hydroPlugins()],
|
||||
worker: {
|
||||
format: 'es',
|
||||
},
|
||||
});
|
||||
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,
|
||||
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,
|
||||
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());
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,31 @@
|
||||
{
|
||||
"name": "@hydrooj/ui-next",
|
||||
"version": "0.0.0",
|
||||
"author": "Baoshuo <i@baoshuo.ren>",
|
||||
"license": "AGPL-3.0",
|
||||
"type": "module",
|
||||
"main": "index.ts",
|
||||
"types": "api.ts",
|
||||
"preferUnplugged": true,
|
||||
"hydro": {
|
||||
"cli": false
|
||||
},
|
||||
"files": [
|
||||
"index.ts",
|
||||
"api.ts",
|
||||
"src/",
|
||||
"index.html",
|
||||
"public/",
|
||||
"tsconfig.json",
|
||||
"vite.config.ts"
|
||||
],
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"esbuild": "0.25.2",
|
||||
"is-relative-url": "^4.1.0",
|
||||
"path-to-regexp": "^8.4.2",
|
||||
"react": "^19.2.5",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Components
|
||||
export { Link, type LinkProps } from './components/link';
|
||||
|
||||
// Context
|
||||
export { type PageData, usePageData } from './context/page-data';
|
||||
export { type RouterState, useNavigate, useRouterState } from './context/router';
|
||||
export { useUrl } from './hooks/use-url';
|
||||
|
||||
// Registry
|
||||
export type {
|
||||
Interceptor, InterceptorEntry, InterceptorOptions,
|
||||
PluginAPI, PluginDefinition,
|
||||
SlotName,
|
||||
} from './registry';
|
||||
export { defineSlot } from './registry';
|
||||
|
||||
// Shared dependencies
|
||||
export { default as React } from 'react';
|
||||
export { default as ReactDOM } from 'react-dom/client';
|
||||
export { default as jsxRuntime } from 'react/jsx-runtime';
|
||||
@@ -14,6 +14,7 @@ if (injectionEl) {
|
||||
|
||||
export const isInjected: boolean = !!injectionData.HYDRO_INJECTED;
|
||||
export const hydroDomains: string[] = injectionData.hydro_domains ?? [];
|
||||
export const pluginsUrl: string | undefined = injectionData.plugins_url;
|
||||
|
||||
// routeMap as an external store for useSyncExternalStore, with HMR state preservation
|
||||
interface RouteMapStore {
|
||||
|
||||
@@ -1,16 +1,44 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import plugins from 'virtual:hydro-plugins';
|
||||
import * as api from './api';
|
||||
import App from './app';
|
||||
import { PageDataProvider } from './context/page-data';
|
||||
import { RouterProvider } from './context/router';
|
||||
import { initialPage } from './globals';
|
||||
import { initialPage, pluginsUrl } from './globals';
|
||||
import { installPlugin } from './registry';
|
||||
|
||||
for (const plugin of plugins) {
|
||||
installPlugin(plugin);
|
||||
declare global {
|
||||
interface Window {
|
||||
__hydroExports: typeof api;
|
||||
__hydroPlugins?: api.PluginDefinition[];
|
||||
}
|
||||
}
|
||||
|
||||
window.__hydroExports = api;
|
||||
|
||||
async function loadPlugins() {
|
||||
let plugins: api.PluginDefinition[] = [];
|
||||
if (import.meta.env.DEV) {
|
||||
const mod = await import('virtual:hydro-plugins');
|
||||
plugins = mod.default || [];
|
||||
} else {
|
||||
try {
|
||||
await import(/* @vite-ignore */ pluginsUrl || '/plugins.js');
|
||||
plugins = window.__hydroPlugins || [];
|
||||
} catch (e) {
|
||||
console.warn('[Hydro] Failed to load plugins:', e);
|
||||
}
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
console.log(`[Hydro] Installing plugin: ${plugin.name}`);
|
||||
installPlugin(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line antfu/no-top-level-await
|
||||
await loadPlugins();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<PageDataProvider initial={initialPage}>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
base: '/',
|
||||
plugins: [react()],
|
||||
publicDir: 'pub',
|
||||
build: {
|
||||
outDir: 'public',
|
||||
emptyOutDir: true,
|
||||
rolldownOptions: {
|
||||
output: {
|
||||
codeSplitting: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
worker: { format: 'es' },
|
||||
});
|
||||
Reference in new issue
Block a user