|
@@ -1,89 +1,136 @@
|
|
|
import { defineConfig } from 'vite';
|
|
import { defineConfig } from 'vite';
|
|
|
import vue from '@vitejs/plugin-vue';
|
|
import vue from '@vitejs/plugin-vue';
|
|
|
|
|
+import fs from 'node:fs';
|
|
|
import path from 'node:path';
|
|
import path from 'node:path';
|
|
|
|
|
+import { DatabaseSync } from 'node:sqlite';
|
|
|
|
|
|
|
|
-// Output goes to web/dist/ at the repo root so the Go binary can embed it
|
|
|
|
|
-// via embed.FS without reaching outside the web/ tree.
|
|
|
|
|
const outDir = path.resolve(__dirname, '../web/dist');
|
|
const outDir = path.resolve(__dirname, '../web/dist');
|
|
|
|
|
+const BACKEND_TARGET = 'http://localhost:2053';
|
|
|
|
|
|
|
|
-// In production the Go binary serves /panel/<route> from web/dist/<route>.html.
|
|
|
|
|
-// In dev the Vue app lives at /index.html, /settings.html, ... while AppSidebar
|
|
|
|
|
-// links use the production-style /panel/<route> URLs. Map each migrated route
|
|
|
|
|
-// to its Vite entry so the sidebar works without relying on the Go backend
|
|
|
|
|
-// for already-ported pages.
|
|
|
|
|
-const MIGRATED_ROUTES = {
|
|
|
|
|
- '/panel': '/index.html',
|
|
|
|
|
- '/panel/': '/index.html',
|
|
|
|
|
- '/panel/settings': '/settings.html',
|
|
|
|
|
- '/panel/settings/': '/settings.html',
|
|
|
|
|
- '/panel/inbounds': '/inbounds.html',
|
|
|
|
|
- '/panel/inbounds/': '/inbounds.html',
|
|
|
|
|
- '/panel/xray': '/xray.html',
|
|
|
|
|
- '/panel/xray/': '/xray.html',
|
|
|
|
|
- '/panel/nodes': '/nodes.html',
|
|
|
|
|
- '/panel/nodes/': '/nodes.html',
|
|
|
|
|
|
|
+function resolveDBPath() {
|
|
|
|
|
+ const envFolder = process.env.XUI_DB_FOLDER;
|
|
|
|
|
+ if (envFolder) return path.join(envFolder, 'x-ui.db');
|
|
|
|
|
+ const repoDB = path.resolve(__dirname, '..', 'x-ui.db');
|
|
|
|
|
+ if (fs.existsSync(repoDB)) return repoDB;
|
|
|
|
|
+ return '/etc/x-ui/x-ui.db';
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const BASE_MIGRATED_ROUTES = {
|
|
|
|
|
+ 'panel': '/index.html',
|
|
|
|
|
+ 'panel/': '/index.html',
|
|
|
|
|
+ 'panel/settings': '/settings.html',
|
|
|
|
|
+ 'panel/settings/': '/settings.html',
|
|
|
|
|
+ 'panel/inbounds': '/inbounds.html',
|
|
|
|
|
+ 'panel/inbounds/': '/inbounds.html',
|
|
|
|
|
+ 'panel/xray': '/xray.html',
|
|
|
|
|
+ 'panel/xray/': '/xray.html',
|
|
|
|
|
+ 'panel/nodes': '/nodes.html',
|
|
|
|
|
+ 'panel/nodes/': '/nodes.html',
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
-// Build a proxy config that suppresses ECONNREFUSED noise when the Go
|
|
|
|
|
-// backend isn't running locally. Real errors (timeouts, 5xx, etc.) still
|
|
|
|
|
-// surface in the Vite log.
|
|
|
|
|
-function makeBackendProxy(target, patterns) {
|
|
|
|
|
- const config = {};
|
|
|
|
|
- for (const pattern of patterns) {
|
|
|
|
|
- config[pattern] = {
|
|
|
|
|
- target,
|
|
|
|
|
- changeOrigin: true,
|
|
|
|
|
- // Returning a path from bypass tells Vite to serve that file from
|
|
|
|
|
- // its own dev server instead of forwarding the request — used here
|
|
|
|
|
- // to short-circuit /panel/<route> for pages we've already migrated.
|
|
|
|
|
- //
|
|
|
|
|
- // Only GETs get bypassed: the xray page reuses its page URL
|
|
|
|
|
- // (`POST /panel/xray/`) for data, so a method-blind bypass would
|
|
|
|
|
- // hand HTML back to fetch calls and break the page in dev.
|
|
|
|
|
- bypass(req) {
|
|
|
|
|
- if (req.method !== 'GET') return undefined;
|
|
|
|
|
- const url = req.url.split('?')[0];
|
|
|
|
|
- if (Object.prototype.hasOwnProperty.call(MIGRATED_ROUTES, url)) {
|
|
|
|
|
- return MIGRATED_ROUTES[url];
|
|
|
|
|
- }
|
|
|
|
|
- return undefined;
|
|
|
|
|
- },
|
|
|
|
|
- configure(proxy) {
|
|
|
|
|
- let warned = false;
|
|
|
|
|
- proxy.on('error', (err, req) => {
|
|
|
|
|
- // Node wraps connection failures in an AggregateError when DNS
|
|
|
|
|
- // returns multiple addresses (e.g. ::1 + 127.0.0.1) and all
|
|
|
|
|
- // refuse — the code lands on the inner errors, not the outer.
|
|
|
|
|
- const codes = new Set();
|
|
|
|
|
- if (err && err.code) codes.add(err.code);
|
|
|
|
|
- if (err && Array.isArray(err.errors)) {
|
|
|
|
|
- for (const inner of err.errors) {
|
|
|
|
|
- if (inner && inner.code) codes.add(inner.code);
|
|
|
|
|
- }
|
|
|
|
|
|
|
+let cachedBasePath = '/';
|
|
|
|
|
+
|
|
|
|
|
+function readBasePathFromDB() {
|
|
|
|
|
+ const dbPath = resolveDBPath();
|
|
|
|
|
+ let db;
|
|
|
|
|
+ try {
|
|
|
|
|
+ db = new DatabaseSync(dbPath, { readOnly: true });
|
|
|
|
|
+ } catch (_e) {
|
|
|
|
|
+ return '/';
|
|
|
|
|
+ }
|
|
|
|
|
+ try {
|
|
|
|
|
+ const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('webBasePath');
|
|
|
|
|
+ let value = row && typeof row.value === 'string' ? row.value : '/';
|
|
|
|
|
+ if (!value.startsWith('/')) value = '/' + value;
|
|
|
|
|
+ if (!value.endsWith('/')) value += '/';
|
|
|
|
|
+ return value;
|
|
|
|
|
+ } catch (_e) {
|
|
|
|
|
+ return '/';
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ db.close();
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function refreshBasePath() {
|
|
|
|
|
+ cachedBasePath = readBasePathFromDB();
|
|
|
|
|
+ return cachedBasePath;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// `apply: 'serve'` keeps the injection out of `vite build` — dist.go
|
|
|
|
|
+// already injects __X_UI_BASE_PATH__ at runtime in production.
|
|
|
|
|
+function injectBasePathPlugin() {
|
|
|
|
|
+ return {
|
|
|
|
|
+ name: 'xui-inject-base-path',
|
|
|
|
|
+ apply: 'serve',
|
|
|
|
|
+ transformIndexHtml(html) {
|
|
|
|
|
+ const basePath = refreshBasePath();
|
|
|
|
|
+ const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
|
|
|
+ const tag = `<script>window.__X_UI_BASE_PATH__="${escaped}";</script>`;
|
|
|
|
|
+ return html.replace('</head>', `${tag}</head>`);
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function bypassMigratedRoute(req) {
|
|
|
|
|
+ if (req.method !== 'GET') return undefined;
|
|
|
|
|
+ const url = req.url.split('?')[0];
|
|
|
|
|
+
|
|
|
|
|
+ for (const [key, value] of Object.entries(BASE_MIGRATED_ROUTES)) {
|
|
|
|
|
+ if (url === '/' + key) return value;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const m = url.match(/^\/[^/]+\/(.+)$/);
|
|
|
|
|
+ if (m) {
|
|
|
|
|
+ const stripped = m[1];
|
|
|
|
|
+ if (stripped in BASE_MIGRATED_ROUTES) return BASE_MIGRATED_ROUTES[stripped];
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (url === '/' || /^\/[^/]+\/$/.test(url)) return '/login.html';
|
|
|
|
|
+
|
|
|
|
|
+ return undefined;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function rewriteToBackend(p) {
|
|
|
|
|
+ if (cachedBasePath === '/' || p.startsWith(cachedBasePath)) return p;
|
|
|
|
|
+ return cachedBasePath + p.replace(/^\//, '');
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function makeBackendProxy(target) {
|
|
|
|
|
+ return {
|
|
|
|
|
+ target,
|
|
|
|
|
+ changeOrigin: true,
|
|
|
|
|
+ rewrite: rewriteToBackend,
|
|
|
|
|
+ bypass: bypassMigratedRoute,
|
|
|
|
|
+ configure(proxy) {
|
|
|
|
|
+ let warned = false;
|
|
|
|
|
+ proxy.on('error', (err, req) => {
|
|
|
|
|
+ const codes = new Set();
|
|
|
|
|
+ if (err && err.code) codes.add(err.code);
|
|
|
|
|
+ if (err && Array.isArray(err.errors)) {
|
|
|
|
|
+ for (const inner of err.errors) {
|
|
|
|
|
+ if (inner && inner.code) codes.add(inner.code);
|
|
|
}
|
|
}
|
|
|
- const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
|
|
|
|
|
- if (offline) {
|
|
|
|
|
- // Print a single friendly hint the first time, then stay quiet.
|
|
|
|
|
- if (!warned) {
|
|
|
|
|
- warned = true;
|
|
|
|
|
- // eslint-disable-next-line no-console
|
|
|
|
|
- console.warn(
|
|
|
|
|
- `[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
- return;
|
|
|
|
|
|
|
+ }
|
|
|
|
|
+ const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
|
|
|
|
|
+ if (offline) {
|
|
|
|
|
+ if (!warned) {
|
|
|
|
|
+ warned = true;
|
|
|
|
|
+ // eslint-disable-next-line no-console
|
|
|
|
|
+ console.warn(
|
|
|
|
|
+ `[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
|
|
|
|
|
+ );
|
|
|
}
|
|
}
|
|
|
- // eslint-disable-next-line no-console
|
|
|
|
|
- console.error('[proxy]', err);
|
|
|
|
|
- });
|
|
|
|
|
- },
|
|
|
|
|
- };
|
|
|
|
|
- }
|
|
|
|
|
- return config;
|
|
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ // eslint-disable-next-line no-console
|
|
|
|
|
+ console.error('[proxy]', err);
|
|
|
|
|
+ });
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export default defineConfig({
|
|
export default defineConfig({
|
|
|
- plugins: [vue()],
|
|
|
|
|
|
|
+ plugins: [vue(), injectBasePathPlugin()],
|
|
|
resolve: {
|
|
resolve: {
|
|
|
alias: {
|
|
alias: {
|
|
|
'@': path.resolve(__dirname, 'src'),
|
|
'@': path.resolve(__dirname, 'src'),
|
|
@@ -94,14 +141,7 @@ export default defineConfig({
|
|
|
emptyOutDir: true,
|
|
emptyOutDir: true,
|
|
|
sourcemap: true,
|
|
sourcemap: true,
|
|
|
target: 'es2020',
|
|
target: 'es2020',
|
|
|
- // ant-design-vue is intentionally bundled as one chunk (its
|
|
|
|
|
- // components share internals — splitting it breaks Modal/Form/
|
|
|
|
|
- // Select interop). Minified it lands ~1.4MB but gzips to ~410kB,
|
|
|
|
|
- // so the actual transfer is fine and caches across every page.
|
|
|
|
|
- // Bump the warning past that ceiling so the build stays quiet.
|
|
|
|
|
chunkSizeWarningLimit: 1500,
|
|
chunkSizeWarningLimit: 1500,
|
|
|
- // Multiple HTML entries — one per legacy page we migrate.
|
|
|
|
|
- // As pages get ported in later phases, add their entrypoints here.
|
|
|
|
|
rollupOptions: {
|
|
rollupOptions: {
|
|
|
input: {
|
|
input: {
|
|
|
index: path.resolve(__dirname, 'index.html'),
|
|
index: path.resolve(__dirname, 'index.html'),
|
|
@@ -113,10 +153,6 @@ export default defineConfig({
|
|
|
subpage: path.resolve(__dirname, 'subpage.html'),
|
|
subpage: path.resolve(__dirname, 'subpage.html'),
|
|
|
},
|
|
},
|
|
|
output: {
|
|
output: {
|
|
|
- // Split vendor deps into stable chunks so each page only pulls
|
|
|
|
|
- // what it needs and the browser caches them across versions.
|
|
|
|
|
- // Without this, ant-design-vue + vue + icons all end up in one
|
|
|
|
|
- // 1.6MB blob attached to whichever page consumed them first.
|
|
|
|
|
manualChunks(id) {
|
|
manualChunks(id) {
|
|
|
if (!id.includes('node_modules')) return undefined;
|
|
if (!id.includes('node_modules')) return undefined;
|
|
|
if (id.includes('ant-design-vue')) return 'vendor-antd';
|
|
if (id.includes('ant-design-vue')) return 'vendor-antd';
|
|
@@ -129,8 +165,6 @@ export default defineConfig({
|
|
|
if (id.includes('dayjs')) return 'vendor-dayjs';
|
|
if (id.includes('dayjs')) return 'vendor-dayjs';
|
|
|
if (id.includes('qrious')) return 'vendor-qrious';
|
|
if (id.includes('qrious')) return 'vendor-qrious';
|
|
|
if (id.includes('axios')) return 'vendor-axios';
|
|
if (id.includes('axios')) return 'vendor-axios';
|
|
|
- // The persian datepicker pulls in moment + moment-jalaali; bundle
|
|
|
|
|
- // the trio together so unrelated pages don't pay the cost.
|
|
|
|
|
if (
|
|
if (
|
|
|
id.includes('vue3-persian-datetime-picker')
|
|
id.includes('vue3-persian-datetime-picker')
|
|
|
|| id.includes('moment-jalaali')
|
|
|| id.includes('moment-jalaali')
|
|
@@ -146,21 +180,14 @@ export default defineConfig({
|
|
|
port: 5173,
|
|
port: 5173,
|
|
|
strictPort: true,
|
|
strictPort: true,
|
|
|
proxy: {
|
|
proxy: {
|
|
|
- ...makeBackendProxy('http://localhost:2053', [
|
|
|
|
|
- // Patterns are anchored regex so /login.html and /index.html
|
|
|
|
|
- // (which Vite serves itself) are NOT forwarded — only the bare
|
|
|
|
|
- // backend paths and their sub-routes.
|
|
|
|
|
- '^/(login|logout|getTwoFactorEnable|csrf-token)$',
|
|
|
|
|
- '^/(panel|server)(/|$)',
|
|
|
|
|
- ]),
|
|
|
|
|
- // The panel mounts the live-update WebSocket at /ws (basePath +
|
|
|
|
|
- // "/ws"). Vite needs `ws: true` to forward the HTTP Upgrade to the
|
|
|
|
|
- // Go backend; without it the dev server would 404 the upgrade and
|
|
|
|
|
- // the page falls back to the no-data state.
|
|
|
|
|
- '/ws': {
|
|
|
|
|
|
|
+ '^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
|
|
|
|
|
+ '^/$': makeBackendProxy(BACKEND_TARGET),
|
|
|
|
|
+ '^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
|
|
|
|
|
+ '^/(?:[^/]+/)?ws$': {
|
|
|
target: 'ws://localhost:2053',
|
|
target: 'ws://localhost:2053',
|
|
|
ws: true,
|
|
ws: true,
|
|
|
changeOrigin: true,
|
|
changeOrigin: true,
|
|
|
|
|
+ rewrite: rewriteToBackend,
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|
|
|
},
|
|
},
|