refactor(compliance-sdk): split client/provider/embed/state under 500 LOC

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>
This commit is contained in:
Sharang Parnerkar
2026-04-11 22:39:47 +02:00
parent 4ed39d2616
commit 5cb91e88d2
16 changed files with 1380 additions and 1162 deletions

View File

@@ -0,0 +1,187 @@
/**
* 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()
}
}