Compare commits

...

1 Commits

Author SHA1 Message Date
yyh
6e34e4b790 fix(web): preserve requests during session recovery 2026-09-22 11:11:14 +08:00
11 changed files with 1116 additions and 94 deletions

View File

@@ -5369,7 +5369,7 @@
},
"web/service/refresh-token.ts": {
"no-restricted-properties": {
"count": 7
"count": 2
}
},
"web/service/share.ts": {

View File

@@ -111,7 +111,7 @@ Mocks must preserve the public contract needed by the test. Do not mock interact
## Dify Test Setup
- Following [Vite+ testing configuration], tests under `web/` use two explicit projects in `web/vite.config.ts`. Supported commands and CI select one project explicitly: `unit` runs in `happy-dom` and loads `web/vitest.setup.ts`, while `browser` runs matching `app/**/*.browser.spec.{ts,tsx}` files in Playwright Chromium and loads `web/vitest.browser.setup.ts`. Bare `vp test` runs both registered projects.
- Following [Vite+ testing configuration], tests under `web/` use two explicit projects in `web/vite.config.ts`. Supported commands and CI select one project explicitly: `unit` runs in `happy-dom` and loads `web/vitest.setup.ts`, while `browser` runs matching `{app,service}/**/*.browser.spec.{ts,tsx}` files in Playwright Chromium and loads `web/vitest.browser.setup.ts`. Bare `vp test` runs both registered projects.
- Browser failures keep screenshots and Playwright traces under `web/.vitest-browser/`. CI uploads that directory only when failure artifacts exist; Browser Mode does not own coverage or report merging.
- New component and feature specs should generally use a sibling `__tests__/` directory. Existing colocated utility and hook specs may follow their owning module's convention. Cross-feature integration specs belong in `web/__tests__/`.
- The shared `react-i18next` mock is loaded globally. Use `createReactI18nextMock` from `web/test/i18n-mock` only when a test needs custom translations.

View File

@@ -1068,11 +1068,42 @@ export const sseGeneratorPost = (
}
// base request
export const request = async <T>(url: string, options = {}, otherOptions?: IOtherOptions) => {
export const request = async <T>(
url: string,
options: FetchOptionType = {},
otherOptions?: IOtherOptions,
) => {
const callerSignal = options.signal ?? otherOptions?.request?.signal
let signal = callerSignal
let detachCallerSignal: (() => void) | undefined
try {
if (otherOptions?.getAbortController) {
const controller = new AbortController()
const abort = () => controller.abort(callerSignal?.reason)
if (callerSignal?.aborted) abort()
else {
callerSignal?.addEventListener('abort', abort, { once: true })
detachCallerSignal = () => callerSignal?.removeEventListener('abort', abort)
}
signal = controller.signal
otherOptions.getAbortController(controller)
}
const otherOptionsForBaseFetch = otherOptions || {}
const { isPublicAPI = false, silent } = otherOptionsForBaseFetch
const [err, resp] = await asyncRunSafe<T>(baseFetch(url, options, otherOptionsForBaseFetch))
const execute = () => {
signal?.throwIfAborted()
return baseFetch<T>(
url,
{ ...options, signal },
{
...otherOptionsForBaseFetch,
// oRPC encodes the body into a Request, which a fetch attempt consumes.
request: otherOptionsForBaseFetch.request?.clone(),
},
)
}
const [err, resp] = await asyncRunSafe<T>(execute())
signal?.throwIfAborted()
if (err === null) {
const address = resolveWebAppAddress()
if (isPublicAPI && address?.kind === 'environment' && !isWebAppAuthorizationEndpoint(url))
@@ -1083,7 +1114,8 @@ export const request = async <T>(url: string, options = {}, otherOptions?: IOthe
if (errResp.status === 401 || (errResp.status === 403 && isPublicAPI)) {
if (!isClient) return Promise.reject(err)
const [parseErr, errRespData] = await asyncRunSafe<ResponseError>(errResp.json())
const [parseErr, errRespData] = await asyncRunSafe<ResponseError>(errResp.clone().json())
signal?.throwIfAborted()
if (parseErr) {
if (errResp.status === 401) {
discardRegistrationStateForConsoleAuthBoundary(otherOptionsForBaseFetch)
@@ -1124,8 +1156,9 @@ export const request = async <T>(url: string, options = {}, otherOptions?: IOthe
}
// refresh token
const [refreshErr] = await asyncRunSafe(refreshAccessTokenOrReLogin(TIME_OUT))
if (refreshErr === null) return baseFetch<T>(url, options, otherOptionsForBaseFetch)
const [refreshErr] = await asyncRunSafe(refreshAccessTokenOrReLogin(TIME_OUT, signal))
signal?.throwIfAborted()
if (refreshErr === null) return await execute()
// /device is the device-flow chooser; logged-out is a valid state
// there. Redirecting to /signin loses the user_code context and
// the post-login flow lands on /apps instead of returning here.
@@ -1147,6 +1180,8 @@ export const request = async <T>(url: string, options = {}, otherOptions?: IOthe
} catch (error) {
console.error(error)
return Promise.reject(error)
} finally {
detachCallerSignal?.()
}
}

View File

@@ -5,7 +5,8 @@ in Server and Client Components. Calls use the appropriate HTTP
transport for the Python Console API.
- [Browser transport] preserves the existing request
layer's authentication and error handling.
layer's authentication and error handling. See [request lifecycle]
for cancellation, session refresh, and replay ownership.
- [Server transport] requires an absolute API address;
see [server configuration].
[Instrumentation] and the [root layout]
@@ -22,5 +23,6 @@ transport for the Python Console API.
[QueryClient factory]: ../../app/get-query-client.ts
[Server transport]: ./server.ts
[query-policies.ts]: ./query-policies.ts
[request lifecycle]: ./request-lifecycle.md
[root layout]: ../../app/layout.tsx
[server configuration]: ../../config/server.ts

View File

@@ -0,0 +1,145 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
import { API_PREFIX, CSRF_COOKIE_NAME, CSRF_HEADER_NAME } from '@/config'
const mocks = vi.hoisted(() => ({ toastError: vi.fn() }))
vi.mock('@/config', () => ({
API_PREFIX: 'https://console.test/console/api',
PUBLIC_API_PREFIX: 'https://console.test/api',
MARKETPLACE_API_PREFIX: 'https://marketplace.test/api',
CSRF_COOKIE_NAME: () => 'request-lifecycle-csrf',
CSRF_HEADER_NAME: 'X-CSRF-Token',
PASSPORT_HEADER_NAME: 'X-Passport',
WEB_APP_SHARE_CODE_HEADER_NAME: 'X-App-Code',
APP_VERSION: 'test',
IS_MARKETPLACE: false,
ACCESS_TOKEN_LOCAL_STORAGE_NAME: 'request-lifecycle-access-token',
PASSPORT_LOCAL_STORAGE_NAME: (code: string) => `request-lifecycle-passport:${code}`,
}))
vi.mock('@/utils/var', () => ({ basePath: '' }))
vi.mock('@/app/notifications', () => ({ toast: { error: mocks.toastError } }))
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
type RequestAttempt = {
bytes: Uint8Array<ArrayBuffer>
contentType: string
csrf: string | null
keepalive: boolean
credentials: RequestCredentials
}
function captureRefreshAndReplay(response: () => Response) {
const attempts: RequestAttempt[] = []
let refreshCount = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const outgoing = new Request(input, init)
expect(outgoing.method).toBe('POST')
expect(outgoing.credentials).toBe('include')
if (outgoing.url === `${API_PREFIX}/refresh-token`) {
refreshCount += 1
document.cookie = `${CSRF_COOKIE_NAME()}=refreshed; path=/`
return jsonResponse({ result: 'success' })
}
attempts.push({
bytes: new Uint8Array(await outgoing.arrayBuffer()),
contentType: outgoing.headers.get('Content-Type')!,
csrf: outgoing.headers.get(CSRF_HEADER_NAME),
keepalive: outgoing.keepalive,
credentials: outgoing.credentials,
})
expect(outgoing.bodyUsed).toBe(true)
return attempts.length === 1
? jsonResponse({ code: 'unauthorized', message: 'Session expired' }, 401)
: response()
})
return { attempts, refreshCount: () => refreshCount }
}
function expectAuthenticatedReplay(network: ReturnType<typeof captureRefreshAndReplay>) {
expect(network.refreshCount()).toBe(1)
expect(network.attempts).toHaveLength(2)
expect(network.attempts[1]!.bytes).toEqual(network.attempts[0]!.bytes)
expect(network.attempts.map(({ csrf }) => csrf)).toEqual(['initial', 'refreshed'])
for (const attempt of network.attempts) {
expect(attempt.keepalive).toBe(true)
expect(attempt.credentials).toBe('include')
}
expect(mocks.toastError).not.toHaveBeenCalled()
}
// Native Request consumption catches session-expiry failures on writes that happy-dom
// misses; keep oRPC encoding, base.request, Ky, and browser body handling real.
describe('Console write recovery with native request bodies', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
localStorage.removeItem(`console-session-refresh:${API_PREFIX}`)
document.cookie = `${CSRF_COOKIE_NAME()}=initial; path=/`
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.removeItem(`console-session-refresh:${API_PREFIX}`)
document.cookie = `${CSRF_COOKIE_NAME()}=; path=/; max-age=0`
})
it('recovers a JSON write after session expiry with identical bytes, fresh CSRF, and keepalive', async () => {
const audioBytes = new Uint8Array([0x52, 0x49, 0x46, 0x46, 0x00, 0xff])
const network = captureRefreshAndReplay(
() => new Response(audioBytes, { headers: { 'Content-Type': 'audio/wav' } }),
)
const { consoleClient } = await import('./index')
const result = await consoleClient.agent.byAgentId.textToAudio.post(
{ params: { agent_id: 'agent-1' }, body: { text: 'Hello 中文', voice: 'echo' } },
{ context: { keepalive: true } },
)
expectAuthenticatedReplay(network)
expect(network.attempts[0]!.contentType).toBe('application/json')
expect(new TextDecoder().decode(network.attempts[0]!.bytes)).toBe(
JSON.stringify({ text: 'Hello 中文', voice: 'echo' }),
)
expect(new Uint8Array(await result.arrayBuffer())).toEqual(audioBytes)
})
it('recovers a multipart import without losing its boundary, fields, filename, or file bytes', async () => {
const archiveBytes = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x00, 0xff])
const network = captureRefreshAndReplay(() =>
jsonResponse({ id: 'import-1', status: 'completed' }),
)
const { consoleClient } = await import('./index')
const result = await consoleClient.apps.imports.post(
{
body: {
app_id: 'existing-app',
file: new File([archiveBytes], 'agent.ifpkg', { type: 'application/zip' }),
},
},
{ context: { keepalive: true } },
)
expect(result).toEqual({ id: 'import-1', status: 'completed' })
expectAuthenticatedReplay(network)
expect(network.attempts[1]!.contentType).toBe(network.attempts[0]!.contentType)
for (const attempt of network.attempts) {
expect(attempt.contentType).toContain('multipart/form-data; boundary=')
const form = await new Response(attempt.bytes, {
headers: { 'Content-Type': attempt.contentType },
}).formData()
expect(form.get('app_id')).toBe('existing-app')
const file = form.get('file')
expect(file).toBeInstanceOf(File)
if (!(file instanceof File)) throw new TypeError('Expected the uploaded archive')
expect(file.name).toBe('agent.ifpkg')
expect(new Uint8Array(await file.arrayBuffer())).toEqual(archiveBytes)
}
})
})

View File

@@ -0,0 +1,87 @@
# Request lifecycle and session recovery
## Scope
The browser Console client currently sends the `Request` produced by oRPC's
`OpenAPILink` through `base.request` and the Ky adapter. This change makes that
existing path reliable before replacing the transport. It does not change query
keys, generated contracts, cache invalidation, `Response` errors, `silent`, or
notification ownership.
## Ownership
- oRPC owns URL/input encoding and output decoding, including multipart and file
responses. The HTTP adapter must preserve the encoded request and headers.
- `base.request` owns the existing authentication decisions and one replay after a
successful refresh. Login, setup, public WebApp authorization, and redirect
behavior remain at this boundary.
- `refresh-token.ts` owns refreshing the cookie-based Console session. It must not
call the authenticated request layer or cause recursive refreshes.
- Query and feature owners continue to own query retries, resource invalidation,
and application state. Session recovery is not a retry policy for writes.
## Session recovery
Concurrent callers in one document share one refresh promise and its result.
Only a successful HTTP response completes a refresh successfully. The refresh
operation has a bounded lifetime, aborts its network request on timeout, clears
its timer, and publishes an expiring result on completion.
Where available, Web Locks serialize refreshes across same-origin documents.
An attempt/result record allows callers waiting for the same refresh to observe
its outcome without starting another refresh. Records contain no credentials.
Storage coordination remains available for HTTP deployments without Web Locks;
it uses expiring, owner-identified attempts and is best effort because localStorage
has no atomic compare-and-set. It must not let a waiter or an old timer release
another attempt. Expired attempts cannot block future recovery indefinitely.
Individual cancellation stops waiting for a shared refresh, not the refresh
needed by other requests. A canceled caller must neither replay its original
request nor redirect to sign-in because of that cancellation. The shared refresh
has its own deadline.
## Request execution
Each HTTP attempt receives its own copy of an oRPC Request. Preserve the original
encoded body before the first send so a 401 can be retried once without consuming
the same body twice. Preserve multipart boundaries, filenames, bytes, keepalive,
cookies, and fresh CSRF headers. A second 401 rejects; it does not refresh again.
The caller's cancellation signal remains effective during the initial request,
while waiting for refresh, and during replay. Legacy `getAbortController` also
controls the actual request and remains usable across recovery.
Ordinary HTTP errors retain their existing representation and notification
behavior. Public WebApp authorization and the SSE event parsers are outside this
change; their use of the shared refresh function must remain compatible.
## Verification
Exercise production owners with the network boundary mocked:
- generated Console JSON and multipart requests survive one 401 and refresh;
- concurrent refresh callers share success and failure, including HTTP 500;
- refresh timeout aborts the network and does not affect a later attempt;
- canceled callers do not replay or redirect, while another caller can recover;
- ordinary error bodies, silent requests, cookies/CSRF, keepalive, file responses,
and authentication routing retain their existing behavior;
- browser validation covers actual Request consumption, cancellation, and
same-origin document coordination rather than only mocked lock behavior.
Run the affected service suites and repository static checks. Coordinate a later
transport/error/Cache-notification migration with the module Query migrations;
this change does not enable global Cache notifications.
## References
- [oRPC v1 OpenAPILink]
- [Fetch request bodies]
- [AbortSignal]
- [Web Locks]
- [Web Storage coordination limits]
[AbortSignal]: https://dom.spec.whatwg.org#interface-abortsignal
[Fetch request bodies]: https://fetch.spec.whatwg.org#requests
[Web Locks]: https://www.w3.org/TR/web-locks
[Web Storage coordination limits]: https://html.spec.whatwg.org/multipage/webstorage.html
[oRPC v1 OpenAPILink]: https://v1.orpc.dev/docs/openapi/client/openapi-link

View File

@@ -0,0 +1,280 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
}))
vi.mock('@/utils/client', () => ({ isClient: true, isServer: false }))
vi.mock('js-cookie', () => ({ default: { get: () => 'csrf-token' } }))
vi.mock('@/app/notifications', () => ({ toast: { error: mocks.toastError } }))
const originalLocation = globalThis.location
const initialHref = 'http://localhost:3000/apps?tab=recent#last-app'
const jsonResponse = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
const unauthorizedResponse = () =>
jsonResponse({ code: 'unauthorized', message: 'Session expired', status: 401 }, 401)
function deferred<T>() {
let resolve!: (value: T) => void
const promise = new Promise<T>((resolvePromise) => {
resolve = resolvePromise
})
return { promise, resolve }
}
const loadClient = async () => (await import('./index')).consoleClient
// oxlint-disable-next-line no-restricted-imports -- Exercise the public legacy cancellation contract through the real HTTP adapter.
const loadRequest = async () => (await import('../base')).request
function pendingUntilAborted(request: Request) {
return new Promise<Response>((_resolve, reject) => {
if (request.signal.aborted) reject(request.signal.reason)
else
request.signal.addEventListener('abort', () => reject(request.signal.reason), { once: true })
})
}
describe('Console request lifecycle', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
localStorage.clear()
sessionStorage.clear()
Object.defineProperty(globalThis, 'location', {
configurable: true,
writable: true,
value: {
origin: 'http://localhost:3000',
pathname: '/apps',
search: '?tab=recent',
hash: '#last-app',
href: initialHref,
reload: vi.fn(),
},
})
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
localStorage.clear()
sessionStorage.clear()
Object.defineProperty(globalThis, 'location', {
configurable: true,
writable: true,
value: originalLocation,
})
})
it('rejects a second 401 without refreshing again and leaves its response body readable', async () => {
let refreshCount = 0
let requestCount = 0
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const outgoing = new Request(input, init)
if (new URL(outgoing.url).pathname.endsWith('/refresh-token')) {
refreshCount += 1
return jsonResponse({ result: 'success' })
}
requestCount += 1
return unauthorizedResponse()
})
const client = await loadClient()
const error = await client.agent.byAgentId
.get({ params: { agent_id: 'agent-1' } })
.catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(Response)
if (!(error instanceof Response)) throw new TypeError('Expected an HTTP response error')
expect(error.status).toBe(401)
expect(await error.json()).toMatchObject({ code: 'unauthorized', message: 'Session expired' })
expect(refreshCount).toBe(1)
expect(requestCount).toBe(2)
expect(globalThis.location.href).toBe(initialHref)
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('keeps a silent ordinary HTTP error readable without refreshing or notifying', async () => {
const fetch = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
jsonResponse({ code: 'provider_unavailable', message: 'Try again later' }, 503),
)
const client = await loadClient()
const error = await client.agent.byAgentId
.get({ params: { agent_id: 'agent-1' } }, { context: { silent: true } })
.catch((caught: unknown) => caught)
expect(error).toBeInstanceOf(Response)
if (!(error instanceof Response)) throw new TypeError('Expected an HTTP response error')
expect(error.status).toBe(503)
expect(await error.json()).toEqual({ code: 'provider_unavailable', message: 'Try again later' })
expect(fetch).toHaveBeenCalledOnce()
expect(mocks.toastError).not.toHaveBeenCalled()
expect(globalThis.location.href).toBe(initialHref)
})
it('cancels the initial generated request without refreshing, replaying, or navigating', async () => {
const started = deferred<Request>()
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
const outgoing = new Request(input, init)
started.resolve(outgoing)
return pendingUntilAborted(outgoing)
})
const controller = new AbortController()
const client = await loadClient()
const result = client.agent.byAgentId.get(
{ params: { agent_id: 'agent-1' } },
{ signal: controller.signal },
)
const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' })
const outgoing = await started.promise
controller.abort()
await rejection
expect(outgoing.signal.aborted).toBe(true)
expect(fetch).toHaveBeenCalledOnce()
expect(globalThis.location.href).toBe(initialHref)
expect(globalThis.location.reload).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
it.each(['callback', 'caller'] as const)(
'lets the %s controller cancel the initial request when both cancellation APIs are provided',
async (source) => {
const started = deferred<Request>()
const fetch = vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
const outgoing = new Request(input, init)
started.resolve(outgoing)
return pendingUntilAborted(outgoing)
})
const callerController = new AbortController()
let callbackController: AbortController | undefined
const request = await loadRequest()
const result = request(
'/agent/agent-1',
{ signal: callerController.signal },
{ getAbortController: (controller) => (callbackController = controller) },
)
const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' })
const outgoing = await started.promise
const controller = source === 'callback' ? callbackController : callerController
expect(controller).toBeDefined()
controller!.abort()
await rejection
expect(outgoing.signal.aborted).toBe(true)
expect(fetch).toHaveBeenCalledOnce()
expect(globalThis.location.href).toBe(initialHref)
expect(globalThis.location.reload).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
},
)
it.each(['callback', 'caller'] as const)(
'keeps the original %s controller effective while replaying after refresh',
async (source) => {
const replayStarted = deferred<Request>()
let refreshCount = 0
let requestCount = 0
vi.spyOn(globalThis, 'fetch').mockImplementation((input, init) => {
const outgoing = new Request(input, init)
if (new URL(outgoing.url).pathname.endsWith('/refresh-token')) {
refreshCount += 1
return Promise.resolve(jsonResponse({ result: 'success' }))
}
requestCount += 1
if (requestCount === 1) return Promise.resolve(unauthorizedResponse())
replayStarted.resolve(outgoing)
return pendingUntilAborted(outgoing)
})
const callerController = new AbortController()
let callbackController: AbortController | undefined
const request = await loadRequest()
const result = request(
'/agent/agent-1',
{ signal: callerController.signal },
{
getAbortController: (controller) => {
callbackController ??= controller
},
},
)
const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' })
const outgoing = await replayStarted.promise
const controller = source === 'callback' ? callbackController : callerController
expect(controller).toBeDefined()
controller!.abort()
await rejection
expect(outgoing.signal.aborted).toBe(true)
expect(refreshCount).toBe(1)
expect(requestCount).toBe(2)
expect(globalThis.location.href).toBe(initialHref)
expect(globalThis.location.reload).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
},
)
it('cancels one refresh waiter without replaying it or disrupting another caller', async () => {
const refresh = deferred<Response>()
const attempts = new Map<string, number>()
let refreshCount = 0
let cancelledError: unknown
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input, init) => {
const outgoing = new Request(input, init)
const path = new URL(outgoing.url).pathname
if (path.endsWith('/refresh-token')) {
refreshCount += 1
return refresh.promise
}
const count = (attempts.get(path) ?? 0) + 1
attempts.set(path, count)
if (count === 1) return unauthorizedResponse()
return jsonResponse({ id: path.split('/').at(-1) })
})
const client = await loadClient()
const controller = new AbortController()
const cancelled = client.agent.byAgentId
.get({ params: { agent_id: 'cancelled-agent' } }, { signal: controller.signal })
.catch((error: unknown) => {
cancelledError = error
})
const surviving = client.agent.byAgentId.get({ params: { agent_id: 'surviving-agent' } })
try {
await vi.waitFor(() => {
expect(refreshCount).toBe(1)
expect(attempts.size).toBe(2)
})
controller.abort()
await vi.waitFor(() => {
expect(cancelledError).toMatchObject({ name: 'AbortError' })
})
expect(globalThis.location.href).toBe(initialHref)
} finally {
refresh.resolve(jsonResponse({ result: 'success' }))
await cancelled
await surviving
}
expect(refreshCount).toBe(1)
expect([...attempts].find(([path]) => path.endsWith('/cancelled-agent'))?.[1]).toBe(1)
expect([...attempts].find(([path]) => path.endsWith('/surviving-agent'))?.[1]).toBe(2)
expect(await surviving).toMatchObject({ id: 'surviving-agent' })
expect(globalThis.location.href).toBe(initialHref)
expect(globalThis.location.reload).not.toHaveBeenCalled()
expect(mocks.toastError).not.toHaveBeenCalled()
})
})

View File

@@ -154,7 +154,6 @@ async function base<T>(
bodyStringify = true,
needAllResponseContent,
deleteContentType,
getAbortController,
fetchCompat = false,
request,
} = otherOptions
@@ -166,12 +165,6 @@ async function base<T>(
else if (isPublicAPI) base = PUBLIC_API_PREFIX
else base = API_PREFIX
if (getAbortController) {
const abortController = new AbortController()
getAbortController(abortController)
options.signal = abortController.signal
}
const fetchPathname = isPublicAPI
? base + getWebAppPublicApiPath(resolveWebAppAddress(), url)
: base + (url.startsWith('/') ? url : `/${url}`)

View File

@@ -0,0 +1,328 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vite-plus/test'
vi.mock('@/config', () => ({ API_PREFIX: 'https://example.com/console/api' }))
const deferred = <T>() => {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((onResolve, onReject) => {
resolve = onResolve
reject = onReject
})
return { promise, resolve, reject }
}
const loadRefresh = async () => {
vi.resetModules()
return (await import('./refresh-token')).refreshAccessTokenOrReLogin
}
const createLocks = () => {
let previous = Promise.resolve()
return {
request: vi.fn((_name: string, options: LockOptions, callback: () => Promise<void>) => {
const released = deferred<void>()
const current = previous
previous = released.promise
return current
.then(() => {
options.signal?.throwIfAborted()
return callback()
})
.finally(() => released.resolve())
}),
}
}
describe('Console session refresh', () => {
const network = vi.fn<typeof fetch>()
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
localStorage.clear()
network.mockReset()
vi.stubGlobal('fetch', network)
vi.stubGlobal('navigator', {})
})
afterEach(() => {
vi.clearAllTimers()
vi.useRealTimers()
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('shares one successful refresh and clears its deadline', async () => {
const response = deferred<Response>()
network.mockReturnValue(response.promise)
const refresh = await loadRefresh()
const first = refresh(1000)
const second = refresh(1000)
await vi.waitFor(() => expect(network).toHaveBeenCalledTimes(1))
response.resolve(new Response('{}'))
await expect(Promise.all([first, second])).resolves.toEqual([undefined, undefined])
expect(network).toHaveBeenCalledWith(
'https://example.com/console/api/refresh-token',
expect.objectContaining({ method: 'POST', credentials: 'include' }),
)
expect(vi.getTimerCount()).toBe(0)
})
it.each([401, 500])(
'shares a refresh HTTP %s failure without another request',
async (status) => {
const response = deferred<Response>()
network.mockReturnValue(response.promise)
const refresh = await loadRefresh()
const results = Promise.allSettled([refresh(1000), refresh(1000)])
await vi.waitFor(() => expect(network).toHaveBeenCalledTimes(1))
response.resolve(new Response('{}', { status }))
await vi.advanceTimersByTimeAsync(1000)
const settled = await results
expect(settled.map((result) => result.status)).toEqual(['rejected', 'rejected'])
expect(settled[1]).toEqual(settled[0])
expect(network).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
},
)
it('shares network failure and allows a later refresh to succeed', async () => {
const response = deferred<Response>()
network.mockReturnValueOnce(response.promise).mockResolvedValue(new Response('{}'))
const refresh = await loadRefresh()
const results = Promise.allSettled([refresh(1000), refresh(1000)])
await vi.waitFor(() => expect(network).toHaveBeenCalledTimes(1))
response.reject(new TypeError('Failed to fetch'))
await vi.advanceTimersByTimeAsync(1000)
const settled = await results
expect(settled.map((result) => result.status)).toEqual(['rejected', 'rejected'])
expect(settled[1]).toEqual(settled[0])
await expect(refresh(1000)).resolves.toBeUndefined()
expect(network).toHaveBeenCalledTimes(2)
})
it('aborts a timed out network request and permits a fresh attempt', async () => {
let signal: AbortSignal | null | undefined
network
.mockImplementationOnce((_input, init) => {
signal = init?.signal
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal?.reason), { once: true })
})
})
.mockResolvedValue(new Response('{}'))
const refresh = await loadRefresh()
const result = Promise.allSettled([refresh(1000)])
await vi.waitFor(() => expect(network).toHaveBeenCalledTimes(1))
await vi.advanceTimersByTimeAsync(1000)
expect((await result)[0]?.status).toBe('rejected')
expect(signal?.aborted).toBe(true)
await expect(refresh(1000)).resolves.toBeUndefined()
expect(network).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
})
it('cancels one caller without aborting the shared refresh', async () => {
const response = deferred<Response>()
network.mockReturnValue(response.promise)
const refresh = await loadRefresh()
const controller = new AbortController()
const canceled = Promise.allSettled([refresh(1000, controller.signal)])
const remaining = refresh(1000)
await vi.waitFor(() => expect(network).toHaveBeenCalledTimes(1))
controller.abort()
expect((await canceled)[0]).toMatchObject({
status: 'rejected',
reason: { name: 'AbortError' },
})
expect(network.mock.calls[0]?.[1]?.signal?.aborted).toBe(false)
response.resolve(new Response('{}'))
await expect(remaining).resolves.toBeUndefined()
expect(network).toHaveBeenCalledTimes(1)
})
it('refreshes without persistence when localStorage is unavailable', async () => {
network.mockResolvedValue(new Response('{}'))
vi.stubGlobal('localStorage', {
getItem: () => {
throw new DOMException('Access denied', 'SecurityError')
},
})
const refresh = await loadRefresh()
await expect(refresh(1000)).resolves.toBeUndefined()
expect(network).toHaveBeenCalledTimes(1)
})
it('does not start refreshing for an already canceled caller', async () => {
const refresh = await loadRefresh()
const controller = new AbortController()
controller.abort()
await expect(refresh(1000, controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
expect(network).not.toHaveBeenCalled()
})
it.each([200, 500])('shares HTTP %s across documents queued on Web Locks', async (status) => {
const locks = createLocks()
vi.stubGlobal('navigator', { locks })
const response = deferred<Response>()
network.mockReturnValue(response.promise)
const firstDocument = await loadRefresh()
const first = Promise.allSettled([firstDocument(5000)])
await vi.waitFor(() => expect(network).toHaveBeenCalledTimes(1))
const otherDocument = await loadRefresh()
const other = Promise.allSettled([otherDocument(5000)])
response.resolve(new Response('{}', { status }))
const expectedStatus = status === 200 ? 'fulfilled' : 'rejected'
expect((await first)[0]?.status).toBe(expectedStatus)
expect((await other)[0]?.status).toBe(expectedStatus)
expect(network).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
it('shares one replacement refresh when multiple documents take over an abandoned lock', async () => {
vi.stubGlobal('navigator', { locks: createLocks() })
localStorage.setItem(
'console-session-refresh:https://example.com/console/api',
JSON.stringify({
id: 'closed-document',
state: 'pending',
expiresAt: Date.now() + 5000,
}),
)
network.mockResolvedValue(new Response('{}'))
const firstDocument = await loadRefresh()
const secondDocument = await loadRefresh()
await expect(Promise.all([firstDocument(5000), secondDocument(5000)])).resolves.toEqual([
undefined,
undefined,
])
expect(network).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
it('shares a failed storage-coordinated refresh with another document', async () => {
const response = deferred<Response>()
network.mockReturnValue(response.promise)
const firstDocument = await loadRefresh()
const first = Promise.allSettled([firstDocument(5000)])
const otherDocument = await loadRefresh()
const other = Promise.allSettled([otherDocument(5000)])
response.resolve(new Response('{}', { status: 500 }))
await vi.advanceTimersByTimeAsync(1000)
expect((await first)[0]?.status).toBe('rejected')
expect((await other)[0]).toMatchObject({
status: 'rejected',
reason: { message: 'Session refresh failed (500)' },
})
expect(network).toHaveBeenCalledTimes(1)
expect(vi.getTimerCount()).toBe(0)
})
it.each([
{ firstStatus: 200, nextStatus: 500, expected: 'fulfilled' },
{ firstStatus: 500, nextStatus: 200, expected: 'rejected' },
])(
'keeps a delayed $firstStatus outcome when a later refresh returns $nextStatus',
async ({ firstStatus, nextStatus, expected }) => {
const firstResponse = deferred<Response>()
network
.mockReturnValueOnce(firstResponse.promise)
.mockResolvedValue(new Response('{}', { status: nextStatus }))
const owner = await loadRefresh()
const first = Promise.allSettled([owner(5000)])
const key = localStorage.key(0)
if (!key) throw new Error('Expected a shared refresh attempt')
const pending = localStorage.getItem(key)
const otherDocument = await loadRefresh()
const waiting = Promise.allSettled([otherDocument(5000)])
firstResponse.resolve(new Response('{}', { status: firstStatus }))
await first
const completed = localStorage.getItem(key)
await Promise.allSettled([owner(5000)])
// A background document can poll the newest record before its queued storage events run.
await vi.advanceTimersByTimeAsync(1000)
globalThis.dispatchEvent(new StorageEvent('storage', { key, newValue: pending }))
globalThis.dispatchEvent(new StorageEvent('storage', { key, newValue: completed }))
expect((await waiting)[0]?.status).toBe(expected)
expect(network).toHaveBeenCalledTimes(2)
expect(vi.getTimerCount()).toBe(0)
},
)
it('does not let a waiting document timeout release the active refresh', async () => {
const response = deferred<Response>()
network.mockReturnValue(response.promise)
const firstDocument = await loadRefresh()
const first = firstDocument(5000)
const waitingDocument = await loadRefresh()
const waiting = Promise.allSettled([waitingDocument(1000)])
await vi.advanceTimersByTimeAsync(1000)
expect((await waiting)[0]?.status).toBe('rejected')
expect(network.mock.calls[0]?.[1]?.signal?.aborted).toBe(false)
const thirdDocument = await loadRefresh()
const third = thirdDocument(5000)
expect(network).toHaveBeenCalledTimes(1)
response.resolve(new Response('{}'))
await vi.advanceTimersByTimeAsync(1000)
await expect(Promise.all([first, third])).resolves.toEqual([undefined, undefined])
expect(network).toHaveBeenCalledTimes(1)
})
it('does not let a late timed-out response overwrite the next attempt', async () => {
const oldResponse = deferred<Response>()
const newResponse = deferred<Response>()
network.mockReturnValueOnce(oldResponse.promise).mockReturnValue(newResponse.promise)
const firstDocument = await loadRefresh()
const first = Promise.allSettled([firstDocument(1000)])
await vi.advanceTimersByTimeAsync(1000)
expect((await first)[0]?.status).toBe('rejected')
const nextDocument = await loadRefresh()
const next = nextDocument(5000)
oldResponse.resolve(new Response('{}'))
await vi.advanceTimersByTimeAsync(0)
const waitingDocument = await loadRefresh()
const waiting = waitingDocument(5000)
expect(network).toHaveBeenCalledTimes(2)
newResponse.resolve(new Response('{}'))
await vi.advanceTimersByTimeAsync(1000)
await expect(Promise.all([next, waiting])).resolves.toEqual([undefined, undefined])
expect(network).toHaveBeenCalledTimes(2)
})
it('uses storage coordination when the browser denies Web Locks access', async () => {
vi.stubGlobal('navigator', {
locks: {
request: vi.fn().mockRejectedValue(new DOMException('Access denied', 'SecurityError')),
},
})
network.mockResolvedValue(new Response('{}'))
const refresh = await loadRefresh()
await expect(refresh(1000)).resolves.toBeUndefined()
expect(network).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,95 +1,247 @@
import { API_PREFIX } from '@/config'
import { fetchWithRetry } from '@/utils'
import { isClient } from '@/utils/client'
const LOCAL_STORAGE_KEY = 'is_other_tab_refreshing'
const STORAGE_KEY = `console-session-refresh:${API_PREFIX}`
const POLL_INTERVAL = 1000
let isRefreshing = false
function waitUntilTokenRefreshed() {
return new Promise<void>((resolve) => {
function _check() {
const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
if ((isRefreshingSign && isRefreshingSign === '1') || isRefreshing) {
setTimeout(() => {
_check()
}, 1000)
} else {
resolve()
}
}
_check()
})
}
type RefreshAttempt = {
id: string
expiresAt: number
} & ({ state: 'pending' | 'succeeded' } | { state: 'failed'; message: string })
const isRefreshingSignAvailable = function (delta: number) {
const nowTime = new Date().getTime()
const lastTime = globalThis.localStorage.getItem('last_refresh_time') || '0'
return nowTime - Number.parseInt(lastTime) <= delta
}
let inFlight: Promise<void> | undefined
let storageAvailable = true
// only one request can send
async function getNewAccessToken(timeout: number): Promise<void> {
function parseAttempt(serialized: string | null): RefreshAttempt | undefined {
try {
const isRefreshingSign = globalThis.localStorage.getItem(LOCAL_STORAGE_KEY)
const value: unknown = JSON.parse(serialized || 'null')
if (
(isRefreshingSign && isRefreshingSign === '1' && isRefreshingSignAvailable(timeout)) ||
isRefreshing
) {
await waitUntilTokenRefreshed()
} else {
isRefreshing = true
globalThis.localStorage.setItem(LOCAL_STORAGE_KEY, '1')
globalThis.localStorage.setItem('last_refresh_time', new Date().getTime().toString())
globalThis.addEventListener('beforeunload', releaseRefreshLock)
typeof value !== 'object' ||
value === null ||
!('id' in value) ||
typeof value.id !== 'string' ||
!('expiresAt' in value) ||
typeof value.expiresAt !== 'number' ||
!Number.isFinite(value.expiresAt) ||
!('state' in value)
)
return
if (
value.state === 'pending' ||
value.state === 'succeeded' ||
(value.state === 'failed' && 'message' in value && typeof value.message === 'string')
)
return value as RefreshAttempt
} catch {}
}
// Do not use baseFetch to refresh tokens.
// If a 401 response occurs and baseFetch itself attempts to refresh the token,
// it can lead to an infinite loop if the refresh attempt also returns 401.
// To avoid this, handle token refresh separately in a dedicated function
// that does not call baseFetch and uses a single retry mechanism.
const [error, ret] = await fetchWithRetry(
globalThis.fetch(`${API_PREFIX}/refresh-token`, {
method: 'POST',
credentials: 'include', // Important: include cookies in the request
headers: {
'Content-Type': 'application/json;utf-8',
},
// No body needed - refresh token is in cookie
}),
)
if (error) {
return Promise.reject(error)
} else {
if (ret.status === 401) return Promise.reject(ret)
}
}
} catch (error) {
console.error(error)
return Promise.reject(error)
} finally {
releaseRefreshLock()
function readAttempt(): RefreshAttempt | undefined {
if (!storageAvailable) return
try {
return parseAttempt(globalThis.localStorage.getItem(STORAGE_KEY))
} catch {
storageAvailable = false
}
}
function releaseRefreshLock() {
// Always clear the refresh lock to avoid cross-tab deadlocks.
// This is safe to call multiple times and from tabs that were only waiting.
isRefreshing = false
globalThis.localStorage.removeItem(LOCAL_STORAGE_KEY)
globalThis.localStorage.removeItem('last_refresh_time')
globalThis.removeEventListener('beforeunload', releaseRefreshLock)
function writeAttempt(attempt: RefreshAttempt) {
if (!storageAvailable) return
try {
globalThis.localStorage.setItem(STORAGE_KEY, JSON.stringify(attempt))
} catch {
storageAvailable = false
}
}
export async function refreshAccessTokenOrReLogin(timeout: number) {
function waitForResult<T>(operation: Promise<T>, signal?: AbortSignal): Promise<T> {
if (!signal) return operation
if (signal.aborted) return Promise.reject(signal.reason)
return new Promise<T>((resolve, reject) => {
const onAbort = () => reject(signal.reason)
signal.addEventListener('abort', onAbort, { once: true })
operation.then(
(value) => {
signal.removeEventListener('abort', onAbort)
resolve(value)
},
(error: unknown) => {
signal.removeEventListener('abort', onAbort)
reject(error)
},
)
})
}
function completeAttempt(attempt: RefreshAttempt) {
if (attempt.state === 'failed') throw new Error(attempt.message)
}
function waitForOtherDocument(attemptId: string, signal: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
let timer: ReturnType<typeof setTimeout> | undefined
function cleanup() {
clearTimeout(timer)
globalThis.removeEventListener('storage', onStorage)
signal.removeEventListener('abort', onAbort)
}
function onAbort() {
cleanup()
reject(signal.reason)
}
function acceptResult(attempt: RefreshAttempt | undefined) {
if (!attempt || attempt.id !== attemptId || attempt.state === 'pending') return false
cleanup()
if (attempt.state === 'failed') reject(new Error(attempt.message))
else resolve()
return true
}
function onStorage(event: StorageEvent) {
if (event.key !== STORAGE_KEY) return
if (signal.aborted) {
onAbort()
return
}
// Queued events preserve this attempt's outcome after another refresh replaces the record.
acceptResult(parseAttempt(event.newValue))
}
function check() {
clearTimeout(timer)
if (signal.aborted) {
onAbort()
return
}
const attempt = readAttempt()
if (!storageAvailable) {
cleanup()
resolve(refreshSession(signal))
return
}
if (acceptResult(attempt)) return
if (attempt?.id === attemptId) {
if (attempt.expiresAt <= Date.now()) {
cleanup()
reject(new Error('Session refresh expired before its result was available'))
return
}
timer = setTimeout(check, Math.min(POLL_INTERVAL, attempt.expiresAt - Date.now()))
} else {
timer = setTimeout(check, POLL_INTERVAL)
}
}
globalThis.addEventListener('storage', onStorage)
signal.addEventListener('abort', onAbort, { once: true })
check()
})
}
async function refreshSession(signal: AbortSignal) {
signal.throwIfAborted()
const response = await globalThis.fetch(`${API_PREFIX}/refresh-token`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json;utf-8' },
signal,
})
signal.throwIfAborted()
if (!response.ok) throw response
}
async function runAttempt(timeout: number, signal: AbortSignal) {
signal.throwIfAborted()
const attempt: RefreshAttempt = {
id: `${Date.now()}-${Math.random().toString(36).slice(2)}`,
state: 'pending',
expiresAt: Date.now() + timeout,
}
writeAttempt(attempt)
const owner = readAttempt()
if (storageAvailable && owner && owner.id !== attempt.id)
return waitForOtherDocument(owner.id, signal)
const publish = (result: RefreshAttempt) => {
// A timed-out owner must never overwrite a newer document's attempt.
if (readAttempt()?.id === attempt.id) writeAttempt(result)
}
try {
await refreshSession(signal)
publish({ ...attempt, state: 'succeeded', expiresAt: Date.now() + timeout })
} catch (error) {
publish({
...attempt,
state: 'failed',
expiresAt: Date.now() + timeout,
message:
error instanceof Response
? `Session refresh failed (${error.status})`
: error instanceof Error
? error.message
: 'Session refresh failed',
})
throw error
}
}
async function coordinateRefresh(timeout: number, signal: AbortSignal) {
const observed = readAttempt()
const acceptCompletedAttempt = (current: RefreshAttempt | undefined) => {
if (
current &&
current.expiresAt > Date.now() &&
current.state !== 'pending' &&
(current.id !== observed?.id || observed.state === 'pending')
) {
completeAttempt(current)
return true
}
return false
}
const withoutLock = () => {
const current = readAttempt()
if (observed?.state === 'pending' && observed.expiresAt > Date.now())
return waitForOtherDocument(observed.id, signal)
if (acceptCompletedAttempt(current)) return
if (current?.state === 'pending' && current.expiresAt > Date.now())
return waitForOtherDocument(current.id, signal)
return runAttempt(timeout, signal)
}
let locks: LockManager | undefined
try {
locks = globalThis.navigator?.locks
} catch {
return withoutLock()
}
if (!locks) return withoutLock()
let acquired = false
try {
await locks.request(STORAGE_KEY, { signal }, async () => {
acquired = true
signal.throwIfAborted()
if (acceptCompletedAttempt(readAttempt())) return
await runAttempt(timeout, signal)
})
} catch (error) {
if (!acquired && error instanceof DOMException && error.name === 'SecurityError')
return withoutLock()
throw error
}
}
export function refreshAccessTokenOrReLogin(timeout: number, signal?: AbortSignal): Promise<void> {
if (!isClient) return Promise.reject(new Error('refresh token is client-only'))
if (signal?.aborted) return Promise.reject(signal.reason)
return Promise.race([
new Promise<void>((resolve, reject) =>
setTimeout(() => {
releaseRefreshLock()
reject(new Error('request timeout'))
}, timeout),
),
getNewAccessToken(timeout),
])
if (!inFlight) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(new Error('request timeout')), timeout)
const operation = waitForResult(
coordinateRefresh(timeout, controller.signal),
controller.signal,
).finally(() => {
clearTimeout(timer)
if (inFlight === operation) inFlight = undefined
})
inFlight = operation
}
return waitForResult(inFlight, signal)
}

View File

@@ -10,7 +10,7 @@ import { nextStaticImageTestPlugin } from './plugins/vite/next-static-image-test
const projectRoot = fileURLToPath(new URL('.', import.meta.url))
const isCI = !!process.env.CI
const rootClientInjectTarget = getRootClientInjectTarget(projectRoot)
const browserTestPattern = 'app/**/*.browser.spec.{ts,tsx}'
const browserTestPattern = '{app,service}/**/*.browser.spec.{ts,tsx}'
export default defineConfig(({ command, mode, isPreview }) => {
const isTest = mode === 'test'