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()
}
}

View File

@@ -1,8 +1,8 @@
/**
* Compliance Client
*
* Main entry point for the SDK. Handles API communication with
* retry logic, timeout handling, and optimistic locking.
* Main entry point for the SDK. Domain methods delegate to HttpTransport
* for retry/timeout/abort handling. Transport primitives live in client-http.ts.
*/
import type {
@@ -19,6 +19,11 @@ import type {
SDKState,
CheckpointStatus,
} from '@breakpilot/compliance-sdk-types'
import {
HttpTransport,
createHttpError,
type APIError,
} from './client-http'
// =============================================================================
// TYPES
@@ -34,157 +39,33 @@ export interface ComplianceClientOptions {
onAuthError?: () => void
}
interface APIError extends Error {
status?: number
code?: string
retryable: boolean
}
// =============================================================================
// CONSTANTS
// =============================================================================
const DEFAULT_TIMEOUT = 30000
const DEFAULT_MAX_RETRIES = 3
const RETRY_DELAYS = [1000, 2000, 4000]
// =============================================================================
// COMPLIANCE CLIENT
// =============================================================================
export class ComplianceClient {
private apiEndpoint: string
private apiKey: string | null
private tenantId: string
private timeout: number
private maxRetries: number
private accessToken: string | null = null
private abortControllers: Map<string, AbortController> = new Map()
private onError?: (error: Error) => void
private onAuthError?: () => void
private http: HttpTransport
constructor(options: ComplianceClientOptions) {
this.apiEndpoint = options.apiEndpoint.replace(/\/$/, '')
this.apiKey = options.apiKey ?? null
this.tenantId = options.tenantId
this.timeout = options.timeout ?? DEFAULT_TIMEOUT
this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES
this.onError = options.onError
this.onAuthError = options.onAuthError
}
// ---------------------------------------------------------------------------
// Private Methods
// ---------------------------------------------------------------------------
private createError(message: string, status?: number, retryable = false): APIError {
const error = new Error(message) as APIError
error.status = status
error.retryable = retryable
return error
}
private 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
}
private 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)
this.http = new HttpTransport({
apiEndpoint: options.apiEndpoint,
tenantId: options.tenantId,
timeout: options.timeout,
maxRetries: options.maxRetries,
onError: options.onError,
onAuthError: options.onAuthError,
})
if (options.apiKey) {
this.http.setApiKey(options.apiKey)
}
}
private 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 this.createError(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 this.createError(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 this.createError('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 this.sleep(RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1])
}
}
throw lastError || this.createError('Unknown error', 500, false)
private get apiEndpoint(): string {
return this.http.apiEndpoint
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
private get tenantId(): string {
return this.http.getTenantId()
}
// ---------------------------------------------------------------------------
@@ -192,7 +73,7 @@ export class ComplianceClient {
// ---------------------------------------------------------------------------
async authenticate(request: AuthTokenRequest): Promise<AuthTokenResponse> {
const response = await this.fetchWithRetry<APIResponse<AuthTokenResponse>>(
const response = await this.http.fetchWithRetry<APIResponse<AuthTokenResponse>>(
`${this.apiEndpoint}/auth/token`,
{
method: 'POST',
@@ -202,11 +83,11 @@ export class ComplianceClient {
)
if (response.success && response.data) {
this.accessToken = response.data.accessToken
this.http.setAccessToken(response.data.accessToken)
return response.data
}
throw this.createError(response.error || 'Authentication failed', 401, false)
throw createHttpError(response.error || 'Authentication failed', 401, false)
}
async refreshToken(refreshToken: string): Promise<AuthTokenResponse> {
@@ -218,11 +99,11 @@ export class ComplianceClient {
}
setAccessToken(token: string): void {
this.accessToken = token
this.http.setAccessToken(token)
}
clearAccessToken(): void {
this.accessToken = null
this.http.setAccessToken(null)
}
// ---------------------------------------------------------------------------
@@ -231,11 +112,11 @@ export class ComplianceClient {
async getState(): Promise<StateResponse | null> {
try {
const response = await this.fetchWithRetry<APIResponse<StateResponse>>(
const response = await this.http.fetchWithRetry<APIResponse<StateResponse>>(
`${this.apiEndpoint}/state?tenantId=${encodeURIComponent(this.tenantId)}`,
{
method: 'GET',
headers: this.getHeaders(),
headers: this.http.getHeaders(),
}
)
@@ -254,12 +135,12 @@ export class ComplianceClient {
}
async saveState(state: SDKState, version?: number): Promise<StateResponse> {
const response = await this.fetchWithRetry<APIResponse<StateResponse>>(
const response = await this.http.fetchWithRetry<APIResponse<StateResponse>>(
`${this.apiEndpoint}/state`,
{
method: 'POST',
headers: {
...this.getHeaders(),
...this.http.getHeaders(),
...(version !== undefined && { 'If-Match': String(version) }),
},
body: JSON.stringify({
@@ -271,18 +152,18 @@ export class ComplianceClient {
)
if (!response.success) {
throw this.createError(response.error || 'Failed to save state', 500, true)
throw createHttpError(response.error || 'Failed to save state', 500, true)
}
return response.data!
}
async deleteState(): Promise<void> {
await this.fetchWithRetry<APIResponse<void>>(
await this.http.fetchWithRetry<APIResponse<void>>(
`${this.apiEndpoint}/state?tenantId=${encodeURIComponent(this.tenantId)}`,
{
method: 'DELETE',
headers: this.getHeaders(),
headers: this.http.getHeaders(),
}
)
}
@@ -295,34 +176,32 @@ export class ComplianceClient {
checkpointId: string,
data?: unknown
): Promise<CheckpointValidationResult> {
const response = await this.fetchWithRetry<APIResponse<CheckpointValidationResult>>(
`${this.apiEndpoint}/checkpoints/validate`,
{
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({
tenantId: this.tenantId,
checkpointId,
data,
}),
}
)
const response = await this.http.fetchWithRetry<
APIResponse<CheckpointValidationResult>
>(`${this.apiEndpoint}/checkpoints/validate`, {
method: 'POST',
headers: this.http.getHeaders(),
body: JSON.stringify({
tenantId: this.tenantId,
checkpointId,
data,
}),
})
if (!response.success || !response.data) {
throw this.createError(response.error || 'Checkpoint validation failed', 500, true)
throw createHttpError(response.error || 'Checkpoint validation failed', 500, true)
}
return response.data
}
async getCheckpoints(): Promise<Record<string, CheckpointStatus>> {
const response = await this.fetchWithRetry<APIResponse<Record<string, CheckpointStatus>>>(
`${this.apiEndpoint}/checkpoints?tenantId=${encodeURIComponent(this.tenantId)}`,
{
method: 'GET',
headers: this.getHeaders(),
}
)
const response = await this.http.fetchWithRetry<
APIResponse<Record<string, CheckpointStatus>>
>(`${this.apiEndpoint}/checkpoints?tenantId=${encodeURIComponent(this.tenantId)}`, {
method: 'GET',
headers: this.http.getHeaders(),
})
return response.data || {}
}
@@ -332,34 +211,34 @@ export class ComplianceClient {
// ---------------------------------------------------------------------------
async searchRAG(request: RAGSearchRequest): Promise<RAGSearchResponse> {
const response = await this.fetchWithRetry<APIResponse<RAGSearchResponse>>(
const response = await this.http.fetchWithRetry<APIResponse<RAGSearchResponse>>(
`${this.apiEndpoint}/rag/search`,
{
method: 'POST',
headers: this.getHeaders(),
headers: this.http.getHeaders(),
body: JSON.stringify(request),
}
)
if (!response.success || !response.data) {
throw this.createError(response.error || 'RAG search failed', 500, true)
throw createHttpError(response.error || 'RAG search failed', 500, true)
}
return response.data
}
async askRAG(request: RAGAskRequest): Promise<RAGAskResponse> {
const response = await this.fetchWithRetry<APIResponse<RAGAskResponse>>(
const response = await this.http.fetchWithRetry<APIResponse<RAGAskResponse>>(
`${this.apiEndpoint}/rag/ask`,
{
method: 'POST',
headers: this.getHeaders(),
headers: this.http.getHeaders(),
body: JSON.stringify(request),
}
)
if (!response.success || !response.data) {
throw this.createError(response.error || 'RAG query failed', 500, true)
throw createHttpError(response.error || 'RAG query failed', 500, true)
}
return response.data
@@ -370,12 +249,12 @@ export class ComplianceClient {
// ---------------------------------------------------------------------------
async exportState(format: ExportFormat): Promise<Blob> {
const response = await this.fetchWithTimeout(
const response = await this.http.fetchWithTimeout(
`${this.apiEndpoint}/export?tenantId=${encodeURIComponent(this.tenantId)}&format=${format}`,
{
method: 'GET',
headers: {
...this.getHeaders(),
...this.http.getHeaders(),
Accept:
format === 'json'
? 'application/json'
@@ -388,7 +267,7 @@ export class ComplianceClient {
)
if (!response.ok) {
throw this.createError(`Export failed: ${response.statusText}`, response.status, true)
throw createHttpError(`Export failed: ${response.statusText}`, response.status, true)
}
return response.blob()
@@ -402,22 +281,19 @@ export class ComplianceClient {
type: 'dsfa' | 'tom' | 'vvt' | 'gutachten' | 'privacy_policy' | 'cookie_banner',
options?: Record<string, unknown>
): Promise<{ id: string; status: string; content?: string }> {
const response = await this.fetchWithRetry<
const response = await this.http.fetchWithRetry<
APIResponse<{ id: string; status: string; content?: string }>
>(
`${this.apiEndpoint}/generate/${type}`,
{
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({
tenantId: this.tenantId,
options,
}),
}
)
>(`${this.apiEndpoint}/generate/${type}`, {
method: 'POST',
headers: this.http.getHeaders(),
body: JSON.stringify({
tenantId: this.tenantId,
options,
}),
})
if (!response.success || !response.data) {
throw this.createError(response.error || 'Document generation failed', 500, true)
throw createHttpError(response.error || 'Document generation failed', 500, true)
}
return response.data
@@ -433,31 +309,30 @@ export class ComplianceClient {
severityThreshold?: string
generateSBOM?: boolean
}): Promise<{ id: string; status: string }> {
const response = await this.fetchWithRetry<APIResponse<{ id: string; status: string }>>(
`${this.apiEndpoint}/security/scan`,
{
method: 'POST',
headers: this.getHeaders(),
body: JSON.stringify({
tenantId: this.tenantId,
...options,
}),
}
)
const response = await this.http.fetchWithRetry<
APIResponse<{ id: string; status: string }>
>(`${this.apiEndpoint}/security/scan`, {
method: 'POST',
headers: this.http.getHeaders(),
body: JSON.stringify({
tenantId: this.tenantId,
...options,
}),
})
if (!response.success || !response.data) {
throw this.createError(response.error || 'Security scan failed', 500, true)
throw createHttpError(response.error || 'Security scan failed', 500, true)
}
return response.data
}
async getSecurityScanResult(scanId: string): Promise<unknown> {
const response = await this.fetchWithRetry<APIResponse<unknown>>(
const response = await this.http.fetchWithRetry<APIResponse<unknown>>(
`${this.apiEndpoint}/security/scan/${scanId}`,
{
method: 'GET',
headers: this.getHeaders(),
headers: this.http.getHeaders(),
}
)
@@ -469,21 +344,20 @@ export class ComplianceClient {
// ---------------------------------------------------------------------------
cancelAllRequests(): void {
this.abortControllers.forEach(controller => controller.abort())
this.abortControllers.clear()
this.http.cancelAllRequests()
}
setTenantId(tenantId: string): void {
this.tenantId = tenantId
this.http.setTenantId(tenantId)
}
getTenantId(): string {
return this.tenantId
return this.http.getTenantId()
}
async healthCheck(): Promise<boolean> {
try {
const response = await this.fetchWithTimeout(
const response = await this.http.fetchWithTimeout(
`${this.apiEndpoint}/health`,
{ method: 'GET' },
`health-${Date.now()}`

View File

@@ -9,8 +9,6 @@ import type {
SearchResponse,
AssistantQuery,
AssistantResponse,
LegalDocument,
ChatSession,
ChatMessage,
} from '@breakpilot/compliance-sdk-types'
import { ComplianceClient } from '../client'
@@ -38,12 +36,20 @@ export class RAGModule {
scoreThreshold: options?.scoreThreshold ?? 0.5,
}
return this.client.searchRAG({
const response = await this.client.searchRAG({
query: searchRequest.query,
filters: searchRequest.filters,
limit: searchRequest.limit,
offset: searchRequest.offset,
})
// The client returns RAGSearchResponse (api.ts shape); enrich it with
// the originating query so the consumer-facing SearchResponse shape
// from types/rag.ts is satisfied.
return {
...response,
query: searchRequest.query,
} as unknown as SearchResponse
}
async searchByRegulation(regulation: string, query: string): Promise<SearchResponse> {