diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 27fad22132..e8014acd54 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -16,6 +16,7 @@ "remoteUser": "root", "forwardPorts": [ 2333, + 3010, 8000 ], "features": { @@ -32,4 +33,4 @@ } }, "postCreateCommand": "git submodule update --init && yarn install && npx hydrooj cli system set server.port 2333 && npx hydrooj cli user create root@hydro.local root rootroot 2 && npx hydrooj cli user setSuperAdmin 2" -} \ No newline at end of file +} diff --git a/.gitignore b/.gitignore index 6cb592a6ab..0a908c7601 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.vscode/settings.json b/.vscode/settings.json index 9208b34f39..f92637c3d9 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -16,5 +16,17 @@ }, "files.associations": { "*.html": "nunjucks" + }, + "[javascript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescript]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "dbaeumer.vscode-eslint" } -} \ No newline at end of file +} diff --git a/build/prepare.js b/build/prepare.js index ba42f9a0f6..f0bc79eb7b 100644 --- a/build/prepare.js +++ b/build/prepare.js @@ -137,7 +137,6 @@ const UINextConfig = { module: 'ESNext', skipLibCheck: true, allowSyntheticDefaultImports: true, - baseUrl: '.', jsx: 'react-jsx', outDir: path.join(baseOutDir, 'ui-next'), diff --git a/eslint.config.mjs b/eslint.config.mjs index b7cd499a77..87b138e4de 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,6 +1,7 @@ /* eslint-disable max-len */ /* eslint-disable ts/naming-convention */ import path from 'node:path'; +import reactHooks from 'eslint-plugin-react-hooks'; import globals from 'globals'; import react from '@hydrooj/eslint-config'; @@ -67,6 +68,7 @@ const config = react({ '**/{public,frontend}/**/*.{ts,tsx,page.js}', '**/plugins/**/*.page.{ts,js,tsx,jsx}', 'packages/ui-default/**/*.{ts,tsx,js,jsx}', + 'packages/ui-next/src/**/*.{ts,tsx,js,jsx}', ], languageOptions: { @@ -193,12 +195,16 @@ const config = react({ }, }, { files: ['packages/ui-next/src/**/*.{ts,tsx}'], + plugins: { + 'react-hooks': reactHooks, + }, languageOptions: { globals: { ...globals.browser, }, }, rules: { + ...reactHooks.configs.recommended.rules, 'style/indent': ['warn', 2], 'style/indent-binary-ops': ['warn', 2], }, diff --git a/framework/framework/base.ts b/framework/framework/base.ts index 1a9a4e0b83..70ed7373cc 100644 --- a/framework/framework/base.ts +++ b/framework/framework/base.ts @@ -76,13 +76,20 @@ export default (logger, xff, xhost) => async (ctx: KoaContext, next: Next) => { response.type = 'application/json'; } else if ( request.json || response.redirect - || request.query.noTemplate || !response.template) { + || request.query.noTemplate || !response.template // no template, send raw data + ) { // Send raw data try { - if (typeof response.body === 'object' && request.headers['x-hydro-inject']) { + if (request.headers['x-hydro-inject']) { const inject = request.headers['x-hydro-inject'].toString().toLowerCase().split(',').map((i) => i.trim()); - if (inject.includes('uicontext')) response.body.UiContext = UiContext; - if (inject.includes('usercontext')) response.body.UserContext = user; + if (inject.includes('pagename')) { + ctx.set('x-hydro-page', ctx._matchedRouteName || ''); + } + if (response.body !== null && typeof response.body === 'object') { + if (inject.includes('uicontext')) response.body.UiContext = UiContext; + if (inject.includes('usercontext')) response.body.UserContext = user; + if (inject.includes('routemap')) response.body.routeMap = handler.ctx.server.routeMap; + } } response.body = JSON.stringify(response.body, serializer(false, handler)); } catch (e) { diff --git a/framework/framework/server.ts b/framework/framework/server.ts index 2e17b282d7..f168e88a24 100644 --- a/framework/framework/server.ts +++ b/framework/framework/server.ts @@ -106,13 +106,19 @@ export type KoaContext = Koa.Context & { holdFiles: (string | File)[]; }; +interface RendererContext { + handler: HandlerCommon; + UserContext: UserModel; + url: HandlerCommon['url']; + _: HandlerCommon['translate']; +} export interface TextRenderer { output: 'html' | 'json' | 'text'; - render: (name: string, args: Record, context: Record) => string | Promise; + render: (name: string, args: Record, context: RendererContext) => string | Promise; } export interface BinaryRenderer { output: 'binary'; - render: (name: string, args: Record, context: Record) => Buffer | Promise; + render: (name: string, args: Record, context: RendererContext) => Buffer | Promise; } export type Renderer = (BinaryRenderer | TextRenderer) & { name: string; @@ -306,7 +312,12 @@ export class NotFoundHandler extends Handler { all() { } } -function executeMiddlewareStack(context: any, middlewares: { name: string, func: Function }[]) { +export interface LayerEntry { + name: string; + func: (ctx: any, next: () => Promise) => any; +} + +function executeMiddlewareStack(context: any, middlewares: LayerEntry[]): Promise { let index = -1; context.__timers ||= {}; function dispatch(i) { @@ -344,9 +355,9 @@ export class WebService extends Service { private registry: Record = Object.create(null); private registrationCount = Counter(); - private serverLayers = []; - private handlerLayers = []; - private wsLayers = []; + private serverLayers: LayerEntry[] = []; + private handlerLayers: LayerEntry[] = []; + private wsLayers: LayerEntry[] = []; private captureAllRoutes = Object.create(null); private customDefaultContext: CordisContext; private activeHandlers: Map = new Map(); @@ -355,6 +366,22 @@ export class WebService extends Service { server = koa; router = router; HandlerCommon = HandlerCommon; + private _routeMap: Record = Object.create(null); + + get routeMap(): Record { + return this._routeMap; + } + + private rebuildRouteMap() { + const map: Record = Object.create(null); + for (const layer of this.router.stack) { + if (layer.name && typeof layer.path === 'string') { + map[layer.name] = layer.path; + } + } + this._routeMap = map; + } + Handler = Handler; ConnectionHandler = ConnectionHandler; @@ -759,12 +786,14 @@ ${c.response.status} ${endTime - startTime}ms ${c.response.length}`); } } const dispose = router.disposeLastOp; + this.rebuildRouteMap(); // @ts-ignore this.ctx.parallel(`handler/register/${name}`, HandlerClass); this.ctx.effect(() => () => { this.registrationCount[name]--; if (!this.registrationCount[name]) delete this.registry[name]; dispose(); + this.rebuildRouteMap(); }); } @@ -794,10 +823,6 @@ ${c.response.status} ${endTime - startTime}ms ${c.response.length}`); // eslint-disable-next-line ts/naming-convention public Route(name: string, path: string, RouteHandler: typeof Handler, ...permPrivChecker) { - // if (name === 'contest_scoreboard') { - // console.log('+++', this.ctx); - // console.log(this.ctx.scoreboard); - // } return this.register('route', name, path, RouteHandler, ...permPrivChecker); } @@ -806,7 +831,7 @@ ${c.response.status} ${endTime - startTime}ms ${c.response.length}`); return this.register('conn', name, path, RouteHandler, ...permPrivChecker); } - private registerLayer(name: 'serverLayers' | 'handlerLayers' | 'wsLayers', layer: any) { + private registerLayer(name: 'serverLayers' | 'handlerLayers' | 'wsLayers', layer: LayerEntry) { this.ctx.effect(() => { this[name].push(layer); return () => { @@ -815,19 +840,19 @@ ${c.response.status} ${endTime - startTime}ms ${c.response.length}`); }); } - public addServerLayer(name: string, func: any) { + public addServerLayer(name: LayerEntry['name'], func: LayerEntry['func']) { return this.registerLayer('serverLayers', { name, func }); } - public addHandlerLayer(name: string, func: any) { + public addHandlerLayer(name: LayerEntry['name'], func: LayerEntry['func']) { return this.registerLayer('handlerLayers', { name, func }); } - public addWSLayer(name: string, func: any) { + public addWSLayer(name: LayerEntry['name'], func: LayerEntry['func']) { return this.registerLayer('wsLayers', { name, func }); } - public addLayer(name: string, layer: any) { + public addLayer(name: LayerEntry['name'], layer: LayerEntry['func']) { this.addHandlerLayer(name, layer); this.addWSLayer(name, layer); } diff --git a/package.json b/package.json index ecce9f070b..53cbce46d0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/ui-next-plugin-sample/package.json b/packages/ui-next-plugin-sample/package.json new file mode 100644 index 0000000000..31905afb1c --- /dev/null +++ b/packages/ui-next-plugin-sample/package.json @@ -0,0 +1,6 @@ +{ + "name": "@hydrooj/ui-next-plugin-sample", + "dependencies": { + "@hydrooj/ui-next": "workspace:*" + } +} diff --git a/packages/ui-next-plugin-sample/ui/before.tsx b/packages/ui-next-plugin-sample/ui/before.tsx new file mode 100644 index 0000000000..a60ec9ea7a --- /dev/null +++ b/packages/ui-next-plugin-sample/ui/before.tsx @@ -0,0 +1,5 @@ +export default function BeforeComponent() { + console.log('before app'); + // throw new Error('test error boundary in before interceptor'); + return
before app via @hydrooj/ui-next-plugin-sample
; +} diff --git a/packages/ui-next-plugin-sample/ui/index.ts b/packages/ui-next-plugin-sample/ui/index.ts new file mode 100644 index 0000000000..9962f2534d --- /dev/null +++ b/packages/ui-next-plugin-sample/ui/index.ts @@ -0,0 +1,6 @@ +import type { PluginAPI } from '@hydrooj/ui-next'; +import BeforeComponent from './before'; + +export function setup(api: PluginAPI) { + api.before('page:app', BeforeComponent); +} diff --git a/packages/ui-next/api.ts b/packages/ui-next/api.ts new file mode 100644 index 0000000000..893c061ee1 --- /dev/null +++ b/packages/ui-next/api.ts @@ -0,0 +1 @@ +export * from './src/api'; diff --git a/packages/ui-next/index.html b/packages/ui-next/index.html index 1d7f9faf71..deacd330d7 100644 --- a/packages/ui-next/index.html +++ b/packages/ui-next/index.html @@ -5,6 +5,7 @@ Hydro +
diff --git a/packages/ui-next/index.ts b/packages/ui-next/index.ts index 9fc34d844a..0e604d2416 100644 --- a/packages/ui-next/index.ts +++ b/packages/ui-next/index.ts @@ -1,59 +1,242 @@ +import crypto from 'crypto'; import fs from 'fs'; import path from 'path'; -import importMetaUrlPlugin from '@codingame/esbuild-import-meta-url-plugin'; -import react from '@vitejs/plugin-react-swc'; -import c2k from 'koa2-connect'; -import { createServer } from 'vite'; -import { } from '@hydrooj/framework'; -import { Context } from 'hydrooj'; +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 { + Context, Handler, Logger, + NotFoundError, param, size, Types, +} from 'hydrooj'; + +const logger = new Logger('ui-next'); + +const PENDING_HTML = ` + + + + Hydro + + + +

Hydro UI is building, please wait and refresh...

+ +`; + +const INJECT_MARKER = ''; +const buildInject = (data: string) => ``; + +function hydroPlugins(): Plugin { + const virtualModuleId = 'virtual:hydro-plugins'; + const resolvedVirtualModuleId = `\0${virtualModuleId}`; + + return { + name: 'hydro-plugins', + resolveId(id) { + if (id === virtualModuleId) { + return resolvedVirtualModuleId; + } + return undefined; + }, + load(id) { + if (id === resolvedVirtualModuleId) { + const entries: string[] = []; + for (const addon of Object.values(global.addons)) { + const uiEntry = path.resolve(addon, 'ui', 'index.ts'); + if (fs.existsSync(uiEntry)) entries.push(uiEntry); + } + if (!entries.length) return 'export default [];'; + const imports = entries.map((e, i) => `import * as plugin${i} from '${e}';`).join('\n'); + const exports = `export default [${entries.map((_, i) => `plugin${i}`).join(', ')}];`; + return `${imports}\n${exports}`; + } + return undefined; + }, + }; +} + +const federationPlugin: esbuild.Plugin = { + name: 'federation', + setup(b) { + const mappings: Record = { + 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 = {}; +const hashes: Record = {}; + +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({ - server: { - middlewareMode: true, - hmr: { - port: 3010, + + 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', + }, }, - headers: { - 'Cross-Origin-Opener-Policy': 'same-origin', - 'Cross-Origin-Embedder-Policy': 'require-corp', + appType: 'custom', + root: __dirname, + base: '/', + plugins: [react(), hydroPlugins()], + worker: { + format: 'es', }, - allowedHosts: ['beta.hydro.ac'], - }, - appType: 'custom', - root: __dirname, - base: '/', - plugins: [react()], - optimizeDeps: { - esbuildOptions: { - plugins: [ - // @ts-ignore - importMetaUrlPlugin, - ], + }); + 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); }, - }, - worker: { - format: 'es', - }, - }); - const middleware = c2k(vite.middlewares); - const capture = ['/@vite/', '/src/', '/node_modules/', '/@react-refresh', '/@fs']; - for (const route of capture) { - ctx.server.addCaptureRoute(route, middleware); + }); + + // 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()); } - const html = fs.readFileSync(path.join(__dirname, 'index.html'), 'utf-8'); - ctx.server.registerRenderer('next', { - name: 'next', - accept: ['main.html'], - output: 'html', - asFallback: false, - priority: 100, - render: async (name, args, context) => await vite.transformIndexHtml(context.handler.context.req.url, html), - }); - - // eslint-disable-next-line consistent-return - return async () => { - await vite.close().catch((e) => console.error(e)); - }; } diff --git a/packages/ui-next/package.json b/packages/ui-next/package.json index a517bbfc8a..9049d33bf3 100644 --- a/packages/ui-next/package.json +++ b/packages/ui-next/package.json @@ -1,21 +1,41 @@ { "name": "@hydrooj/ui-next", "version": "0.0.0", + "author": "Baoshuo ", + "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": { - "@vitejs/plugin-react": "^6.0.1", + "esbuild": "0.25.2", + "is-relative-url": "^4.1.0", + "path-to-regexp": "^8.4.2", "react": "^19.2.5", "react-dom": "^19.2.5" }, "devDependencies": { "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react-swc": "^4.3.0", + "@vitejs/plugin-react": "^6.0.1", + "koa2-connect": "^1.0.2", "vite": "^8.0.10" } } diff --git a/packages/ui-next/src/App.tsx b/packages/ui-next/src/App.tsx deleted file mode 100644 index 6f74d94b5d..0000000000 --- a/packages/ui-next/src/App.tsx +++ /dev/null @@ -1,5 +0,0 @@ -function App() { - return
; -} - -export default App; diff --git a/packages/ui-next/src/api.ts b/packages/ui-next/src/api.ts new file mode 100644 index 0000000000..3c6799e8a2 --- /dev/null +++ b/packages/ui-next/src/api.ts @@ -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'; diff --git a/packages/ui-next/src/app.tsx b/packages/ui-next/src/app.tsx new file mode 100644 index 0000000000..2257bac683 --- /dev/null +++ b/packages/ui-next/src/app.tsx @@ -0,0 +1,18 @@ +import { Link } from './components/link'; +import { usePageData } from './context/page-data'; +import { defineSlot } from './registry'; + +const AppInner = defineSlot('page:app', () => { + const data = usePageData(); + + return ( + <> +
app, page:{data.name}
+
+ homepage problem_main +
+ + ); +}); + +export default AppInner; diff --git a/packages/ui-next/src/components/link.tsx b/packages/ui-next/src/components/link.tsx new file mode 100644 index 0000000000..7f4b5ff4fb --- /dev/null +++ b/packages/ui-next/src/components/link.tsx @@ -0,0 +1,44 @@ +import React, { useCallback, useMemo } from 'react'; +import { useNavigate } from '../context/router'; +import { useUrl } from '../hooks/use-url'; + +export interface LinkProps extends Omit, 'href'> { + /** Pre-built href. Use this or `to`, not both. */ + href?: string; + /** Route name to resolve via the route map. */ + to?: string; + /** Params for route resolution when `to` is given. */ + params?: Record; +} + +function isModifiedEvent(e: React.MouseEvent): boolean { + return e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey; +} + +export const Link: React.FC> = ({ href, to, params, onClick, target, download = false, children, ...rest }) => { + const buildUrl = useUrl(); + const navigate = useNavigate(); + + const resolvedHref = useMemo(() => (to ? buildUrl(to, params) : (href ?? '#')), [buildUrl, href, to, params]); + + const handleClick = useCallback( + (e: React.MouseEvent) => { + onClick?.(e); + if (e.defaultPrevented || isModifiedEvent(e)) return; + if (target && target !== '_self') return; + if (download) return; + if (resolvedHref.startsWith('#')) return; + const resolved = new URL(resolvedHref, window.location.href); + if (resolved.pathname === window.location.pathname && resolved.hash) return; + e.preventDefault(); + navigate(resolvedHref); + }, + [onClick, resolvedHref, navigate, target, download], + ); + + return ( + + {children} + + ); +}; diff --git a/packages/ui-next/src/context/page-data.tsx b/packages/ui-next/src/context/page-data.tsx new file mode 100644 index 0000000000..54d9d07bdb --- /dev/null +++ b/packages/ui-next/src/context/page-data.tsx @@ -0,0 +1,42 @@ +/* eslint-disable react-refresh/only-export-components */ + +import { createContext, type ReactNode, useContext, useMemo, useState } from 'react'; + +export interface PageData { + name: string; + args: Record; + url: string; +} + +interface PageDataContextValue { + data: PageData; + setData: React.Dispatch>; +} + +const PageDataContext = createContext(null); + +interface PageDataProviderProps { + initial: PageData; + children: ReactNode; +} + +export function PageDataProvider({ initial, children }: PageDataProviderProps) { + const [data, setData] = useState(initial); + const value = useMemo(() => ({ data, setData }), [data]); + + return {children}; +} + +function usePageDataContext(): PageDataContextValue { + const ctx = useContext(PageDataContext); + if (!ctx) throw new Error('usePageData must be used within PageDataProvider'); + return ctx; +} + +export function usePageData(): PageData { + return usePageDataContext().data; +} + +export function useSetPageData(): React.Dispatch> { + return usePageDataContext().setData; +} diff --git a/packages/ui-next/src/context/router.tsx b/packages/ui-next/src/context/router.tsx new file mode 100644 index 0000000000..5253552f2f --- /dev/null +++ b/packages/ui-next/src/context/router.tsx @@ -0,0 +1,181 @@ +/* eslint-disable react-refresh/only-export-components */ + +import React, { createContext, useCallback, useContext, useEffect, useMemo, useReducer, useRef } from 'react'; +import { endpointOrigins, endpoints, isInjected, routeMapStore } from '../globals'; +import { useSetPageData } from './page-data'; + +interface InternalState { + status: 'idle' | 'loading' | 'error'; + error: Error | null; +} + +type RouterAction = + | { type: 'FETCH_START' } + | { type: 'FETCH_SUCCESS' } + | { type: 'FETCH_ERROR', error: Error } + | { type: 'FETCH_ABORT' }; + +function routerReducer(state: InternalState, action: RouterAction): InternalState { + switch (action.type) { + case 'FETCH_START': return { status: 'loading', error: null }; + case 'FETCH_SUCCESS': return { status: 'idle', error: null }; + case 'FETCH_ERROR': return { status: 'error', error: action.error }; + case 'FETCH_ABORT': return state; + default: return state; + } +} + +export interface RouterState { + loading: boolean; + error: Error | null; +} + +interface RouterNavigateContextValue { + navigate: (url: string) => Promise; +} + +const RouterStateContext = createContext(null); +const RouterNavigateContext = createContext(null); + +export const RouterProvider: React.FC = ({ children }) => { + const [state, dispatch] = useReducer(routerReducer, { status: 'idle', error: null }); + const abortRef = useRef(null); + const genRef = useRef(0); + const setData = useSetPageData(); + + const isSameOrigin = useCallback((url: string) => { + try { + return endpointOrigins.has(new URL(url, endpoints[0]).origin); + } catch { + return false; + } + }, []); + + const fetchPage = useCallback( + async (url: string, init = false) => { + abortRef.current?.abort(); + const gen = ++genRef.current; + const controller = new AbortController(); + abortRef.current = controller; + + dispatch({ type: 'FETCH_START' }); + + let lastError: Error | null = null; + for (const ep of endpoints) { + try { + const signal = endpoints.length > 1 + ? AbortSignal.any([controller.signal, AbortSignal.timeout(10000)]) + : controller.signal; + const reqUrl = new URL(url, ep).href; + const res = await fetch(reqUrl, { + signal, + headers: { + Accept: 'application/json', + 'x-hydro-inject': [ + 'uicontext', 'usercontext', 'pagename', + ...(init ? ['routemap'] : []), + ].join(','), + }, + }); + if (res.redirected) { + window.location.href = res.url; + return false; + } + if (!res.ok) throw new Error(`Navigation failed: ${res.status} ${res.statusText}`); + const body = await res.json(); + const pageName = res.headers.get('x-hydro-page') || ''; + console.log('[Hydro] data from', reqUrl, 'received:', body, 'pageName:', pageName); + + if (gen !== genRef.current) return false; + + if (init && body.routeMap && typeof body.routeMap === 'object') { + routeMapStore.set(body.routeMap); + } + setData((prev) => ({ + ...prev, + args: body, + name: pageName, + url, + })); + dispatch({ type: 'FETCH_SUCCESS' }); + return true; + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') { + dispatch({ type: 'FETCH_ABORT' }); + console.log('[Hydro] navigation to', url, 'aborted'); + return false; + } + lastError = e instanceof Error ? e : new Error(String(e)); + console.warn('[Hydro] endpoint', ep, 'failed:', lastError.message); + if (controller.signal.aborted) { + // User-initiated abort propagated through AbortSignal.any + dispatch({ type: 'FETCH_ABORT' }); + return false; + } + } + } + + console.error('[Hydro] all endpoints failed:', lastError); + if (gen !== genRef.current) return false; + dispatch({ type: 'FETCH_ERROR', error: lastError! }); + window.location.href = url; + return false; + }, + [setData], + ); + + useEffect(() => { + const handler = (e: PopStateEvent) => { + const url: string = + (e.state as { url?: string } | null)?.url + ?? window.location.pathname + window.location.search; + fetchPage(url); + }; + window.addEventListener('popstate', handler); + return () => window.removeEventListener('popstate', handler); + }, [fetchPage]); + + // If no server-side injection, fetch initial page data from the API + useEffect(() => { + if (!isInjected) { + console.log('[Hydro] no initial data injection found, fetching page data for current URL'); + fetchPage(window.location.pathname + window.location.search, true); + } + }, [fetchPage]); + + const stateValue = useMemo( + () => ({ loading: state.status === 'loading', error: state.error }), + [state.status, state.error], + ); + + const navigate = useCallback(async (url: string) => { + if (!isSameOrigin(url)) { + window.location.href = url; + return; + } + const ok = await fetchPage(url); + if (ok) history.pushState({ url }, '', url); + }, [fetchPage, isSameOrigin]); + + const navigateValue = useMemo(() => ({ navigate }), [navigate]); + + return ( + + + {children} + + + ); +}; + +export function useRouterState(): RouterState { + const ctx = useContext(RouterStateContext); + if (!ctx) throw new Error('useRouterState must be used within RouterProvider'); + return ctx; +} + +export function useNavigate(): (url: string) => Promise { + const ctx = useContext(RouterNavigateContext); + if (!ctx) throw new Error('useNavigate must be used within RouterProvider'); + return ctx.navigate; +} diff --git a/packages/ui-next/src/globals.ts b/packages/ui-next/src/globals.ts new file mode 100644 index 0000000000..8a45b3ab52 --- /dev/null +++ b/packages/ui-next/src/globals.ts @@ -0,0 +1,70 @@ +import isRelativeUrl from 'is-relative-url'; +import type { PageData } from './context/page-data'; + +const injectionEl = document.getElementById('__HYDRO_INJECTION__'); +let injectionData: Record = {}; +if (injectionEl) { + try { + injectionData = JSON.parse(injectionEl.textContent!); + console.log('[Hydro] initial data:', injectionData); + } catch (e) { + console.error('[Hydro] Failed to parse injection data:', e); + } +} + +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 { + _routeMap: Record; + _listeners: Set<() => void>; + getSnapshot: () => Record; + subscribe: (listener: () => void) => () => void; + set: (map: Record) => void; +} + +function createRouteMapStore(initial: Record): RouteMapStore { + const store: RouteMapStore = { + _routeMap: initial, + _listeners: new Set(), + getSnapshot: () => store._routeMap, + subscribe: (listener: () => void) => { + store._listeners.add(listener); + return () => { store._listeners.delete(listener); }; + }, + set: (map: Record) => { + store._routeMap = { ...store._routeMap, ...map }; + store._listeners.forEach((l) => l()); + }, + }; + return store; +} + +export const routeMapStore: RouteMapStore = import.meta.hot?.data?.routeMapStore + ?? createRouteMapStore(injectionData.route_map || {}); +if (import.meta.hot) import.meta.hot.data.routeMapStore = routeMapStore; + +export const endpoints: string[] = (() => { + if (hydroDomains.length) { + return hydroDomains + .map((d) => d.includes('://') ? d : `${window.location.protocol}//${d}`) + .map((d) => d.replace(/\/$/, '')); + } + if (typeof injectionData.endpoint === 'string') { + const ep = injectionData.endpoint; + if (isRelativeUrl(ep, { allowProtocolRelative: false })) { + return [new URL(ep, window.location.href).href.replace(/\/$/, '')]; + } + return [ep.replace(/\/$/, '')]; + } + return [window.location.origin]; +})(); +export const endpointOrigins = new Set(endpoints.map((ep) => new URL(ep).origin)); + +export const initialPage: PageData = { + name: (injectionData.name as string) || '', + args: (injectionData.args as Record) || {}, + url: (injectionData.url as string) || (window.location.pathname + window.location.search), +}; diff --git a/packages/ui-next/src/hooks/use-route-map.ts b/packages/ui-next/src/hooks/use-route-map.ts new file mode 100644 index 0000000000..885db4fa59 --- /dev/null +++ b/packages/ui-next/src/hooks/use-route-map.ts @@ -0,0 +1,6 @@ +import { useSyncExternalStore } from 'react'; +import { routeMapStore } from '../globals'; + +export function useRouteMap() { + return useSyncExternalStore(routeMapStore.subscribe, routeMapStore.getSnapshot); +} diff --git a/packages/ui-next/src/hooks/use-url.ts b/packages/ui-next/src/hooks/use-url.ts new file mode 100644 index 0000000000..795bff72c9 --- /dev/null +++ b/packages/ui-next/src/hooks/use-url.ts @@ -0,0 +1,25 @@ +import { compile } from 'path-to-regexp'; +import { useCallback } from 'react'; +import { useRouteMap } from './use-route-map'; + +function buildUrl(pattern: string, params: Record = {}): string { + try { + return compile(pattern)(params); + } catch (err) { + console.warn(`[Hydro] Failed to build URL for pattern "${pattern}":`, err); + return '#'; + } +} + +export function useUrl() { + const routeMap = useRouteMap(); + + return useCallback((name: string, params: Record = {}): string => { + const pattern = routeMap[name]; + if (!pattern) { + console.warn(`[Hydro] Unknown route: ${name}`); + return '#'; + } + return buildUrl(pattern, params); + }, [routeMap]); +} diff --git a/packages/ui-next/src/main.tsx b/packages/ui-next/src/main.tsx index 8b9bc3ccda..28ee364e3e 100644 --- a/packages/ui-next/src/main.tsx +++ b/packages/ui-next/src/main.tsx @@ -1,7 +1,54 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import App from './App'; +import * as api from './api'; +import App from './app'; +import { PageDataProvider } from './context/page-data'; +import { RouterProvider } from './context/router'; +import { initialPage, pluginsUrl } from './globals'; +import { installPlugin } from './registry'; -createRoot(document.getElementById('root')!).render( - -); +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}`); + try { + installPlugin(plugin); + } catch (e) { + console.error(`[Hydro] Failed to install plugin ${plugin.name}:`, e); + } + } +} + +// eslint-disable-next-line antfu/no-top-level-await +await loadPlugins(); + +createRoot(document.getElementById('root')!).render( + + + + + + + , +); diff --git a/packages/ui-next/src/registry/error-boundary.tsx b/packages/ui-next/src/registry/error-boundary.tsx new file mode 100644 index 0000000000..2ab8f9e28c --- /dev/null +++ b/packages/ui-next/src/registry/error-boundary.tsx @@ -0,0 +1,53 @@ +import React from 'react'; + +interface SlotErrorBoundaryProps { + slotName: string; + label?: string; + children: React.ReactNode; +} + +interface SlotErrorBoundaryState { + hasError: boolean; + error: Error | null; +} + +export class SlotErrorBoundary extends React.Component { + state: SlotErrorBoundaryState = { hasError: false, error: null }; + + static getDerivedStateFromError(error: Error): SlotErrorBoundaryState { + return { hasError: true, error }; + } + + componentDidCatch(error: Error, info: React.ErrorInfo): void { + const tag = this.props.label + ? `[Hydro] SlotErrorBoundary(${this.props.slotName}/${this.props.label})` + : `[Hydro] SlotErrorBoundary(${this.props.slotName})`; + console.error(tag, error, info.componentStack); + } + + render(): React.ReactNode { + if (!this.state.hasError) return this.props.children; + + const { slotName, label } = this.props; + return ( +
+ Slot Error in {slotName} + {label && <> / {label}} +
+          {this.state.error?.message}
+        
+
+ ); + } +} diff --git a/packages/ui-next/src/registry/index.ts b/packages/ui-next/src/registry/index.ts new file mode 100644 index 0000000000..51d3076a76 --- /dev/null +++ b/packages/ui-next/src/registry/index.ts @@ -0,0 +1,6 @@ +export { SlotErrorBoundary } from './error-boundary'; +export { after, before, intercept, patch, replace, wrap } from './interceptors'; +export { installPlugin } from './plugin'; +export type { PluginAPI, PluginDefinition } from './plugin'; +export { defineSlot } from './slot'; +export type { Interceptor, InterceptorEntry, InterceptorOptions, SlotName } from './types'; diff --git a/packages/ui-next/src/registry/interceptors.tsx b/packages/ui-next/src/registry/interceptors.tsx new file mode 100644 index 0000000000..a14b6aa447 --- /dev/null +++ b/packages/ui-next/src/registry/interceptors.tsx @@ -0,0 +1,102 @@ +import { SlotErrorBoundary } from './error-boundary'; +import { store } from './store'; +import type { Interceptor, InterceptorOptions, SlotName } from './types'; + +export function intercept

( + name: SlotName, + interceptor: Interceptor

, + opts?: InterceptorOptions, +): () => void { + return store.addInterceptor(name, interceptor, opts); +} + +/** default priority: -100 */ +export function before

= Record>( + name: SlotName, + Comp: React.FC

, + opts?: InterceptorOptions, +): () => void { + const label = `before:${Comp.displayName || Comp.name || '?'}`; + return intercept

( + name, + (props, next) => ( + <> + + + + {next()} + + ), + { priority: -100, ...opts }, + ); +} + +/** default priority: 100 */ +export function after

= Record>( + name: SlotName, + Comp: React.FC

, + opts?: InterceptorOptions, +): () => void { + const label = `after:${Comp.displayName || Comp.name || '?'}`; + return intercept

( + name, + (props, next) => ( + <> + {next()} + + + + + ), + { priority: 100, ...opts }, + ); +} + +/** default priority: -50 */ +export function patch

( + name: SlotName, + patcher: (props: P) => Partial

, + opts?: InterceptorOptions, +): () => void { + return intercept

( + name, + (props, next) => next(patcher(props)), + { priority: -50, ...opts }, + ); +} + +/** default priority: 0 */ +export function replace

= Record>( + name: SlotName, + Replacement: React.FC

, + opts?: { id?: string, priority?: number }, +): () => void { + const label = `replace:${Replacement.displayName || Replacement.name || '?'}`; + return intercept

( + name, + (props, _next) => ( + + + + ), + { priority: 0, ...opts }, + ); +} + +/** default priority: -10 */ +export function wrap

= Record>( + name: SlotName, + Wrapper: React.FC<{ children: React.ReactNode } & P>, + opts?: InterceptorOptions, +): () => void { + const label = `wrap:${Wrapper.displayName || Wrapper.name || '?'}`; + return intercept

( + name, + (props, next) => ( + + {next()} + + ), + { priority: -10, ...opts }, + ); +} diff --git a/packages/ui-next/src/registry/plugin.ts b/packages/ui-next/src/registry/plugin.ts new file mode 100644 index 0000000000..3f0b585c18 --- /dev/null +++ b/packages/ui-next/src/registry/plugin.ts @@ -0,0 +1,25 @@ +import { after, before, intercept, patch, replace, wrap } from './interceptors'; + +export interface PluginAPI { + intercept: typeof intercept; + before: typeof before; + after: typeof after; + patch: typeof patch; + replace: typeof replace; + wrap: typeof wrap; +} + +export function createPluginAPI(): PluginAPI { + return { intercept, before, after, patch, replace, wrap }; +} + +export interface PluginDefinition { + name: string; + setup: (api: PluginAPI) => (() => void) | void; +} + +export function installPlugin(plugin: PluginDefinition): () => void { + const api = createPluginAPI(); + const cleanup = plugin.setup(api); + return () => cleanup?.(); +} diff --git a/packages/ui-next/src/registry/slot.tsx b/packages/ui-next/src/registry/slot.tsx new file mode 100644 index 0000000000..20cc6dbbb0 --- /dev/null +++ b/packages/ui-next/src/registry/slot.tsx @@ -0,0 +1,57 @@ +import { useMemo, useSyncExternalStore } from 'react'; +import { SlotErrorBoundary } from './error-boundary'; +import { store } from './store'; +import type { InterceptorEntry, SlotName } from './types'; + +function buildChain

>( + interceptors: InterceptorEntry

[], + DefaultComp: React.FC

, + slotName: SlotName, +): (props: P) => React.ReactNode { + let pipeline: (props: P) => React.ReactNode = (props) => ( + + + + ); + + for (let i = interceptors.length - 1; i >= 0; i--) { + const { interceptor, id } = interceptors[i]; + const downstream = pipeline; + + pipeline = (props: P) => ( + + {interceptor(props, (overrideProps) => + downstream(overrideProps ? { ...props, ...overrideProps } : props), + )} + + ); + } + + return pipeline; +} + +export function defineSlot

>( + name: SlotName, + DefaultComp: React.FC

, +): React.FC

{ + store.setDefault(name, DefaultComp); + + const subscribeSlot = (cb: () => void) => store.subscribe(name, cb); + const getSnapshot = () => store.getVersion(name); + + const SlotComponent: React.FC

= (props) => { + const version = useSyncExternalStore(subscribeSlot, getSnapshot); + + // eslint-disable-next-line react-hooks/exhaustive-deps + const chain = useMemo(() => buildChain(store.getInterceptors(name), store.getDefault(name)!, name), [version]); + + return ( + + {chain(props)} + + ); + }; + + SlotComponent.displayName = `Slot(${name})`; + return SlotComponent; +} diff --git a/packages/ui-next/src/registry/store.ts b/packages/ui-next/src/registry/store.ts new file mode 100644 index 0000000000..5838af9b75 --- /dev/null +++ b/packages/ui-next/src/registry/store.ts @@ -0,0 +1,94 @@ +import type { InterceptorEntry, InterceptorOptions, SlotName } from './types'; + +type Listener = () => void; + +interface RegistryState { + interceptors: Record; + defaults: Record>; +} + +function createRegistryStore() { + const state: RegistryState = { + interceptors: {}, + defaults: {}, + }; + + const slotListeners: Record> = {}; + const versions: Record = {}; + + let idCounter = 0; + const genId = () => `__slot_${++idCounter}`; + + function subscribe(name: SlotName, cb: Listener) { + const set = (slotListeners[name] ??= new Set()); + set.add(cb); + return () => set.delete(cb); + } + + function notify(name: SlotName) { + slotListeners[name]?.forEach((l) => l()); + } + + function getVersion(name: SlotName): number { + return versions[name] ?? 0; + } + + function bumpVersion(name: SlotName) { + versions[name] = (versions[name] ?? 0) + 1; + notify(name); + } + + function addInterceptor

( + name: SlotName, + interceptor: (props: P, next: (overrideProps?: Partial

) => React.ReactNode) => React.ReactNode, + opts?: InterceptorOptions, + ): () => void { + const entry: InterceptorEntry

= { + id: opts?.id ?? genId(), + priority: opts?.priority ?? 0, + interceptor, + }; + + const list = (state.interceptors[name] ??= []); + const idx = list.findIndex((e) => e.id === entry.id); + if (idx !== -1) list[idx] = entry; + else list.push(entry); + + list.sort((a, b) => a.priority - b.priority); + bumpVersion(name); + + return () => { + const arr = state.interceptors[name]; + if (!arr) return; + const i = arr.findIndex((e) => e.id === entry.id); + if (i !== -1) { + arr.splice(i, 1); + bumpVersion(name); + } + }; + } + + function getInterceptors(name: SlotName): InterceptorEntry[] { + return state.interceptors[name] ?? []; + } + + function setDefault(name: SlotName, comp: React.FC) { + state.defaults[name] = comp; + } + + function getDefault(name: SlotName): React.FC | undefined { + return state.defaults[name]; + } + + return { + subscribe, + getVersion, + addInterceptor, + getInterceptors, + setDefault, + getDefault, + }; +} + +export const store: ReturnType = import.meta.hot?.data?.slotStore ?? createRegistryStore(); +if (import.meta.hot) import.meta.hot.data.slotStore = store; diff --git a/packages/ui-next/src/registry/types.ts b/packages/ui-next/src/registry/types.ts new file mode 100644 index 0000000000..4913fff37a --- /dev/null +++ b/packages/ui-next/src/registry/types.ts @@ -0,0 +1,20 @@ +type Scope = 'page' | 'component' | 'layout' | string; +export type SlotName = `${Scope}:${string}`; + +export type Interceptor

= ( + props: P, + next: (overrideProps?: Partial

) => React.ReactNode, +) => React.ReactNode; + +export interface InterceptorOptions { + id?: string; + /** 越小越靠外,越先执行 */ + priority?: number; +} + +export interface InterceptorEntry

{ + id: string; + /** 越小越靠外,越先执行 */ + priority: number; + interceptor: Interceptor

; +} diff --git a/packages/ui-next/src/vite-env.d.ts b/packages/ui-next/src/vite-env.d.ts index 11f02fe2a0..4fc67de751 100644 --- a/packages/ui-next/src/vite-env.d.ts +++ b/packages/ui-next/src/vite-env.d.ts @@ -1 +1,7 @@ /// + +declare module 'virtual:hydro-plugins' { + import type { PluginDefinition } from './registry'; + const plugins: PluginDefinition[]; + export default plugins; +} diff --git a/packages/ui-next/vite.config.ts b/packages/ui-next/vite.config.ts new file mode 100644 index 0000000000..769d4e5222 --- /dev/null +++ b/packages/ui-next/vite.config.ts @@ -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' }, +});