feat(web,hub): in-place connection switching via setServerUrl + CORS allowlist for lanUrl

This commit is contained in:
2026-08-02 23:50:34 +08:00
parent 6a280ef7c6
commit 54f98387e5
6 changed files with 38 additions and 4 deletions
+13
View File
@@ -21,6 +21,7 @@ export interface ServerSettings {
listenPort: number
publicUrl: string
corsOrigins: string[]
lanUrl: string | null
}
export interface ServerSettingsResult {
@@ -33,6 +34,7 @@ export interface ServerSettingsResult {
listenHost: 'env' | 'file' | 'default'
listenPort: 'env' | 'file' | 'default'
publicUrl: 'env' | 'file' | 'default'
lanUrl: 'file' | 'default'
corsOrigins: 'env' | 'file' | 'default'
}
savedToFile: boolean
@@ -110,6 +112,7 @@ export async function loadServerSettings(dataDir: string): Promise<ServerSetting
listenHost: 'default',
listenPort: 'default',
publicUrl: 'default',
lanUrl: 'default',
corsOrigins: 'default',
}
// telegramBotToken: env > file > null
@@ -214,6 +217,15 @@ export async function loadServerSettings(dataDir: string): Promise<ServerSetting
sources.publicUrl = 'file'
}
// lanUrl: file only (optional alternative access URL, e.g. LAN direct)
let lanUrl: string | null = null
if (typeof settings.lanUrl === 'string' && settings.lanUrl.trim()) {
lanUrl = settings.lanUrl.trim()
sources.lanUrl = 'file'
} else {
sources.lanUrl = 'default'
}
// corsOrigins: env > file > derived from publicUrl
let corsOrigins: string[]
if (process.env.CORS_ORIGINS) {
@@ -244,6 +256,7 @@ export async function loadServerSettings(dataDir: string): Promise<ServerSetting
listenHost,
listenPort,
publicUrl,
lanUrl,
corsOrigins,
},
sources,
+5
View File
@@ -40,6 +40,7 @@ export interface ConfigSources {
listenHost: ConfigSource
listenPort: ConfigSource
publicUrl: ConfigSource
lanUrl: ConfigSource
corsOrigins: ConfigSource
cliApiToken: 'env' | 'file' | 'generated'
}
@@ -87,6 +88,9 @@ class Configuration {
/** Public URL for external access (e.g., Telegram Mini App) */
public readonly publicUrl: string
/** Optional alternative access URL (e.g. LAN direct) */
public readonly lanUrl: string | null
/** Allowed CORS origins for Mini App + Socket.IO (comma-separated env override) */
public readonly corsOrigins: string[]
@@ -113,6 +117,7 @@ class Configuration {
this.listenHost = serverSettings.listenHost
this.listenPort = serverSettings.listenPort
this.publicUrl = serverSettings.publicUrl
this.lanUrl = serverSettings.lanUrl
this.corsOrigins = serverSettings.corsOrigins
// CLI API token - will be set by _setCliApiToken() before create() returns
+10 -1
View File
@@ -232,7 +232,16 @@ function createWebApp(options: {
const configuration = getConfiguration()
const corsOrigins = options.corsOrigins ?? configuration.corsOrigins
const corsOriginOption = corsOrigins.includes('*') ? '*' : corsOrigins
const mergedCorsOrigins = configuration.lanUrl
? (() => {
try {
return [...new Set([...corsOrigins, new URL(configuration.lanUrl).origin])]
} catch {
return corsOrigins
}
})()
: corsOrigins
const corsOriginOption = mergedCorsOrigins.includes('*') ? '*' : mergedCorsOrigins
const corsMiddleware = cors({
origin: corsOriginOption,
allowMethods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
+1 -1
View File
@@ -449,7 +449,7 @@ function AppInner() {
}
return (
<AppContextProvider value={{ api, token, baseUrl }}>
<AppContextProvider value={{ api, token, baseUrl, setServerUrl }}>
<VoiceProvider>
<PwaUpdateBannerWithStatusOffset
isSyncing={isSyncing}
+3
View File
@@ -1,10 +1,13 @@
import { createContext, useContext, type ReactNode } from 'react'
import type { ApiClient } from '@/api/client'
import type { ServerUrlResult } from '@/hooks/useServerUrl'
type AppContextValue = {
api: ApiClient
token: string
baseUrl: string
setServerUrl: (input: string) => ServerUrlResult
}
const AppContext = createContext<AppContextValue | null>(null)
+6 -2
View File
@@ -11,7 +11,7 @@ const locales: ReadonlyArray<{ value: Locale; label: string }> = [
function ConnectionSwitch() {
const { t } = useTranslation()
const { api } = useAppContext()
const { api, setServerUrl } = useAppContext()
const [info, setInfo] = useState<{ publicUrl: string | null; lanUrl: string | null } | null>(null)
useEffect(() => {
@@ -68,7 +68,11 @@ function ConnectionSwitch() {
<button
type="button"
onClick={() => {
window.location.href = target
// Switch the hub connection in place — the app
// reconnects to the target origin without a full
// page navigation (same hub, so the token stays
// valid).
setServerUrl(target)
}}
className="shrink-0 rounded-md border border-[var(--app-border)] bg-[var(--app-subtle-bg)] px-2 py-1.5 text-xs text-[var(--app-fg)] hover:bg-[var(--app-secondary-bg)]"
>