mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
4a11ed9887
* ✨ feat(oidc): add interaction details endpoint * ✨ feat(auth-spa): scaffold standalone auth SPA shell and build pipeline * 🐛 fix(auth-spa): address review findings in AuthShell copies * ✨ feat(auth-spa): add spa-auth html route handler * ♻️ refactor(auth-spa): migrate simple auth pages into auth SPA * 🔒 fix(auth-spa): validate locale segment in spa-auth route * ♻️ refactor(auth-spa): move verify-im route to main SPA * 🔒 fix(auth-spa): sanitize callbackUrl, fix signup form wiring, add router error element * ♻️ refactor(auth-spa): migrate oauth pages into auth SPA * 🐛 fix(auth-spa): address oauth migration review findings * ♻️ refactor(auth): route auth pages to standalone SPA and drop Next auth tree * 🔒 fix(auth): validate locale before middleware rewrite * 🔥 chore(auth-spa): drop unused messenger i18n namespace from auth shell * ⚡️ perf(build): share one react vendor bundle across web/mobile/auth SPA builds Build react core (react, react-dom, react-dom/client, react/jsx-runtime) once as a self-contained ESM bundle under /_spa/vendor-shared, then mark those specifiers external in every SPA build and map them via rolldown output.paths to the same hashed URLs, so the auth page warms the main app's react cache. react-router-dom stays per-build: apps use ~19K of it after tree shaking while a shared bundle must export all 252K. Also split auth i18n namespaces into per-locale chunks, keep locale runtime helpers out of the default locale chunk, and group packages/const into app-const so vendor-ai-runtime no longer captures it. * ♻️ refactor(spa): extract shared SPA html serving helpers Both the main SPA and auth SPA route handlers duplicated the Vite dev asset rewriting, analytics config assembly and html template rendering. Move them into src/server/spaHtml.ts; the desktop umami block becomes an opt-in flag only the main SPA enables. * 🐛 fix(auth-spa): bundle default locale resources and disable i18n suspense to fix signin mount loop * ✨ feat(auth-spa): wrap auth shell with BusinessAuthProvider slot * 👷 build(spa): support custom vite dev origin and mark SPA entries side-effectful * 🔥 chore: drop dead /welcome entry from nextjsOnlyRoutes * 🐛 fix(auth-spa): forward referral to signup and fix error boundary dark-mode contrast * ♻️ refactor(spa): lift NextThemeProvider above RouterProvider so route error boundaries are theme-aware * update
364 lines
12 KiB
TypeScript
364 lines
12 KiB
TypeScript
import { spawn } from 'node:child_process';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
|
|
import { DevTools } from '@vitejs/devtools';
|
|
import type { PluginOption, ViteDevServer } from 'vite';
|
|
import { defineConfig, loadEnv } from 'vite';
|
|
import { VitePWA } from 'vite-plugin-pwa';
|
|
|
|
import { viteEnvRestartKeys } from './plugins/vite/envRestartKeys';
|
|
import {
|
|
createSharedRolldownOutput,
|
|
sharedModulePreload,
|
|
sharedOptimizeDeps,
|
|
sharedRendererDefine,
|
|
sharedRendererPlugins,
|
|
} from './plugins/vite/sharedRendererConfig';
|
|
import { vercelSkewProtection } from './plugins/vite/vercelSkewProtection';
|
|
|
|
const isMobile = process.env.MOBILE === 'true';
|
|
const isAuth = process.env.AUTH === 'true';
|
|
const mode = process.env.NODE_ENV === 'production' ? 'production' : 'development';
|
|
|
|
Object.assign(process.env, loadEnv(mode, process.cwd(), ''));
|
|
|
|
const isDev = process.env.NODE_ENV !== 'production';
|
|
const platform = isAuth ? 'auth' : isMobile ? 'mobile' : 'web';
|
|
const enableViteDevTools = process.env.LOBE_VITE_DEVTOOLS === 'true';
|
|
|
|
const resolveCommandExecutable = (cmd: string) => {
|
|
const pathValue = process.env.PATH;
|
|
if (!pathValue) return;
|
|
|
|
if (process.platform === 'win32') {
|
|
const pathExt = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD')
|
|
.split(';')
|
|
.filter(Boolean)
|
|
.map((ext) => ext.toLowerCase());
|
|
const candidateNames = cmd.includes('.') ? [cmd] : pathExt.map((ext) => `${cmd}${ext}`);
|
|
|
|
for (const entry of pathValue.split(path.delimiter).filter(Boolean)) {
|
|
for (const candidate of candidateNames) {
|
|
const resolved = path.win32.join(entry, candidate);
|
|
if (fs.existsSync(resolved)) return resolved;
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
for (const entry of pathValue.split(path.delimiter).filter(Boolean)) {
|
|
const resolved = path.join(entry, cmd);
|
|
if (fs.existsSync(resolved)) return resolved;
|
|
}
|
|
};
|
|
|
|
const openExternalBrowser = async (
|
|
url: string,
|
|
logger?: { warn: (msg: string) => void },
|
|
): Promise<boolean> => {
|
|
const command =
|
|
process.platform === 'win32'
|
|
? {
|
|
args: ['url.dll,FileProtocolHandler', url],
|
|
cmd: 'rundll32',
|
|
}
|
|
: {
|
|
args: [url],
|
|
cmd: process.platform === 'darwin' ? 'open' : 'xdg-open',
|
|
};
|
|
|
|
const executable = resolveCommandExecutable(command.cmd);
|
|
if (!executable) {
|
|
logger?.warn(`openExternalBrowser: ${command.cmd} not found on PATH`);
|
|
return false;
|
|
}
|
|
|
|
return new Promise<boolean>((resolve) => {
|
|
try {
|
|
const child = spawn(executable, command.args, {
|
|
detached: true,
|
|
stdio: 'ignore',
|
|
});
|
|
let settled = false;
|
|
const done = (ok: boolean, reason?: string) => {
|
|
if (settled) return;
|
|
settled = true;
|
|
if (!ok && reason) logger?.warn(`openExternalBrowser: ${reason}`);
|
|
resolve(ok);
|
|
};
|
|
child.once('error', (err) => done(false, (err as Error).message));
|
|
child.once('spawn', () => {
|
|
child.unref();
|
|
done(true);
|
|
});
|
|
setTimeout(() => done(true), 200);
|
|
} catch (e) {
|
|
logger?.warn(`openExternalBrowser: ${(e as Error).message}`);
|
|
resolve(false);
|
|
}
|
|
});
|
|
};
|
|
|
|
export default defineConfig({
|
|
base: isDev ? '/' : process.env.VITE_CDN_BASE || (isAuth ? '/_spa-auth/' : '/_spa/'),
|
|
build: {
|
|
modulePreload: sharedModulePreload,
|
|
outDir: isAuth ? 'dist/auth' : isMobile ? 'dist/mobile' : 'dist/desktop',
|
|
reportCompressedSize: false,
|
|
rolldownOptions: {
|
|
...(enableViteDevTools && { devtools: {} }),
|
|
input: path.resolve(
|
|
__dirname,
|
|
isAuth ? 'index.auth.html' : isMobile ? 'index.mobile.html' : 'index.html',
|
|
),
|
|
output: createSharedRolldownOutput({ strictExecutionOrder: true }),
|
|
},
|
|
},
|
|
define: sharedRendererDefine({ isMobile, isElectron: false }),
|
|
experimental: {
|
|
bundledDev: false,
|
|
},
|
|
resolve: {
|
|
tsconfigPaths: true,
|
|
},
|
|
optimizeDeps: sharedOptimizeDeps,
|
|
plugins: [
|
|
vercelSkewProtection(),
|
|
viteEnvRestartKeys(['APP_URL']),
|
|
enableViteDevTools &&
|
|
DevTools({
|
|
build: {
|
|
withApp: true,
|
|
},
|
|
}),
|
|
...sharedRendererPlugins({ platform }),
|
|
|
|
isDev && {
|
|
name: 'lobe-dev-proxy-print',
|
|
configureServer(server: ViteDevServer) {
|
|
const ONLINE_HOST = 'https://app.lobehub.com';
|
|
const c = {
|
|
green: (s: string) => `\x1B[32m${s}\x1B[0m`,
|
|
bold: (s: string) => `\x1B[1m${s}\x1B[0m`,
|
|
cyan: (s: string) => `\x1B[36m${s}\x1B[0m`,
|
|
};
|
|
const { info } = server.config.logger;
|
|
const isBundledDev = (server.config.experimental as any)?.bundledDev;
|
|
|
|
const getProxyUrl = () => {
|
|
const urls = server.resolvedUrls;
|
|
if (!urls?.local?.[0]) return;
|
|
const localHost = urls.local[0].replace(/\/$/, '');
|
|
return `${ONLINE_HOST}/_dangerous_local_dev_proxy?debug-host=${encodeURIComponent(localHost)}`;
|
|
};
|
|
const printProxyUrl = () => {
|
|
const proxyUrl = getProxyUrl();
|
|
if (!proxyUrl) return;
|
|
const colorUrl = (url: string) =>
|
|
c.cyan(url.replace(/:(\d+)\//, (_, port) => `:${c.bold(port)}/`));
|
|
info(` ${c.green('➜')} ${c.bold('Debug Proxy')}: ${colorUrl(proxyUrl)}`);
|
|
};
|
|
const openProxyUrl = async () => {
|
|
const proxyUrl = getProxyUrl();
|
|
if (!proxyUrl) return;
|
|
|
|
const opened = await openExternalBrowser(proxyUrl, server.config.logger);
|
|
|
|
if (!opened) {
|
|
server.config.logger.warn(`Failed to open Debug Proxy automatically: ${proxyUrl}`);
|
|
}
|
|
};
|
|
|
|
if (isBundledDev) {
|
|
// Disable Vite's built-in browser opening. We always open the debug
|
|
// proxy URL after the first bundled compile finishes instead.
|
|
server.openBrowser = () => {};
|
|
|
|
const spinnerFrames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
let spinnerIdx = 0;
|
|
let spinnerTimer: NodeJS.Timeout | null = null;
|
|
const formatElapsed = (ms: number) =>
|
|
ms < 1000 ? `${Math.max(0, Math.round(ms))}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
|
|
const startSpinner = (msg: string, since: number) => {
|
|
spinnerIdx = 0;
|
|
spinnerTimer = setInterval(() => {
|
|
const elapsed = formatElapsed(Date.now() - since);
|
|
process.stdout.write(`\r${c.cyan(spinnerFrames[spinnerIdx])} ${msg} (${elapsed})`);
|
|
spinnerIdx = (spinnerIdx + 1) % spinnerFrames.length;
|
|
}, 80);
|
|
};
|
|
const stopSpinner = (clearLine = true) => {
|
|
if (spinnerTimer) {
|
|
clearInterval(spinnerTimer);
|
|
spinnerTimer = null;
|
|
}
|
|
if (clearLine) process.stdout.write('\r\x1B[K');
|
|
};
|
|
|
|
server.httpServer?.once('listening', () => {
|
|
void (async () => {
|
|
const rootUrl =
|
|
server.resolvedUrls?.local?.[0] ||
|
|
`http://localhost:${String(server.config.server.port || 9876)}/`;
|
|
const startedAt = Date.now();
|
|
const timeout = 180_000;
|
|
const interval = 400;
|
|
let ready = false;
|
|
|
|
startSpinner('Vite: compile and bundle...', startedAt);
|
|
|
|
try {
|
|
while (Date.now() - startedAt < timeout) {
|
|
try {
|
|
const res = await fetch(rootUrl, { signal: AbortSignal.timeout(5_000) });
|
|
const text = await res.text();
|
|
if (text.includes('Bundling in progress')) {
|
|
await new Promise((r) => setTimeout(r, interval));
|
|
continue;
|
|
}
|
|
ready = true;
|
|
stopSpinner();
|
|
info(
|
|
` ${c.green('✅')} Vite: compile and bundle finished (${res.status}) ${rootUrl}`,
|
|
);
|
|
void openProxyUrl();
|
|
break;
|
|
} catch {
|
|
await new Promise((r) => setTimeout(r, interval));
|
|
}
|
|
}
|
|
} catch (e) {
|
|
stopSpinner();
|
|
console.warn('⚠️ Vite: could not wait for compile and bundle:', e);
|
|
}
|
|
|
|
if (!ready && spinnerTimer) {
|
|
stopSpinner();
|
|
console.warn(`⚠️ Vite: compile and bundle timed out after ${timeout / 1000}s`);
|
|
}
|
|
|
|
printProxyUrl();
|
|
})();
|
|
});
|
|
}
|
|
|
|
return () => {
|
|
server.printUrls = () => {
|
|
if (isBundledDev) return;
|
|
printProxyUrl();
|
|
};
|
|
};
|
|
},
|
|
},
|
|
|
|
!isAuth &&
|
|
VitePWA({
|
|
injectRegister: null,
|
|
manifest: false,
|
|
registerType: 'prompt',
|
|
workbox: {
|
|
globPatterns: ['**/*.{js,css,html,woff2}'],
|
|
maximumFileSizeToCacheInBytes: 10 * 1024 * 1024,
|
|
runtimeCaching: [
|
|
{
|
|
handler: 'StaleWhileRevalidate',
|
|
options: { cacheName: 'google-fonts-stylesheets' },
|
|
urlPattern: /^https:\/\/fonts\.googleapis\.com\/.*/i,
|
|
},
|
|
{
|
|
handler: 'CacheFirst',
|
|
options: {
|
|
cacheName: 'google-fonts-webfonts',
|
|
expiration: { maxAgeSeconds: 60 * 60 * 24 * 365, maxEntries: 30 },
|
|
},
|
|
urlPattern: /^https:\/\/fonts\.gstatic\.com\/.*/i,
|
|
},
|
|
{
|
|
handler: 'StaleWhileRevalidate',
|
|
options: {
|
|
cacheName: 'image-assets',
|
|
expiration: { maxAgeSeconds: 60 * 60 * 24 * 30, maxEntries: 100 },
|
|
},
|
|
urlPattern: /\.(?:png|jpg|jpeg|svg|gif|webp|ico|avif)$/i,
|
|
},
|
|
{
|
|
handler: 'NetworkFirst',
|
|
options: {
|
|
cacheName: 'api-cache',
|
|
expiration: { maxAgeSeconds: 60 * 5, maxEntries: 50 },
|
|
},
|
|
urlPattern: /\/(api|trpc)\/.*/i,
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
].filter(Boolean) as PluginOption[],
|
|
|
|
server: {
|
|
cors: true,
|
|
host: true,
|
|
port: 9876,
|
|
proxy: {
|
|
'/api': `http://localhost:${process.env.PORT || 3010}`,
|
|
'/oidc': `http://localhost:${process.env.PORT || 3010}`,
|
|
'/trpc': `http://localhost:${process.env.PORT || 3010}`,
|
|
'/webapi': `http://localhost:${process.env.PORT || 3010}`,
|
|
},
|
|
warmup: {
|
|
clientFiles: [
|
|
// src/ business code
|
|
'./src/initialize.ts',
|
|
'./src/spa/**/*.tsx',
|
|
'./src/business/**/*.{ts,tsx}',
|
|
'./src/components/**/*.{ts,tsx}',
|
|
'./src/const/**/*.ts',
|
|
'./src/features/**/*.{ts,tsx}',
|
|
'./src/helpers/**/*.ts',
|
|
'./src/hooks/**/*.{ts,tsx}',
|
|
'./src/layout/**/*.{ts,tsx}',
|
|
'./src/libs/**/*.{ts,tsx}',
|
|
'./src/routes/**/*.{ts,tsx}',
|
|
'./src/services/**/*.ts',
|
|
'./src/store/**/*.{ts,tsx}',
|
|
'./src/styles/**/*.ts',
|
|
'./src/utils/**/*.{ts,tsx}',
|
|
|
|
// monorepo packages
|
|
'./packages/types/src/**/*.ts',
|
|
'./packages/const/src/**/*.ts',
|
|
'./packages/utils/src/**/*.ts',
|
|
'./packages/context-engine/src/**/*.ts',
|
|
'./packages/prompts/src/**/*.ts',
|
|
'./packages/model-bank/src/**/*.ts',
|
|
'./packages/model-runtime/src/**/*.ts',
|
|
'./packages/agent-runtime/src/**/*.ts',
|
|
'./packages/conversation-flow/src/**/*.ts',
|
|
'./packages/electron-client-ipc/src/**/*.ts',
|
|
'./packages/builtin-agents/src/**/*.ts',
|
|
'./packages/builtin-skills/src/**/*.ts',
|
|
'./packages/builtin-tool-*/src/**/*.ts',
|
|
'./packages/builtin-tools/src/**/*.ts',
|
|
'./packages/business/*/src/**/*.ts',
|
|
'./packages/business-server/src/**/*.ts',
|
|
'./packages/config/src/**/*.ts',
|
|
'./packages/edge-config/src/**/*.ts',
|
|
'./packages/editor-runtime/src/**/*.ts',
|
|
'./packages/env/src/**/*.ts',
|
|
'./packages/trpc/src/**/*.{ts,tsx}',
|
|
'./packages/app-config/src/**/*.ts',
|
|
'./packages/locales/src/**/*.ts',
|
|
'./packages/fetch-sse/src/**/*.ts',
|
|
'./packages/desktop-bridge/src/**/*.ts',
|
|
'./packages/python-interpreter/src/**/*.ts',
|
|
'./packages/agent-manager-runtime/src/**/*.ts',
|
|
],
|
|
},
|
|
watch: {
|
|
ignored: ['**/e2e/reports/**', '**/e2e/screenshots/**'],
|
|
},
|
|
},
|
|
});
|