vite.config.js 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. import { defineConfig } from 'vite';
  2. import react from '@vitejs/plugin-react';
  3. import fs from 'node:fs';
  4. import path from 'node:path';
  5. import { DatabaseSync } from 'node:sqlite';
  6. const outDir = path.resolve(import.meta.dirname, '../internal/web/dist');
  7. const BACKEND_TARGET = 'http://localhost:2053';
  8. function resolveDBPath() {
  9. const envFolder = process.env.XUI_DB_FOLDER;
  10. if (envFolder) {
  11. const abs = path.isAbsolute(envFolder)
  12. ? envFolder
  13. : path.resolve(import.meta.dirname, '..', envFolder);
  14. return path.join(abs, 'x-ui.db');
  15. }
  16. const repoSubDB = path.resolve(import.meta.dirname, '..', 'x-ui', 'x-ui.db');
  17. if (fs.existsSync(repoSubDB)) return repoSubDB;
  18. const repoDB = path.resolve(import.meta.dirname, '..', 'x-ui.db');
  19. if (fs.existsSync(repoDB)) return repoDB;
  20. return '/etc/x-ui/x-ui.db';
  21. }
  22. const PANEL_API_PREFIXES = ['panel/api/', 'panel/csrf-token'];
  23. let cachedBasePath = '/';
  24. function readBasePathFromDB() {
  25. const dbPath = resolveDBPath();
  26. let db;
  27. try {
  28. db = new DatabaseSync(dbPath, { readOnly: true });
  29. } catch (_e) {
  30. return '/';
  31. }
  32. try {
  33. const row = db.prepare('SELECT value FROM settings WHERE key = ?').get('webBasePath');
  34. let value = row && typeof row.value === 'string' ? row.value : '/';
  35. if (!value.startsWith('/')) value = '/' + value;
  36. if (!value.endsWith('/')) value += '/';
  37. return value;
  38. } catch (_e) {
  39. return '/';
  40. } finally {
  41. db.close();
  42. }
  43. }
  44. function refreshBasePath() {
  45. cachedBasePath = readBasePathFromDB();
  46. return cachedBasePath;
  47. }
  48. function readPanelVersion() {
  49. try {
  50. const versionFile = path.resolve(import.meta.dirname, '..', 'config', 'version');
  51. return fs.readFileSync(versionFile, 'utf8').trim();
  52. } catch (_e) {
  53. return '';
  54. }
  55. }
  56. // `apply: 'serve'` keeps the injection out of `vite build` — dist.go
  57. // already injects webBasePath and version at runtime in production.
  58. function injectBasePathPlugin() {
  59. return {
  60. name: 'xui-inject-base-path',
  61. apply: 'serve',
  62. transformIndexHtml(html) {
  63. const basePath = refreshBasePath();
  64. const escaped = basePath.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  65. const version = readPanelVersion().replace(/\\/g, '\\\\').replace(/"/g, '\\"');
  66. const tag = `<script>window.X_UI_BASE_PATH="${escaped}";window.X_UI_CUR_VER="${version}";</script>`;
  67. return html.replace('</head>', `${tag}</head>`);
  68. },
  69. };
  70. }
  71. // Cloudflare Rocket Loader rewrites script tags and runs bundles through its
  72. // own loader, breaking ES-module semantics; data-cfasync="false" opts out.
  73. function rocketLoaderOptOutPlugin() {
  74. return {
  75. name: 'xui-rocket-loader-opt-out',
  76. apply: 'build',
  77. transformIndexHtml(html) {
  78. return html.replaceAll('<script ', '<script data-cfasync="false" ');
  79. },
  80. };
  81. }
  82. function bypassMigratedRoute(req) {
  83. if (req.method !== 'GET') return undefined;
  84. const url = req.url.split('?')[0];
  85. const basePath = refreshBasePath();
  86. if (url === basePath) return '/login.html';
  87. if (url.startsWith(basePath)) {
  88. const stripped = url.slice(basePath.length);
  89. for (const prefix of PANEL_API_PREFIXES) {
  90. if (prefix.endsWith('/')) {
  91. if (stripped.startsWith(prefix)) return undefined;
  92. } else if (stripped === prefix || stripped.startsWith(prefix + '/')) {
  93. return undefined;
  94. }
  95. }
  96. if (stripped === 'panel' || stripped === 'panel/' || stripped.startsWith('panel/')) {
  97. return '/index.html';
  98. }
  99. }
  100. return undefined;
  101. }
  102. function rewriteToBackend(p) {
  103. if (cachedBasePath === '/' || p.startsWith(cachedBasePath)) return p;
  104. return cachedBasePath + p.replace(/^\//, '');
  105. }
  106. function makeBackendProxy(target) {
  107. return {
  108. target,
  109. changeOrigin: true,
  110. rewrite: rewriteToBackend,
  111. bypass: bypassMigratedRoute,
  112. configure(proxy) {
  113. let warned = false;
  114. proxy.on('error', (err, req) => {
  115. const codes = new Set();
  116. if (err && err.code) codes.add(err.code);
  117. if (err && Array.isArray(err.errors)) {
  118. for (const inner of err.errors) {
  119. if (inner && inner.code) codes.add(inner.code);
  120. }
  121. }
  122. const offline = codes.has('ECONNREFUSED') || codes.has('ECONNRESET');
  123. if (offline) {
  124. if (!warned) {
  125. warned = true;
  126. // eslint-disable-next-line no-console
  127. console.warn(
  128. `[proxy] backend ${target} is not reachable — start the Go server (e.g. \`go run main.go\`) to forward ${req?.url || 'requests'}.`,
  129. );
  130. }
  131. return;
  132. }
  133. // eslint-disable-next-line no-console
  134. console.error('[proxy]', err);
  135. });
  136. },
  137. };
  138. }
  139. // Deps only reachable through swagger-ui-react (verified via `npm ls`). The
  140. // catch-all `vendor` chunk would otherwise eager-load them on first paint,
  141. // although the api-docs page is the only lazy route importing them.
  142. const SWAGGER_ONLY_DEPS = [
  143. '@babel/runtime-corejs3',
  144. '@scarf/scarf',
  145. '@swagger-api/apidom-',
  146. '@swaggerexpert/',
  147. 'base64-js',
  148. 'buffer',
  149. 'classnames',
  150. 'css.escape',
  151. 'deep-extend',
  152. 'dompurify',
  153. 'fast-json-patch',
  154. 'highlight.js',
  155. 'highlightjs-vue',
  156. 'ieee754',
  157. 'immutable',
  158. 'js-file-download',
  159. 'js-yaml',
  160. 'lodash',
  161. 'lowlight',
  162. 'neotraverse',
  163. 'node-abort-controller',
  164. 'openapi-path-templating',
  165. 'openapi-server-url-templating',
  166. 'prismjs',
  167. 'prop-types',
  168. 'ramda',
  169. 'ramda-adjunct',
  170. 'randexp',
  171. 'react-copy-to-clipboard',
  172. 'react-debounce-input',
  173. 'react-immutable-proptypes',
  174. 'react-immutable-pure-component',
  175. 'react-inspector',
  176. 'react-redux',
  177. 'react-syntax-highlighter',
  178. 'redux',
  179. 'redux-immutable',
  180. 'remarkable',
  181. 'reselect',
  182. 'serialize-error',
  183. 'sha.js',
  184. 'url-parse',
  185. 'xml',
  186. 'xml-but-prettier',
  187. 'zenscroll',
  188. ];
  189. export default defineConfig({
  190. plugins: [react(), injectBasePathPlugin(), rocketLoaderOptOutPlugin()],
  191. resolve: {
  192. alias: {
  193. '@': path.resolve(import.meta.dirname, 'src'),
  194. },
  195. },
  196. experimental: {
  197. renderBuiltUrl(filename, { hostType }) {
  198. if (hostType === 'js') {
  199. return {
  200. runtime: `((window.X_UI_BASE_PATH||'/')+${JSON.stringify(filename)})`,
  201. };
  202. }
  203. return undefined;
  204. },
  205. },
  206. build: {
  207. outDir,
  208. emptyOutDir: true,
  209. // Everything in outDir is embedded into the Go binary via embed.FS, so
  210. // production sourcemaps (~18MB across 112 files, 72% of dist) ship inside
  211. // every release build. Nothing consumes them there; `npm run dev` serves
  212. // its own maps regardless of this setting. To debug a minified bundle
  213. // (including the XUI_DEBUG serve-from-disk path), build once with
  214. // XUI_SOURCEMAP=true — no tracked-file edit to accidentally commit.
  215. sourcemap: process.env.XUI_SOURCEMAP === 'true',
  216. target: 'es2020',
  217. chunkSizeWarningLimit: 1500,
  218. rollupOptions: {
  219. input: {
  220. index: path.resolve(import.meta.dirname, 'index.html'),
  221. login: path.resolve(import.meta.dirname, 'login.html'),
  222. subpage: path.resolve(import.meta.dirname, 'subpage.html'),
  223. },
  224. output: {
  225. manualChunks(id) {
  226. if (!id.includes('node_modules')) return undefined;
  227. if (id.includes('/node_modules/antd/')) return 'vendor-antd';
  228. if (id.includes('/@ant-design/icons/') || id.includes('/@ant-design/icons-svg/')) return 'vendor-icons';
  229. if (
  230. id.includes('/node_modules/@rc-component/')
  231. || id.includes('/node_modules/rc-')
  232. || id.includes('/@ant-design/cssinjs')
  233. || id.includes('/@ant-design/colors')
  234. || id.includes('/@ant-design/fast-color')
  235. || id.includes('/@ant-design/react-slick')
  236. || id.includes('/@ctrl/tinycolor')
  237. ) return 'vendor-antd';
  238. if (
  239. id.includes('/node_modules/react-i18next/')
  240. || id.includes('/node_modules/i18next/')
  241. ) return 'vendor-i18next';
  242. if (
  243. id.includes('/node_modules/react/')
  244. || id.includes('/node_modules/react-dom/')
  245. || id.includes('/node_modules/scheduler/')
  246. ) return 'vendor-react';
  247. if (
  248. id.includes('/node_modules/codemirror/')
  249. || id.includes('/node_modules/@codemirror/')
  250. || id.includes('/node_modules/@lezer/')
  251. ) return 'vendor-codemirror';
  252. if (id.includes('/node_modules/persian-calendar-suite/')) return 'vendor-jalali';
  253. if (id.includes('/node_modules/otpauth/')) return 'vendor-otpauth';
  254. if (id.includes('/node_modules/@tanstack/')) return 'vendor-tanstack';
  255. if (id.includes('/node_modules/react-router')) return 'vendor-router';
  256. if (
  257. id.includes('/node_modules/swagger-ui-react/')
  258. || id.includes('/node_modules/swagger-ui/')
  259. || id.includes('/node_modules/swagger-client/')
  260. || SWAGGER_ONLY_DEPS.some((dep) => id.includes(`/node_modules/${dep}/`))
  261. ) return 'vendor-swagger';
  262. if (id.includes('/node_modules/uplot/')) return 'vendor-uplot';
  263. if (id.includes('dayjs')) return 'vendor-dayjs';
  264. return 'vendor';
  265. },
  266. },
  267. },
  268. },
  269. server: {
  270. port: 5173,
  271. strictPort: true,
  272. proxy: {
  273. '^/(?:[^/]+/)?(login|logout|getTwoFactorEnable|csrf-token|panel|server)(?:/|$)': makeBackendProxy(BACKEND_TARGET),
  274. '^/$': makeBackendProxy(BACKEND_TARGET),
  275. '^/[^/]+/$': makeBackendProxy(BACKEND_TARGET),
  276. '^/(?:[^/]+/)?ws$': {
  277. target: 'ws://localhost:2053',
  278. ws: true,
  279. changeOrigin: true,
  280. rewrite: rewriteToBackend,
  281. },
  282. },
  283. },
  284. });