Phase 4 continuation. All touched files now under the file-size cap, and drive-by fixes unblock the types/core/react/vanilla builds which were broken at baseline. Splits - packages/types/src/state 505 -> 31 LOC barrel + state-flow/-assessment/-core - packages/core/src/client 521 -> 395 LOC + client-http 187 LOC (HTTP transport) - packages/react/src/provider 539 -> 460 LOC + provider-context 101 LOC - packages/vanilla/src/embed 611 -> 290 LOC + embed-banner 321 + embed-translations 78 Drive-by fixes (pre-existing typecheck/build failures) - types/rag.ts: rename colliding LegalDocument export to RagLegalDocument (the `export *` chain in index.ts was ambiguous; two consumers updated - core/modules/rag.ts drops unused import, vue/composables/useRAG.ts switches to the renamed symbol). - core/modules/rag.ts: wrap client searchRAG response to add the missing `query` field so the declared SearchResponse return type is satisfied. - react/provider.tsx: re-export useCompliance so ComplianceDashboard / ConsentBanner / DSRPortal legacy `from '../provider'` imports resolve. - vanilla/embed.ts + web-components/base.ts: default tenantId to '' so ComplianceClient construction typechecks. - vanilla/web-components/consent-banner.ts: tighten categories literal to `as const` so t.categories indexing narrows correctly. Verification: packages/types + core + react + vanilla all `pnpm build` clean with DTS emission. consent-sdk unaffected (still green). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
188 lines
5.0 KiB
TypeScript
188 lines
5.0 KiB
TypeScript
/**
|
|
* HTTP transport primitives for ComplianceClient.
|
|
*
|
|
* Phase 4: extracted from client.ts. Handles headers, timeouts, retry logic,
|
|
* abort-controller lifecycle, and error classification so the main client
|
|
* module can focus on domain endpoints.
|
|
*/
|
|
|
|
export interface HttpTransportOptions {
|
|
apiEndpoint: string
|
|
tenantId: string
|
|
timeout?: number
|
|
maxRetries?: number
|
|
onAuthError?: () => void
|
|
onError?: (error: Error) => void
|
|
}
|
|
|
|
export interface APIError extends Error {
|
|
status?: number
|
|
code?: string
|
|
retryable: boolean
|
|
}
|
|
|
|
export const DEFAULT_TIMEOUT = 30000
|
|
export const DEFAULT_MAX_RETRIES = 3
|
|
const RETRY_DELAYS = [1000, 2000, 4000]
|
|
|
|
export function createHttpError(
|
|
message: string,
|
|
status?: number,
|
|
retryable = false
|
|
): APIError {
|
|
const error = new Error(message) as APIError
|
|
error.status = status
|
|
error.retryable = retryable
|
|
return error
|
|
}
|
|
|
|
const sleep = (ms: number): Promise<void> =>
|
|
new Promise(resolve => setTimeout(resolve, ms))
|
|
|
|
/**
|
|
* HttpTransport — reusable fetch wrapper with retry + timeout + abort control.
|
|
*
|
|
* Not exposed via the SDK public surface; ComplianceClient instantiates one
|
|
* internally.
|
|
*/
|
|
export class HttpTransport {
|
|
readonly apiEndpoint: string
|
|
private apiKey: string | null = null
|
|
private accessToken: string | null = null
|
|
private tenantId: string
|
|
private timeout: number
|
|
private maxRetries: number
|
|
private abortControllers: Map<string, AbortController> = new Map()
|
|
private onAuthError?: () => void
|
|
private onError?: (error: Error) => void
|
|
|
|
constructor(options: HttpTransportOptions) {
|
|
this.apiEndpoint = options.apiEndpoint.replace(/\/$/, '')
|
|
this.tenantId = options.tenantId
|
|
this.timeout = options.timeout ?? DEFAULT_TIMEOUT
|
|
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES
|
|
this.onAuthError = options.onAuthError
|
|
this.onError = options.onError
|
|
}
|
|
|
|
setApiKey(apiKey: string | null): void {
|
|
this.apiKey = apiKey
|
|
}
|
|
|
|
setAccessToken(token: string | null): void {
|
|
this.accessToken = token
|
|
}
|
|
|
|
setTenantId(tenantId: string): void {
|
|
this.tenantId = tenantId
|
|
}
|
|
|
|
getTenantId(): string {
|
|
return this.tenantId
|
|
}
|
|
|
|
getHeaders(): Record<string, string> {
|
|
const headers: Record<string, string> = {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': this.tenantId,
|
|
}
|
|
|
|
if (this.accessToken) {
|
|
headers['Authorization'] = `Bearer ${this.accessToken}`
|
|
} else if (this.apiKey) {
|
|
headers['Authorization'] = `Bearer ${this.apiKey}`
|
|
}
|
|
|
|
return headers
|
|
}
|
|
|
|
async fetchWithTimeout(
|
|
url: string,
|
|
options: RequestInit,
|
|
requestId: string
|
|
): Promise<Response> {
|
|
const controller = new AbortController()
|
|
this.abortControllers.set(requestId, controller)
|
|
|
|
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
...options,
|
|
signal: controller.signal,
|
|
})
|
|
return response
|
|
} finally {
|
|
clearTimeout(timeoutId)
|
|
this.abortControllers.delete(requestId)
|
|
}
|
|
}
|
|
|
|
async fetchWithRetry<T>(
|
|
url: string,
|
|
options: RequestInit,
|
|
retries = this.maxRetries
|
|
): Promise<T> {
|
|
const requestId = `${Date.now()}-${Math.random()}`
|
|
let lastError: Error | null = null
|
|
|
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
try {
|
|
const response = await this.fetchWithTimeout(url, options, requestId)
|
|
|
|
if (!response.ok) {
|
|
const errorBody = await response.text()
|
|
let errorMessage = `HTTP ${response.status}`
|
|
|
|
try {
|
|
const errorJson = JSON.parse(errorBody)
|
|
errorMessage = errorJson.error || errorJson.message || errorMessage
|
|
} catch {
|
|
// Keep HTTP status message
|
|
}
|
|
|
|
// Handle auth errors
|
|
if (response.status === 401) {
|
|
this.onAuthError?.()
|
|
throw createHttpError(errorMessage, response.status, false)
|
|
}
|
|
|
|
// Don't retry client errors (4xx) except 429
|
|
const retryable = response.status >= 500 || response.status === 429
|
|
|
|
if (!retryable || attempt === retries) {
|
|
throw createHttpError(errorMessage, response.status, retryable)
|
|
}
|
|
} else {
|
|
const data = await response.json()
|
|
return data as T
|
|
}
|
|
} catch (error) {
|
|
lastError = error as Error
|
|
|
|
if (error instanceof Error && error.name === 'AbortError') {
|
|
throw createHttpError('Request timeout', 408, true)
|
|
}
|
|
|
|
const apiError = error as APIError
|
|
if (!apiError.retryable || attempt === retries) {
|
|
this.onError?.(error as Error)
|
|
throw error
|
|
}
|
|
}
|
|
|
|
// Exponential backoff
|
|
if (attempt < retries) {
|
|
await sleep(RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1])
|
|
}
|
|
}
|
|
|
|
throw lastError || createHttpError('Unknown error', 500, false)
|
|
}
|
|
|
|
cancelAllRequests(): void {
|
|
this.abortControllers.forEach(controller => controller.abort())
|
|
this.abortControllers.clear()
|
|
}
|
|
}
|