{"version":3,"file":"http-client.mjs","names":[],"sources":["../../src/utils/http-client.ts"],"sourcesContent":["import { Context, Data, Duration, Effect, Layer, Schedule } from \"effect\";\n\n// --- Tagged errors ---\n\nexport class FetchNetworkError extends Data.TaggedError(\"FetchNetworkError\")<{\n  url: string;\n  cause: unknown;\n}> {}\n\nexport class FetchTimeoutError extends Data.TaggedError(\"FetchTimeoutError\")<{\n  url: string;\n}> {}\n\nexport class FetchHttpError extends Data.TaggedError(\"FetchHttpError\")<{\n  url: string;\n  status: number;\n  statusText: string;\n}> {}\n\nexport type FetchError = FetchNetworkError | FetchTimeoutError | FetchHttpError;\n\n// --- Options ---\n\nexport interface FetchOptions {\n  method?: string;\n  headers?: Record<string, string>;\n  body?: BodyInit;\n  redirect?: RequestRedirect;\n  timeout?: Duration.Input;\n}\n\nexport interface FetchWithRetryOptions extends FetchOptions {\n  retries?: number;\n}\n\n// --- Defaults ---\n\nconst DEFAULT_TIMEOUT = \"10 seconds\";\nconst DEFAULT_RETRIES = 3;\n\nconst EXPONENTIAL_BASE = Duration.seconds(1);\nconst EXPONENTIAL_CAP = Duration.seconds(15);\n\ntype FetchImpl = typeof globalThis.fetch;\n\n// --- Helpers ---\n\nconst isRetryable = (error: FetchError): boolean => {\n  if (error instanceof FetchNetworkError) return true;\n  if (error instanceof FetchTimeoutError) return true;\n  if (error instanceof FetchHttpError) return error.status >= 500;\n  return false;\n};\n\nexport interface HttpFetchShape {\n  /** One network attempt: no status assertion, no internal deadline. The\n   * fiber's signal is composed with any caller-provided signal, so an\n   * `Effect.timeout` around the call aborts the in-flight request, and a\n   * caller-initiated cancellation still wins. */\n  readonly attempt: (\n    input: string | URL | Request,\n    init?: RequestInit,\n  ) => Effect.Effect<Response, FetchNetworkError>;\n  /** attempt + deadline + ok assertion (non-2xx → `FetchHttpError`) + capped\n   * exponential jittered retry (5xx/network/timeout retried, 4xx not). */\n  readonly get: (\n    url: string,\n    options?: FetchWithRetryOptions,\n  ) => Effect.Effect<Response, FetchError>;\n}\n\nconst makeHttpFetch = (\n  base: (input: string | URL | Request, init?: RequestInit) => Promise<Response>,\n): HttpFetchShape => {\n  const urlOf = (input: string | URL | Request): string =>\n    typeof input === \"string\" ? input : input instanceof URL ? input.href : input.url;\n\n  const attempt = (\n    input: string | URL | Request,\n    init?: RequestInit,\n  ): Effect.Effect<Response, FetchNetworkError> =>\n    Effect.tryPromise({\n      try: (signal) =>\n        base(input, {\n          ...init,\n          signal: init?.signal ? AbortSignal.any([init.signal, signal]) : signal,\n        }),\n      catch: (cause) => new FetchNetworkError({ url: urlOf(input), cause }),\n    });\n\n  const get = (\n    url: string,\n    options?: FetchWithRetryOptions,\n  ): Effect.Effect<Response, FetchError> => {\n    const timeoutMs = Duration.toMillis(options?.timeout ?? DEFAULT_TIMEOUT);\n    const retries = options?.retries ?? DEFAULT_RETRIES;\n    const once = attempt(url, {\n      method: options?.method ?? \"GET\",\n      headers: options?.headers,\n      body: options?.body,\n      redirect: options?.redirect,\n    }).pipe(\n      Effect.timeoutOrElse({\n        duration: timeoutMs,\n        orElse: () => Effect.fail(new FetchTimeoutError({ url })),\n      }),\n      Effect.filterOrFail(\n        (response) => response.ok,\n        (response) =>\n          new FetchHttpError({ url, status: response.status, statusText: response.statusText }),\n      ),\n    );\n    if (retries <= 0) return once;\n    return once.pipe(\n      Effect.retry({\n        schedule: Schedule.exponential(EXPONENTIAL_BASE).pipe(\n          Schedule.upTo({ duration: EXPONENTIAL_CAP, times: retries }),\n          Schedule.jittered,\n        ),\n        while: isRetryable,\n      }),\n    );\n  };\n\n  return { attempt, get };\n};\n\nconst defaultHttpFetch = makeHttpFetch((url, init) => globalThis.fetch(url, init));\n\n/**\n * The HTTP seam for boot-path consumers (bundle resolution, federation\n * loading, orchestrator deadlines): one resilience policy — interruption-\n * backed deadlines, capped exponential jittered retry, typed errors — with\n * the base fetch injectable so the bundle fetch interceptor can point it at\n * the pre-patch `fetch` and not re-enter itself.\n */\nexport class HttpFetch extends Context.Service<HttpFetch, HttpFetchShape>()(\n  \"everything-dev/http-client/HttpFetch\",\n) {\n  static layerWith(options: { fetchImpl: FetchImpl }): Layer.Layer<HttpFetch> {\n    return Layer.succeed(HttpFetch, makeHttpFetch(options.fetchImpl));\n  }\n\n  static readonly layer: Layer.Layer<HttpFetch> = Layer.succeed(HttpFetch, defaultHttpFetch);\n}\n\n// --- In-memory GET cache (per-process, prevents duplicate round trips) ---\n\nconst getCache = new Map<string, { data: unknown; expiresAt: number }>();\nconst GET_CACHE_TTL_MS = 30_000;\nconst GET_CACHE_MAX_ENTRIES = 128;\n\nexport function clearHttpCache(): void {\n  getCache.clear();\n}\n\nexport function httpCacheSize(): number {\n  return getCache.size;\n}\n\nfunction setCached(url: string, data: unknown): void {\n  const now = Date.now();\n  for (const [key, entry] of getCache) {\n    if (entry.expiresAt <= now) getCache.delete(key);\n  }\n  if (getCache.size >= GET_CACHE_MAX_ENTRIES) {\n    for (const key of getCache.keys()) {\n      getCache.delete(key);\n      if (getCache.size < GET_CACHE_MAX_ENTRIES) break;\n    }\n  }\n  getCache.set(url, { data, expiresAt: now + GET_CACHE_TTL_MS });\n}\n\nfunction isCacheable(_url: string, options?: FetchWithRetryOptions): boolean {\n  if (options?.method && options.method !== \"GET\") return false;\n  if (options?.body) return false;\n  return true;\n}\n\n// --- Promise wrappers (bridge for non-Effect code) — the ambient call-time\n// lookup means a boot fetch crossing the bundle interceptor still gains its\n// retry/deadline policy; the fiber-signal composition aborts the in-flight\n// request at the deadline. ---\n\n/** One raw fetch attempt with a deadline: returns the Response regardless of\n * status; rejects on network failure or deadline expiry. */\nexport const fetchRaw = (\n  url: string,\n  init?: RequestInit,\n  timeout: Duration.Input = DEFAULT_TIMEOUT,\n): Promise<Response> =>\n  Effect.runPromise(\n    defaultHttpFetch.attempt(url, init).pipe(\n      Effect.timeoutOrElse({\n        duration: timeout,\n        orElse: () => Effect.fail(new FetchTimeoutError({ url })),\n      }),\n    ),\n  );\n\nexport const fetchResponse = (url: string, options?: FetchOptions): Promise<Response> =>\n  Effect.runPromise(\n    defaultHttpFetch\n      .attempt(url, {\n        method: options?.method,\n        headers: options?.headers,\n        body: options?.body,\n        redirect: options?.redirect,\n      })\n      .pipe(\n        Effect.timeoutOrElse({\n          duration: options?.timeout ?? DEFAULT_TIMEOUT,\n          orElse: () => Effect.fail(new FetchTimeoutError({ url })),\n        }),\n      ),\n  );\n\nexport const fetchJsonOrNull = async <T>(\n  url: string,\n  options?: FetchWithRetryOptions,\n): Promise<T | null> => {\n  if (isCacheable(url, options)) {\n    const cached = getCache.get(url);\n    if (cached && cached.expiresAt > Date.now()) {\n      return cached.data as T;\n    }\n  }\n\n  try {\n    const res = await Effect.runPromise(defaultHttpFetch.get(url, options));\n    const data = (await res.json()) as T;\n    if (isCacheable(url, options)) {\n      setCached(url, data);\n    }\n    return data;\n  } catch (error) {\n    const msg =\n      error instanceof FetchNetworkError\n        ? `[http] Network error: ${error.url} — ${String(error.cause)}`\n        : error instanceof FetchTimeoutError\n          ? `[http] Timeout: ${error.url}`\n          : error instanceof FetchHttpError\n            ? `[http] HTTP ${error.status}: ${error.url}`\n            : `[http] Unknown error while fetching ${url}`;\n    console.error(msg);\n    return null;\n  }\n};\n\nexport const fetchWithRetryEff = (\n  url: string,\n  options?: FetchWithRetryOptions,\n): Effect.Effect<Response, FetchError> => defaultHttpFetch.get(url, options);\n"],"mappings":"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"}