mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
* test: reproduce issue #927 * fix(hub): cache generated images + raise socket buffer cap (closes #927) Generated-image display was slow and could silently fail: - The /generated-images route sent `Cache-Control: no-store`, so every card remount (session switch, scroll, reload) re-ran the full HTTP -> socket.io RPC -> base64 round-trip. The bytes for an imageId are immutable, so serve them `private, max-age=31536000, immutable` + ETag. - socket.io / bun-engine `maxHttpBufferSize` was left at the 1 MB default, while the MCP tool accepts images up to 25 MB. The base64 CLI -> hub ack frame for anything above ~750 KB raw exceeded the cap and was dropped. Raise the buffer to comfortably carry the largest allowed image. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(hub): short-circuit generated-image revalidation with a 304 (#927) The imageId is an immutable content fingerprint, so use it as the ETag and answer If-None-Match with 304 before issuing the readGeneratedImage RPC. This makes the ETag actually useful: revalidation now skips the CLI socket round-trip entirely, and still serves correctly even after the image was evicted from the CLI's in-memory store. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> * fix(web): stabilize ApiClient identity across token refresh (#927) The ApiClient was rebuilt whenever the token value changed (useMemo dep), even though every request already reads the live token via getToken/tokenRef. On a flaky/remote connection, repeated 401s -> onUnauthorized -> forced refresh churned `api`'s identity, which remounts everything keyed on it: VoiceBackendSession ([props.api]) -> Voice re-register spam, and GeneratedImageCard ([ctx.api]) -> per-image refetch storm, feeding a render avalanche. Depend on auth presence (hasToken) instead. Reproduced with a useAuth hook test (red->green); full web suite stays green. via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run> --------- Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Mock the network layer so we can drive token refreshes deterministically.
|
||||
// The real ApiClient reads the live token via `getToken`, so the mock records the
|
||||
// constructor options (including onUnauthorized) and hands out incrementing tokens.
|
||||
const h = vi.hoisted(() => {
|
||||
let idSeq = 0
|
||||
let authCount = 0
|
||||
class MockApiClient {
|
||||
token: string
|
||||
options: { getToken?: () => string | null; onUnauthorized?: () => unknown; baseUrl?: string } | undefined
|
||||
readonly id: number
|
||||
constructor(token: string, options?: MockApiClient['options']) {
|
||||
this.token = token
|
||||
this.options = options
|
||||
this.id = ++idSeq
|
||||
}
|
||||
async authenticate(): Promise<{ token: string; user: { id: string } }> {
|
||||
authCount += 1
|
||||
return { token: `token-${authCount}`, user: { id: 'u1' } }
|
||||
}
|
||||
}
|
||||
class MockApiError extends Error {
|
||||
status: number
|
||||
code?: string
|
||||
constructor(message: string, status = 401, code?: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.code = code
|
||||
}
|
||||
}
|
||||
return { MockApiClient, MockApiError }
|
||||
})
|
||||
|
||||
vi.mock('@/api/client', () => ({ ApiClient: h.MockApiClient, ApiError: h.MockApiError }))
|
||||
|
||||
// Imported after the mock is registered (vi.mock is hoisted).
|
||||
import { useAuth } from '@/hooks/useAuth'
|
||||
|
||||
type ApiWithOptions = {
|
||||
id: number
|
||||
options?: { getToken?: () => string | null; onUnauthorized?: () => unknown }
|
||||
}
|
||||
|
||||
describe('useAuth — api identity stability across token refresh (issue #927)', () => {
|
||||
it('keeps the same ApiClient instance when the token refreshes', async () => {
|
||||
// Stable authSource reference, exactly like the real caller (useAuthSource holds it in
|
||||
// useState). This isolates the bug under test: a *token* refresh, not a source change.
|
||||
const authSource = { type: 'accessToken' as const, token: 'seed' }
|
||||
const { result } = renderHook(() => useAuth(authSource, 'http://hub.test'))
|
||||
|
||||
// Initial authenticate resolves and sets the first token.
|
||||
await waitFor(() => expect(result.current.api).not.toBeNull())
|
||||
const api1 = result.current.api as unknown as ApiWithOptions
|
||||
const token1 = result.current.token
|
||||
expect(token1).toBe('token-1')
|
||||
|
||||
// Drive the exact real-world trigger: a 401 invokes onUnauthorized,
|
||||
// which force-refreshes the token (this is what the flaky remote network does).
|
||||
await act(async () => {
|
||||
await api1.options?.onUnauthorized?.()
|
||||
})
|
||||
|
||||
// The token did advance...
|
||||
expect(result.current.token).toBe('token-2')
|
||||
expect(result.current.token).not.toBe(token1)
|
||||
|
||||
// ...but recreating the client was unnecessary: the OLD instance already serves
|
||||
// the fresh token via getToken, so nothing downstream needed a new `api` reference.
|
||||
expect(api1.options?.getToken?.()).toBe(result.current.token)
|
||||
|
||||
// DESIRED: `api` stays referentially stable across a refresh, so effects keyed on
|
||||
// `api` (VoiceBackendSession `[props.api]`, GeneratedImageCard `[ctx.api, ...]`) do
|
||||
// NOT re-run / remount. On current code `api` is rebuilt because `token` is a useMemo
|
||||
// dep, which drives the Voice-remount spam + per-image refetch storm. This fails today.
|
||||
expect(result.current.api).toBe(api1 as unknown as typeof result.current.api)
|
||||
})
|
||||
})
|
||||
@@ -157,15 +157,21 @@ export function useAuth(authSource: AuthSource | null, baseUrl: string): {
|
||||
}
|
||||
}, [baseUrl])
|
||||
|
||||
// Keep the ApiClient referentially stable across token *refreshes*: the client always reads
|
||||
// the live token via getToken (tokenRef), so it never needs rebuilding when the token value
|
||||
// changes — only when auth presence toggles (login/logout). Rebuilding on every refresh churns
|
||||
// `api`'s identity, which remounts everything keyed on it (VoiceBackendSession `[props.api]`,
|
||||
// GeneratedImageCard `[ctx.api, ...]`) and drives the remount/refetch storm. Issue #927.
|
||||
const hasToken = token !== null
|
||||
const api = useMemo(() => (
|
||||
token
|
||||
? new ApiClient(token, {
|
||||
hasToken
|
||||
? new ApiClient(tokenRef.current ?? '', {
|
||||
baseUrl,
|
||||
getToken: () => tokenRef.current,
|
||||
onUnauthorized: () => refreshAuth({ force: true })
|
||||
})
|
||||
: null
|
||||
), [baseUrl, refreshAuth, token])
|
||||
), [baseUrl, refreshAuth, hasToken])
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
Reference in New Issue
Block a user