Preserve recoverable browser H5 startup paths behind proxies

Remote browser bootstrap now keeps reverse-proxy path prefixes, recognizes IPv6 loopback as local, and converts remote health or verify failures into recoverable H5 connection prompts instead of falling back to the generic startup error surface. Token invalidation clears only the stored token so users can retry without re-entering the server address.

Constraint: Review fixes must stay inside the existing Task 4 browser-runtime scope without changing the Tauri localhost startup path
Rejected: Clear both stored server URL and token on remote bootstrap failures | it forces unnecessary re-entry after stale credentials
Directive: Any future browser-mode startup error for non-loopback backends should remain recoverable through H5ConnectionView unless the desktop/Tauri contract changes
Confidence: high
Scope-risk: narrow
Tested: cd desktop && bunx vitest run src/api/client.test.ts src/api/websocket.test.ts src/lib/desktopRuntime.test.ts src/components/layout/AppShell.test.tsx
Tested: cd desktop && bun run lint
Tested: git diff --check -- desktop/src/api/client.ts desktop/src/api/websocket.ts desktop/src/lib/desktopRuntime.ts desktop/src/components/layout/H5ConnectionView.tsx desktop/src/components/layout/AppShell.tsx desktop/src/api/client.test.ts desktop/src/api/websocket.test.ts desktop/src/lib/desktopRuntime.test.ts desktop/src/components/layout/AppShell.test.tsx
Not-tested: Live reverse-proxy browser session against a real remote H5 server
This commit is contained in:
程序员阿江(Relakkes) 2026-05-10 00:35:42 +08:00
parent ba1807541a
commit 5c1583f852
5 changed files with 197 additions and 15 deletions

View File

@ -114,4 +114,12 @@ describe('wsManager reconnect buffering', () => {
'wss://remote.example.com/ws/secure-session',
)
})
it('preserves reverse-proxy subpaths when building websocket URLs', () => {
clientMocks.baseUrl = 'https://public.example.com/app'
expect(buildSessionWebSocketUrl('s1')).toBe(
'wss://public.example.com/app/ws/s1',
)
})
})

View File

@ -180,7 +180,8 @@ class WebSocketManager {
export function buildSessionWebSocketUrl(sessionId: string) {
const url = new URL(getBaseUrl())
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.pathname = `/ws/${encodeURIComponent(sessionId)}`
const basePath = url.pathname === '/' ? '' : url.pathname.replace(/\/$/, '')
url.pathname = `${basePath}/ws/${encodeURIComponent(sessionId)}`
const token = getAuthToken()
if (token) {

View File

@ -169,6 +169,21 @@ describe('AppShell boot flow', () => {
expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument()
})
it('shows the H5 connection view for unreachable remote browser startup failures', async () => {
mocks.initializeDesktopServerUrl.mockRejectedValueOnce(
Object.assign(new Error('Unable to reach https://remote.example.com. Check the server URL or network access.'), {
name: 'H5ConnectionRequiredError',
serverUrl: 'https://remote.example.com',
}),
)
render(<AppShell />)
expect(await screen.findByText('h5 connection view')).toBeInTheDocument()
expect(screen.getByText('Unable to reach https://remote.example.com. Check the server URL or network access.')).toBeInTheDocument()
expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument()
})
it('retries bootstrap after a successful H5 connection', async () => {
mocks.initializeDesktopServerUrl
.mockRejectedValueOnce(

View File

@ -0,0 +1,111 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const clientMocks = vi.hoisted(() => ({
defaultBaseUrl: 'http://127.0.0.1:3456',
setBaseUrl: vi.fn(),
setAuthToken: vi.fn(),
postVerify: vi.fn(),
}))
vi.mock('../api/client', () => ({
api: {
post: clientMocks.postVerify,
},
getDefaultBaseUrl: () => clientMocks.defaultBaseUrl,
setAuthToken: clientMocks.setAuthToken,
setBaseUrl: clientMocks.setBaseUrl,
}))
import {
H5ConnectionRequiredError,
H5_SERVER_URL_STORAGE_KEY,
H5_TOKEN_STORAGE_KEY,
initializeDesktopServerUrl,
isLoopbackHostname,
requiresH5AuthForServerUrl,
} from './desktopRuntime'
describe('desktopRuntime browser H5 bootstrap', () => {
const originalFetch = globalThis.fetch
beforeEach(() => {
vi.clearAllMocks()
vi.useRealTimers()
window.localStorage.clear()
window.history.pushState({}, '', '/')
globalThis.fetch = originalFetch
})
afterEach(() => {
vi.useRealTimers()
globalThis.fetch = originalFetch
})
it('treats IPv6 loopback as local', () => {
expect(isLoopbackHostname('[::1]')).toBe(true)
expect(isLoopbackHostname('::1')).toBe(true)
expect(requiresH5AuthForServerUrl('http://[::1]:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('http://localhost:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(true)
})
it('clears an invalid token but preserves the remembered remote server URL', async () => {
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp')
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'stale-token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(new Error('Unauthorized'))
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://public.example.com/app',
message: 'The saved H5 token is no longer valid.',
} satisfies Partial<H5ConnectionRequiredError>)
expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe(
'https://public.example.com/app',
)
expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull()
})
it('normalizes unreachable remote browser startup into a recoverable H5 error', async () => {
vi.useFakeTimers()
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Funreachable.example.com')
globalThis.fetch = vi.fn().mockRejectedValue(new TypeError('Failed to fetch')) as typeof fetch
const startup = expect(initializeDesktopServerUrl()).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://unreachable.example.com',
message: 'Unable to reach https://unreachable.example.com. Check the server URL or network access.',
} satisfies Partial<H5ConnectionRequiredError>)
await vi.runAllTimersAsync()
await startup
expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe(
'https://unreachable.example.com',
)
})
it('normalizes remote verify failures like disabled H5 or CORS into recoverable H5 errors', async () => {
window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com')
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'h5_token')
globalThis.fetch = vi.fn().mockResolvedValue(
new Response(null, { status: 200 }),
) as typeof fetch
clientMocks.postVerify.mockRejectedValueOnce(new TypeError('Failed to fetch'))
await expect(initializeDesktopServerUrl()).rejects.toMatchObject({
name: 'H5ConnectionRequiredError',
serverUrl: 'https://public.example.com',
message: 'Unable to verify the H5 access token.',
} satisfies Partial<H5ConnectionRequiredError>)
expect(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)).toBe(
'https://public.example.com',
)
expect(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)).toBeNull()
})
})

View File

@ -3,7 +3,11 @@ import { api, getDefaultBaseUrl, setAuthToken, setBaseUrl } from '../api/client'
export const H5_SERVER_URL_STORAGE_KEY = 'cc-haha-h5-server-url'
export const H5_TOKEN_STORAGE_KEY = 'cc-haha-h5-token'
type H5ConnectionFailureReason = 'missing-token' | 'invalid-token' | 'verify-failed'
type H5ConnectionFailureReason =
| 'missing-token'
| 'invalid-token'
| 'verify-failed'
| 'unreachable'
export type StoredH5Connection = {
serverUrl: string | null
@ -73,18 +77,18 @@ export async function saveAndVerifyH5Connection(serverUrl: string, token: string
setBaseUrl(normalizedServerUrl)
setAuthToken(normalizedToken)
rememberStoredH5ServerUrl(normalizedServerUrl)
try {
await waitForHealth(normalizedServerUrl)
await verifyH5Access()
} catch (error) {
setAuthToken(null)
throw normalizeH5VerificationError(error, normalizedServerUrl)
clearStoredH5Token()
throw normalizeBrowserH5Error(error, normalizedServerUrl)
}
if (typeof window !== 'undefined') {
try {
window.localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, normalizedServerUrl)
window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, normalizedToken)
} catch {
// Ignore storage failures after a successful verification.
@ -128,17 +132,30 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
const stored = readStoredH5Connection()
const requestedUrl = normalizeServerUrl(queryUrl) ?? stored.serverUrl ?? fallbackUrl
const token = stored.token
const browserH5Runtime = requiresH5AuthForServerUrl(requestedUrl)
setBaseUrl(requestedUrl)
setAuthToken(token)
await waitForHealth(requestedUrl)
if (browserH5Runtime) {
rememberStoredH5ServerUrl(requestedUrl)
}
if (!requiresH5Auth(requestedUrl)) {
try {
await waitForHealth(requestedUrl)
} catch (error) {
if (browserH5Runtime) {
clearStoredH5Token()
throw normalizeBrowserH5Error(error, requestedUrl)
}
throw error
}
if (!browserH5Runtime) {
return requestedUrl
}
if (!token) {
clearStoredH5Connection()
clearStoredH5Token()
throw new H5ConnectionRequiredError(
'Enter your H5 token to continue.',
requestedUrl,
@ -149,8 +166,8 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
try {
await verifyH5Access()
} catch (error) {
clearStoredH5Connection()
throw normalizeH5VerificationError(error, requestedUrl)
clearStoredH5Token()
throw normalizeBrowserH5Error(error, requestedUrl)
}
return requestedUrl
@ -202,22 +219,30 @@ function normalizeToken(value: string | null | undefined) {
return trimmed ? trimmed : null
}
function requiresH5Auth(serverUrl: string) {
export function isLoopbackHostname(hostname: string) {
const normalized = hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'
}
export function requiresH5AuthForServerUrl(serverUrl: string) {
try {
const hostname = new URL(serverUrl).hostname
return hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1'
return !isLoopbackHostname(new URL(serverUrl).hostname)
} catch {
return false
}
}
function normalizeH5VerificationError(error: unknown, serverUrl: string) {
function normalizeBrowserH5Error(error: unknown, serverUrl: string) {
if (error instanceof H5ConnectionRequiredError) {
return error
}
if (error instanceof Error && error.message.startsWith('Server healthcheck failed')) {
return new Error(`Unable to reach ${serverUrl}. Check the server URL or network access.`)
return new H5ConnectionRequiredError(
`Unable to reach ${serverUrl}. Check the server URL or network access.`,
serverUrl,
'unreachable',
)
}
const message =
@ -229,3 +254,25 @@ function normalizeH5VerificationError(error: unknown, serverUrl: string) {
unauthorized ? 'invalid-token' : 'verify-failed',
)
}
function rememberStoredH5ServerUrl(serverUrl: string) {
if (typeof window === 'undefined') return
try {
window.localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, serverUrl)
} catch {
// Ignore storage failures.
}
}
function clearStoredH5Token() {
if (typeof window !== 'undefined') {
try {
window.localStorage.removeItem(H5_TOKEN_STORAGE_KEY)
} catch {
// Ignore storage failures.
}
}
setAuthToken(null)
}