优化
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { coordinateCrossTabRefresh } from './crossTabRefresh.ts'
|
||||
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from './crossTabRefresh.ts'
|
||||
|
||||
test('reuses tokens rotated by another browser tab while waiting for the refresh lock', async () => {
|
||||
let currentRefreshToken = 'refresh-old'
|
||||
@@ -10,7 +10,7 @@ test('reuses tokens rotated by another browser tab while waiting for the refresh
|
||||
configurable: true,
|
||||
value: {
|
||||
locks: {
|
||||
async request(_name, callback) {
|
||||
async request(_name, _options, callback) {
|
||||
currentRefreshToken = 'refresh-new'
|
||||
return callback()
|
||||
},
|
||||
@@ -41,7 +41,7 @@ test('refreshes normally when this tab owns the current refresh token', async ()
|
||||
const originalNavigator = globalThis.navigator
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: { locks: { request: async (_name, callback) => callback() } },
|
||||
value: { locks: { request: async (_name, _options, callback) => callback() } },
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -76,3 +76,32 @@ test('does not wait for rotation after the shared session was removed', async ()
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator })
|
||||
}
|
||||
})
|
||||
|
||||
test('aborts a refresh lock that cannot be acquired', async () => {
|
||||
const originalNavigator = globalThis.navigator
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: {
|
||||
locks: {
|
||||
request(_name, options) {
|
||||
return new Promise((_resolve, reject) => {
|
||||
options.signal.addEventListener('abort', () => reject(options.signal.reason), { once: true })
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
await assert.rejects(coordinateCrossTabRefresh({
|
||||
lockName: 'client-refresh',
|
||||
lockTimeoutMs: 5,
|
||||
observedRefreshToken: 'refresh-old',
|
||||
currentRefreshToken: () => 'refresh-old',
|
||||
reuseCurrentSession: async () => 'unexpected',
|
||||
refresh: async () => 'unexpected',
|
||||
}), CrossTabRefreshTimeoutError)
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: originalNavigator })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,11 +1,21 @@
|
||||
export interface CrossTabRefreshOptions<T> {
|
||||
lockName: string
|
||||
lockTimeoutMs?: number
|
||||
observedRefreshToken: string
|
||||
currentRefreshToken: () => string
|
||||
reuseCurrentSession: () => Promise<T>
|
||||
refresh: (refreshToken: string) => Promise<T>
|
||||
}
|
||||
|
||||
export class CrossTabRefreshTimeoutError extends Error {
|
||||
constructor() {
|
||||
super('登录状态刷新等待超时,请重新登录')
|
||||
this.name = 'CrossTabRefreshTimeoutError'
|
||||
}
|
||||
}
|
||||
|
||||
const DEFAULT_LOCK_TIMEOUT_MS = 20_000
|
||||
|
||||
export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptions<T>): Promise<T> {
|
||||
const run = async () => {
|
||||
const currentRefreshToken = options.currentRefreshToken()
|
||||
@@ -33,5 +43,14 @@ export async function coordinateCrossTabRefresh<T>(options: CrossTabRefreshOptio
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return navigator.locks.request(options.lockName, run)
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS)
|
||||
try {
|
||||
return await navigator.locks.request(options.lockName, { signal: controller.signal }, run)
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted) throw new CrossTabRefreshTimeoutError()
|
||||
throw error
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -3,7 +3,7 @@ import { parseApiJsonResponse } from '@chongchong/shared'
|
||||
import { clearSessionTokens, getAccessToken, getRefreshToken, persistTokenPair, type TokenPair } from '@/api/session'
|
||||
import { apiErrorMessage, asFieldErrors } from '@/api/errorDetails'
|
||||
import { ApiError, isApiEnvelope, type ApiEnvelope } from '@/api/types'
|
||||
import { coordinateCrossTabRefresh } from '@/api/crossTabRefresh'
|
||||
import { coordinateCrossTabRefresh, CrossTabRefreshTimeoutError } from '@/api/crossTabRefresh'
|
||||
|
||||
const transport = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL || '/api',
|
||||
@@ -78,6 +78,12 @@ export async function refreshAuthenticatedSession() {
|
||||
return tokens.accessToken
|
||||
},
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof CrossTabRefreshTimeoutError) {
|
||||
throw new ApiError(error.message, 'A0401', 401)
|
||||
}
|
||||
throw error
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
|
||||
const config = readFileSync(new URL('../../nginx/default.conf.template', import.meta.url), 'utf8')
|
||||
const testCompose = readFileSync(new URL('../../docker-compose.test.yml', import.meta.url), 'utf8')
|
||||
const jenkinsfile = readFileSync(new URL('../../Jenkinsfile', import.meta.url), 'utf8')
|
||||
const dockerfile = readFileSync(new URL('../../Dockerfile', import.meta.url), 'utf8')
|
||||
|
||||
@@ -10,6 +11,14 @@ test('allows qualification uploads with multipart overhead through the API proxy
|
||||
assert.match(config, /location \/api\/ \{[\s\S]*?client_max_body_size 12m;/)
|
||||
})
|
||||
|
||||
test('routes domestic and overseas browser APIs to their test gateways without duplicate api prefixes', () => {
|
||||
assert.match(testCompose, /API_UPSTREAM: "http:\/\/test-api-cn\.buggoglobal\.com"/)
|
||||
assert.match(testCompose, /OVERSEAS_API_UPSTREAM: "http:\/\/test-api-global\.buggoglobal\.com"/)
|
||||
assert.match(config, /location \/overseas-api\/ \{[\s\S]*?rewrite \^\/overseas-api\/\(\.\*\)\$ \/api\/\$1 break;[\s\S]*?proxy_pass \$\{OVERSEAS_API_UPSTREAM\};/)
|
||||
assert.match(config, /proxy_set_header Host \$proxy_host;/)
|
||||
assert.doesNotMatch(config, /\/api\/api/)
|
||||
})
|
||||
|
||||
test('runs each deployment on its target node without controller-local SSH files', () => {
|
||||
assert.match(jenkinsfile, /env\.JOB_BASE_NAME\?\.endsWith\('-dev'\) \? 'java-dev' : 'java-test'/)
|
||||
assert.match(jenkinsfile, /DEVELOPMENT_COMPOSE_PROJECT = 'client-web'/)
|
||||
|
||||
@@ -87,6 +87,8 @@ test('uses real auth endpoints and single-flight refresh handling', () => {
|
||||
assert.match(httpSource, /return await transport\.request\(request\)/)
|
||||
assert.match(httpSource, /envelope\.code !== '200'/)
|
||||
assert.match(httpSource, /redirectToLoginAfterSessionExpiry/)
|
||||
assert.match(httpSource, /error instanceof CrossTabRefreshTimeoutError/)
|
||||
assert.match(httpSource, /new ApiError\(error\.message, 'A0401', 401\)/)
|
||||
assert.match(httpSource, /window\.location\.replace\(`\/login\?redirect=\$\{encodeURIComponent\(redirect\)\}`\)/)
|
||||
assert.match(authStoreSource, /decodeMerchantToken/)
|
||||
assert.doesNotMatch(authStoreSource, /mock-client:/)
|
||||
|
||||
Reference in New Issue
Block a user