/** * HTTP transport layer for the Deepline SDK. * * Handles authentication, retries with exponential backoff, localhost failover * (tries both `localhost` and `127.0.0.1`), and structured error mapping. * * ## Retry behavior * * - **Max attempts**: `1 + maxRetries` (default: 4 total attempts) * - **Backoff**: exponential — 1s, 2s, 4s, 8s, ... capped at 30s * - **Retryable**: network errors, timeouts, HTTP 429 (rate limit) * - **Not retryable**: HTTP 401 (auth error), HTTP 403 and other 4xx API errors * * ## Localhost failover * * Local development hosts try loopback variants. This handles DNS resolution * differences across platforms. * * @module */ import { existsSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import type { ResolvedConfig } from './types.js'; import { AuthError, DeeplineError, RateLimitError, ToolExecutionError, ToolRateLimitError, } from './errors.js'; import { deserializeToolExecutionFailure, serializeToolExecutionFailure, TOOL_EXECUTION_ERROR_SCHEMA_VERSION, } from '../../shared_libs/tool-execution-error.js'; import { SDK_API_CONTRACT, SDK_VERSION } from './version.js'; import type { LiveEventEnvelope } from './types.js'; import { baseUrlSlug, sdkCliStateDirPath } from './config.js'; import { detectAgentRuntime, isCoworkLikeSandbox } from './agent-runtime.js'; import { ABSURD_RELEASE_OVERRIDE_HEADER, COORDINATOR_INTERNAL_TOKEN_HEADER, COORDINATOR_URL_OVERRIDE_HEADER, RUNTIME_SCHEDULER_SCHEMA_OVERRIDE_HEADER, SYNTHETIC_RUN_HEADER, WORKER_CALLBACK_URL_OVERRIDE_HEADER, } from '../../shared_libs/play-runtime/coordinator-headers.js'; import { PLAY_RUNTIME_TEST_FAULT_HEADER } from '../../shared_libs/play-runtime/test-runtime-seams.js'; const MAX_DIAGNOSTIC_HEADER_LENGTH = 120; const COWORK_NETWORK_HINT = 'Claude Cowork appears to be running Deepline in a network-restricted sandbox. In Claude Desktop, open Settings > Capabilities, turn on Allow network egress, and set Domain allowlist to All domains for the Cowork session.'; const REQUEST_TIMEOUT_MARKER = Symbol('deeplineRequestTimeout'); const REQUEST_ABORT_MARKER = Symbol('deeplineRequestAbort'); interface RequestOptions { method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: unknown; formData?: FormData | (() => FormData); headers?: Record; /** Per-request timeout override in milliseconds. */ timeout?: number; /** * Retry HTTP 408/425/5xx JSON API responses. Use only for idempotent requests; * most mutating API calls must fail loudly after the first server response. */ retryApiErrors?: boolean; /** Per-request retry override. Use 0 for possibly delivered mutating calls. */ maxRetries?: number; /** Disable localhost/127.0.0.1 failover for non-idempotent requests. */ exactUrlOnly?: boolean; /** * Return a server-owned 429 envelope as a DeeplineError instead of replacing * it with the transport's generic RateLimitError. Lifecycle mutations use * this when their response explains whether the upstream change applied. */ preserveRateLimitResponse?: boolean; /** Enables endpoint-specific structured tool failure mapping. */ toolId?: string; } interface StreamOptions { method?: 'GET' | 'POST'; body?: unknown; headers?: Record; signal?: AbortSignal; } type RequestAbortTaggedError = Error & { [REQUEST_TIMEOUT_MARKER]?: number; [REQUEST_ABORT_MARKER]?: true; }; function normalizeRequestAbortError( error: unknown, input: { timeoutMs: number; timedOut: boolean }, ): Error { const normalized = error instanceof Error ? error : new Error(String(error)); const tagged = normalized as RequestAbortTaggedError; if (input.timedOut) { tagged[REQUEST_TIMEOUT_MARKER] = input.timeoutMs; } else if (isAbortLikeError(normalized)) { tagged[REQUEST_ABORT_MARKER] = true; } return normalized; } function isAbortLikeError(error: Error): boolean { const name = error.name.toLowerCase(); const message = error.message.toLowerCase(); return ( name === 'aborterror' || message.includes('operation was aborted') || message === 'aborted' ); } function mapNetworkError( baseUrl: string, error: Error | null, targetLabel: string, ): { message: string; code: string; details?: Record; } { const tagged = error as RequestAbortTaggedError | null; const timeoutMs = tagged?.[REQUEST_TIMEOUT_MARKER]; if (typeof timeoutMs === 'number') { return { message: `Request to ${targetLabel} timed out after ${timeoutMs}ms while waiting for a response.`, code: 'NETWORK_TIMEOUT', details: { timeoutMs, target: targetLabel }, }; } if (tagged?.[REQUEST_ABORT_MARKER]) { return { message: `Request to ${targetLabel} was aborted before it completed.`, code: 'NETWORK_ABORTED', }; } return { message: error?.message ? `Unable to connect to ${baseUrl}. ${error.message}` : `Unable to connect to ${baseUrl}. Is the computer able to access the url?`, code: 'NETWORK_ERROR', }; } function describeRequestTarget(path: string): string { const toolExecuteMatch = path.match( /^\/api\/v2\/integrations\/([^/?#]+)\/execute(?:[/?#]|$)/, ); if (toolExecuteMatch) { return providerLabelFromToolId(decodeURIComponent(toolExecuteMatch[1])); } return 'Deepline'; } function providerLabelFromToolId(toolId: string): string { const normalized = toolId.trim().toLowerCase(); const provider = normalized.split(/[_:./-]+/)[0]; return provider || normalized || 'provider'; } function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value); } /** * Low-level HTTP client used internally by {@link DeeplineClient}. * * You typically don't construct this directly — it's created automatically * when you instantiate `DeeplineClient` or call `Deepline.connect()`. * * @example * ```typescript * // Internal usage pattern: * const http = new HttpClient(resolvedConfig); * const tools = await http.get<{ tools: ToolDefinition[] }>('/api/v2/tools'); * const result = await http.post('/api/v2/integrations/apollo/execute', { payload: input }); * ``` */ export class HttpClient { constructor(private config: ResolvedConfig) {} private cleanDiagnosticHeader( value: string | null | undefined, ): string | null { const normalized = String(value ?? '') .replace(/[\u0000-\u001f\u007f]/g, ' ') .trim() .slice(0, MAX_DIAGNOSTIC_HEADER_LENGTH); return normalized || null; } private readSkillsVersionHeader(): string | null { const explicit = this.cleanDiagnosticHeader( process.env.DEEPLINE_SKILLS_VERSION, ); if (explicit) return explicit; try { const versionPath = join( sdkCliStateDirPath(this.config.baseUrl), 'skills-version', ); const legacyVersionPath = join( process.env.HOME?.trim() || homedir(), '.local', 'deepline', baseUrlSlug(this.config.baseUrl), 'sdk-skills', '.version', ); const resolvedPath = existsSync(versionPath) ? versionPath : legacyVersionPath; if (!existsSync(resolvedPath)) return null; return this.cleanDiagnosticHeader(readFileSync(resolvedPath, 'utf-8')); } catch { return null; } } private authHeaders(extra?: Record): Record { const headers: Record = { Authorization: `Bearer ${this.config.apiKey}`, 'User-Agent': `deepline-ts-sdk/${SDK_VERSION}`, 'X-Deepline-Client-Family': 'sdk', 'X-Deepline-CLI-Family': 'sdk', 'X-Deepline-Agent-Runtime': detectAgentRuntime(), 'X-Deepline-CLI-Version': SDK_VERSION, 'X-Deepline-SDK-Version': SDK_VERSION, 'X-Deepline-API-Contract': SDK_API_CONTRACT, 'X-Deepline-Run-Result-Shape': 'canonical', ...extra, }; const skillsVersion = this.readSkillsVersionHeader(); if (skillsVersion) { headers['X-Deepline-Skills-Version'] = skillsVersion; } const bypassToken = typeof process !== 'undefined' ? process.env?.VERCEL_PROTECTION_BYPASS_TOKEN : undefined; if (bypassToken) { headers['x-vercel-protection-bypass'] = bypassToken; } const playArtifactR2Prefix = typeof process !== 'undefined' ? process.env?.DEEPLINE_PLAY_ARTIFACT_R2_PREFIX : undefined; if (playArtifactR2Prefix) { headers['x-deepline-play-artifact-r2-prefix'] = playArtifactR2Prefix; } const coordinatorUrl = typeof process !== 'undefined' ? process.env?.DEEPLINE_COORDINATOR_URL : undefined; const coordinatorInternalToken = typeof process !== 'undefined' ? process.env?.DEEPLINE_INTERNAL_TOKEN : undefined; const absurdReleaseOverride = typeof process !== 'undefined' ? process.env?.DEEPLINE_ABSURD_RELEASE : undefined; if (coordinatorUrl?.trim()) { headers[COORDINATOR_URL_OVERRIDE_HEADER] = coordinatorUrl.trim(); } const workerCallbackUrl = typeof process !== 'undefined' ? process.env?.DEEPLINE_WORKER_CALLBACK_URL : undefined; if (workerCallbackUrl?.trim()) { headers[WORKER_CALLBACK_URL_OVERRIDE_HEADER] = workerCallbackUrl.trim(); } const runtimeSchedulerSchema = typeof process !== 'undefined' ? process.env?.DEEPLINE_RUNTIME_SCHEDULER_SCHEMA : undefined; if (runtimeSchedulerSchema?.trim()) { headers[RUNTIME_SCHEDULER_SCHEMA_OVERRIDE_HEADER] = runtimeSchedulerSchema.trim(); } // Automated test harnesses (e.g. tests/v2-plays) set DEEPLINE_SYNTHETIC_RUN // so their intentionally failing plays do not page the SDK CLI error // channel. Honored server-side only in non-prod (see plays/run route). const syntheticRun = typeof process !== 'undefined' ? process.env?.DEEPLINE_SYNTHETIC_RUN : undefined; if (syntheticRun && syntheticRun.trim() && syntheticRun.trim() !== '0') { headers[SYNTHETIC_RUN_HEADER] = '1'; } // Test-only runtime seam. The server validates both environment and value // before honoring this header. const runtimeTestFault = typeof process !== 'undefined' ? process.env?.DEEPLINE_TEST_RUNTIME_FAULT : undefined; if (runtimeTestFault?.trim()) { headers[PLAY_RUNTIME_TEST_FAULT_HEADER] = runtimeTestFault.trim(); } if (absurdReleaseOverride?.trim() && coordinatorInternalToken?.trim()) { headers[ABSURD_RELEASE_OVERRIDE_HEADER] = absurdReleaseOverride.trim(); } if ( coordinatorInternalToken?.trim() && (coordinatorUrl?.trim() || workerCallbackUrl?.trim() || runtimeTestFault?.trim() || absurdReleaseOverride?.trim()) ) { headers[COORDINATOR_INTERNAL_TOKEN_HEADER] = coordinatorInternalToken.trim(); } return headers; } /** * Send an HTTP request with automatic retries and error handling. * * @typeParam T - Expected response body type * @param path - API path (e.g. `"/api/v2/tools"`) * @param options - HTTP method, body, headers, and timeout * @returns Parsed JSON response body * @throws {@link AuthError} on Deepline-auth HTTP 401/403 (immediate, no retry) * @throws {@link RateLimitError} on HTTP 429 after all retries exhausted * @throws {@link DeeplineError} on other API errors or connection failures */ async request( path: string, options?: RequestOptions, ): Promise { const baseUrl = this.config.baseUrl; const url = `${baseUrl}${path}`; const method = options?.method ?? 'GET'; const headers = this.authHeaders(options?.headers); if (options?.body !== undefined) { headers['Content-Type'] = 'application/json'; } let lastError: Error | null = null; const candidateUrls = options?.exactUrlOnly ? [url] : buildCandidateUrls(url); let retryAfterDelayMs: number | null = null; const maxRetries = options?.maxRetries ?? this.config.maxRetries; for (let attempt = 0; attempt <= maxRetries; attempt++) { if (attempt > 0) { const backoffMs = Math.min(1000 * Math.pow(2, attempt - 1), 30_000); const delayMs = retryAfterDelayMs === null ? backoffMs : Math.max(backoffMs, retryAfterDelayMs); retryAfterDelayMs = null; await sleep(delayMs); } for (const candidateUrl of candidateUrls) { const controller = new AbortController(); const timeoutMs = options?.timeout ?? this.config.timeout; let requestTimedOut = false; const timeoutId = setTimeout(() => { requestTimedOut = true; controller.abort(); }, timeoutMs); try { const response = await fetch(candidateUrl, { method, headers, body: options?.formData !== undefined ? typeof options.formData === 'function' ? options.formData() : options.formData : options?.body !== undefined ? JSON.stringify(options.body) : undefined, signal: controller.signal, }); clearTimeout(timeoutId); const body = await response.text(); const parsed = parseResponseBody(body); const structuredToolError = options?.toolId && !response.ok ? deserializeToolExecutionFailure( apiErrorMessage(parsed, response.status), isRecord(parsed) ? (parsed as Record).tool_error : null, TOOL_EXECUTION_ERROR_SCHEMA_VERSION, ) : null; if (structuredToolError) { if (response.status === 429) { const retryAfter = parseRetryAfter(response); const failure = serializeToolExecutionFailure(structuredToolError); lastError = failure === null ? structuredToolError : new ToolRateLimitError(structuredToolError.message, { ...failure, retryAfterMs: failure.retryAfterMs ?? Math.max(0, retryAfter), }); if (attempt < maxRetries) { retryAfterDelayMs = retryAfter; break; } throw lastError; } throw structuredToolError; } if (response.status === 429) { if ( options?.preserveRateLimitResponse && isMonitorRateLimitEnvelope(parsed) ) { throw new DeeplineError( apiErrorMessage(parsed, response.status), response.status, apiErrorCodeFromResponse(parsed), { response: parsed }, ); } const retryAfter = parseRetryAfter(response); lastError = new RateLimitError(retryAfter); if (attempt < maxRetries) { retryAfterDelayMs = retryAfter; break; } throw lastError; } if ( response.status === 401 && !isProviderOriginatedHttpError(parsed) ) { throw new AuthError(apiErrorMessage(parsed, response.status)); } if (!response.ok) { const retryableApiError = options?.retryApiErrors === true && isRetryableApiErrorResponse(response.status); // A 5xx that escaped a Worker can return Cloudflare's default HTML // error page. Never surface raw HTML to CLI users: replace the // message with a structured summary and store a short flag in // details instead of the full HTML body. JSON-envelope handling // below is unchanged. const htmlError = detectHtmlErrorBody( body, response.headers.get('content-type'), ); if (htmlError) { lastError = new DeeplineError( htmlError.message(response.status), response.status, 'API_ERROR', { htmlErrorPage: true, ...(htmlError.title ? { title: htmlError.title } : {}), ...(htmlError.workerThrewException ? { workerThrewException: true } : {}), }, ); if (retryableApiError && attempt < maxRetries) { retryAfterDelayMs = parseOptionalRetryAfter(response); break; } throw lastError; } const errorValue = typeof parsed === 'object' && parsed && 'error' in parsed ? (parsed as Record).error : undefined; const msg = typeof errorValue === 'string' ? errorValue : errorValue && typeof errorValue === 'object' && 'message' in errorValue && typeof (errorValue as Record).message === 'string' ? (errorValue as Record).message : typeof parsed === 'object' && parsed && 'message' in parsed && typeof (parsed as Record).message === 'string' ? (parsed as Record).message : `HTTP ${response.status}`; const apiErrorCode = apiErrorCodeFromResponse(parsed); lastError = new DeeplineError(msg, response.status, apiErrorCode, { response: parsed, }); if (retryableApiError && attempt < maxRetries) { retryAfterDelayMs = parseOptionalRetryAfter(response); break; } throw lastError; } return parsed as T; } catch (error) { clearTimeout(timeoutId); if (error instanceof AuthError || error instanceof DeeplineError) { throw error; } if (error instanceof RateLimitError) { lastError = error; break; } lastError = normalizeRequestAbortError(error, { timeoutMs, timedOut: requestTimedOut, }); } } if (attempt < maxRetries) continue; } if (lastError instanceof DeeplineError) { throw lastError; } const mappedNetworkError = mapNetworkError( baseUrl, lastError, describeRequestTarget(path), ); if (options?.toolId) { const code = mappedNetworkError.code; throw new ToolExecutionError( withCoworkNetworkHint(mappedNetworkError.message), { toolId: options.toolId, provider: providerLabelFromToolId(options.toolId), operation: options.toolId, code, origin: 'deepline', category: 'network', // A direct client timeout has ambiguous delivery. Fail closed unless // the server returns the authoritative action delivery contract. retryable: false, statusCode: null, requestId: null, retryAfterMs: null, networkKind: code === 'NETWORK_TIMEOUT' ? 'timeout' : code === 'NETWORK_ABORTED' ? 'unknown' : 'unavailable', networkScope: 'client_to_deepline', details: mappedNetworkError.details, }, ); } throw new DeeplineError( withCoworkNetworkHint(mappedNetworkError.message), undefined, mappedNetworkError.code, mappedNetworkError.details, ); } /** * Send a GET request. * * @typeParam T - Expected response body type * @param path - API path (e.g. `"/api/v2/tools"`) */ async get( path: string, options?: Pick< RequestOptions, 'retryApiErrors' | 'timeout' | 'maxRetries' | 'exactUrlOnly' | 'toolId' >, ): Promise { return this.request(path, { method: 'GET', retryApiErrors: true, ...options, }); } async *streamSse( path: string, options?: StreamOptions, ): AsyncGenerator { const url = `${this.config.baseUrl}${path}`; const method = options?.method ?? 'GET'; const headers = this.authHeaders({ Accept: 'text/event-stream', ...options?.headers, }); if (options?.body !== undefined) { headers['Content-Type'] = 'application/json'; } let lastError: Error | null = null; for (const candidateUrl of buildCandidateUrls(url)) { try { const response = await fetch(candidateUrl, { method, headers, body: options?.body !== undefined ? JSON.stringify(options.body) : undefined, signal: options?.signal, }); if (!response.ok) { const body = await response.text(); const parsed = parseResponseBody(body); if ( response.status === 401 && !isProviderOriginatedHttpError(parsed) ) { throw new AuthError(apiErrorMessage(parsed, response.status)); } const htmlError = detectHtmlErrorBody( body, response.headers.get('content-type'), ); if (htmlError) { throw new DeeplineError( htmlError.message(response.status), response.status, 'API_ERROR', { htmlErrorPage: true, ...(htmlError.title ? { title: htmlError.title } : {}), ...(htmlError.workerThrewException ? { workerThrewException: true } : {}), }, ); } throw new DeeplineError( apiErrorMessage(parsed, response.status), response.status, apiErrorCodeFromResponse(parsed), { response: parsed }, ); } if (!response.body) { throw new DeeplineError('SSE response did not include a body.'); } yield* decodeSseStream(response.body); return; } catch (error) { if (error instanceof AuthError || error instanceof DeeplineError) { throw error; } const normalized = error instanceof Error ? error : new Error(String(error)); if (isAbortLikeError(normalized) && options?.signal?.aborted) { throw new DeeplineError( `Stream from ${this.config.baseUrl} was aborted by the caller.`, undefined, 'ABORTED', ); } lastError = normalized; } } if (lastError && isAbortLikeError(lastError)) { throw new DeeplineError( withCoworkNetworkHint( `Unable to stream from ${this.config.baseUrl}. The remote stream was interrupted.`, ), undefined, 'PLAY_STREAM_NETWORK_ABORTED', ); } throw new DeeplineError( withCoworkNetworkHint( lastError?.message ? `Unable to stream from ${this.config.baseUrl}. ${lastError.message}` : `Unable to stream from ${this.config.baseUrl}.`, ), ); } /** * Send a POST request with a JSON body. * * @typeParam T - Expected response body type * @param path - API path * @param body - Request body (will be JSON-serialized) */ async post( path: string, body: unknown, headers?: Record, options?: Pick< RequestOptions, 'retryApiErrors' | 'timeout' | 'maxRetries' | 'exactUrlOnly' | 'toolId' >, ): Promise { return this.request(path, { method: 'POST', body, headers, ...options, }); } async postFormData( path: string, formData: FormData | (() => FormData), headers?: Record, ): Promise { return this.request(path, { method: 'POST', formData, headers, }); } /** * Send a PATCH request with a JSON body. * * @typeParam T - Expected response body type * @param path - API path * @param body - JSON-serializable request body */ async patch( path: string, body?: unknown, headers?: Record, ): Promise { return this.request(path, { method: 'PATCH', body, headers, }); } async put( path: string, body?: unknown, headers?: Record, ): Promise { return this.request(path, { method: 'PUT', body, headers }); } /** * Send a DELETE request. * * @typeParam T - Expected response body type * @param path - API path */ async delete(path: string): Promise { return this.request(path, { method: 'DELETE' }); } } function parseResponseBody(body: string): unknown { try { return JSON.parse(body); } catch { return body; } } /** * A provider can reject its own credential with HTTP 401 while the caller's * Deepline API key remains valid. The API envelope is authoritative in that * case, so preserve it as a regular DeeplineError rather than prompting the * caller to replace DEEPLINE_API_KEY. */ function isProviderOriginatedHttpError(parsed: unknown): boolean { const response = asRecord(parsed); const error = asRecord(response?.error); const failureOrigin = error?.failure_origin ?? response?.failure_origin; const code = error?.code ?? response?.code; return failureOrigin === 'provider' || code === 'UPSTREAM_BLOCKED'; } /** * A monitor deployment may receive a safe, actionable 429 from Deepline after * an upstream lifecycle attempt. Do not treat a CDN/WAF 429 as that envelope: * it can be arbitrary HTML or text and must retain normal RateLimitError * behavior. */ function isMonitorRateLimitEnvelope(parsed: unknown): boolean { const response = asRecord(parsed); const error = asRecord(response?.error); return ( error?.code === 'monitor_upstream_rate_limited' && typeof error.message === 'string' ); } function apiErrorCodeFromResponse(parsed: unknown): string { const response = asRecord(parsed); const error = asRecord(response?.error); const code = error?.code ?? response?.code; return typeof code === 'string' ? code : 'API_ERROR'; } function asRecord(value: unknown): Record | null { return typeof value === 'object' && value !== null ? (value as Record) : null; } function isRetryableApiErrorResponse(status: number): boolean { return status === 408 || status === 425 || (status >= 500 && status < 600); } /** * Detect a raw Cloudflare HTML error page in an error response body. * * A 5xx that escaped a Worker can return Cloudflare's default HTML error page * ("Worker threw exception" / error code 1042). We must never surface raw HTML * to CLI users — it lands verbatim in `DeeplineError.message`/`details`. When * detected, we synthesize a structured message (status + extracted ) and * preserve the literal token `Worker threw exception` when present, since SDK * retry classifiers (`isTransientPlayStreamError`) regex-match on it. */ function detectHtmlErrorBody( body: string, contentType?: string | null, ): { title?: string; workerThrewException: boolean; message: (status: number) => string; } | null { const trimmed = body.trim(); const lower = trimmed.toLowerCase(); const isHtml = (contentType ?? '').toLowerCase().includes('text/html') || lower.startsWith('<!doctype') || lower.startsWith('<html'); if (!isHtml) { return null; } const titleMatch = trimmed.match(/<title[^>]*>([\s\S]*?)<\/title>/i); const title = titleMatch?.[1]?.replace(/\s+/g, ' ').trim() || undefined; const workerThrewException = /worker threw exception/i.test(trimmed); return { title, workerThrewException, message: (status: number): string => { // Detail segments joined with ': ', then the suppression note appended // in parens so it never reads as another colon-delimited segment. const segments = [`HTTP ${status}`]; if (workerThrewException) { segments.push('Worker threw exception'); } if (title) { segments.push(title); } return `${segments.join(': ')} (Cloudflare HTML error page suppressed)`; }, }; } function apiErrorMessage(parsed: unknown, status: number): string { const errorValue = typeof parsed === 'object' && parsed && 'error' in parsed ? (parsed as Record<string, unknown>).error : undefined; if (typeof errorValue === 'string') { return errorValue; } if ( errorValue && typeof errorValue === 'object' && 'message' in errorValue && typeof (errorValue as Record<string, unknown>).message === 'string' ) { return (errorValue as Record<string, string>).message; } if ( typeof parsed === 'object' && parsed && 'message' in parsed && typeof (parsed as Record<string, unknown>).message === 'string' ) { return (parsed as Record<string, string>).message; } return `HTTP ${status}`; } /** Parse the `Retry-After` header as milliseconds. Falls back to 5000ms. */ function parseRetryAfter(response: Response): number { return parseOptionalRetryAfter(response) ?? 5000; } function parseOptionalRetryAfter(response: Response): number | null { const header = response.headers.get('retry-after'); if (header) { const seconds = Number(header); if (Number.isFinite(seconds) && seconds > 0) { return seconds * 1000; } } return null; } /** * For local development URLs, generate loopback variants to handle platform DNS * differences. */ function buildCandidateUrls(url: string): string[] { try { const parsed = new URL(url); const candidates = [url]; if (parsed.hostname === 'localhost') { const loopback = new URL(url); loopback.hostname = '127.0.0.1'; candidates.push(loopback.toString()); } return [...new Set(candidates)]; } catch { return [url]; } } async function* decodeSseStream<TEvent extends LiveEventEnvelope>( body: ReadableStream<Uint8Array>, ): AsyncGenerator<TEvent> { const reader = body.getReader(); const decoder = new TextDecoder(); let buffered = ''; try { while (true) { const { value, done } = await reader.read(); if (done) break; buffered += decoder.decode(value, { stream: true }); const frames = buffered.split(/\r?\n\r?\n/); buffered = frames.pop() ?? ''; for (const frame of frames) { const event = decodeSseFrame<TEvent>(frame); if (event) { yield event; } } } buffered += decoder.decode(); const event = decodeSseFrame<TEvent>(buffered); if (event) { yield event; } } finally { reader.releaseLock(); } } function decodeSseFrame<TEvent extends LiveEventEnvelope>( frame: string, ): TEvent | null { const data = frame .split(/\r?\n/) .filter((line) => line.startsWith('data:')) .map((line) => line.slice('data:'.length).trimStart()) .join('\n') .trim(); if (!data) { return null; } const parsed = JSON.parse(data) as Partial<LiveEventEnvelope>; if ( !parsed || typeof parsed !== 'object' || typeof parsed.cursor !== 'string' || typeof parsed.streamId !== 'string' || typeof parsed.scope !== 'string' || typeof parsed.type !== 'string' || typeof parsed.at !== 'string' ) { return null; } return parsed as TEvent; } function sleep(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); } function withCoworkNetworkHint(message: string): string { if (!isCoworkLikeSandbox() || message.includes(COWORK_NETWORK_HINT)) { return message; } return `${message}\n${COWORK_NETWORK_HINT}`; }