mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
ba1807541a
commit
5c1583f852
@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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(
|
||||
|
||||
111
desktop/src/lib/desktopRuntime.test.ts
Normal file
111
desktop/src/lib/desktopRuntime.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user