Compare commits
1 Commits
codex/dial
...
codex/requ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e34e4b790 |
@@ -5369,7 +5369,7 @@
|
||||
},
|
||||
"web/service/refresh-token.ts": {
|
||||
"no-restricted-properties": {
|
||||
"count": 7
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"web/service/share.ts": {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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?.()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
145
web/service/console/request-lifecycle.browser.spec.ts
Normal file
145
web/service/console/request-lifecycle.browser.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
87
web/service/console/request-lifecycle.md
Normal file
87
web/service/console/request-lifecycle.md
Normal 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
|
||||
280
web/service/console/request-lifecycle.spec.ts
Normal file
280
web/service/console/request-lifecycle.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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}`)
|
||||
|
||||
328
web/service/refresh-token.spec.ts
Normal file
328
web/service/refresh-token.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user