@openstage/monadyssey-fetch 3.0.0-beta.1 → 3.0.0-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/monadyssey-fetch.cjs +1 -1
- package/dist/monadyssey-fetch.cjs.map +1 -1
- package/dist/monadyssey-fetch.d.ts +29 -7
- package/dist/monadyssey-fetch.mjs +28 -25
- package/dist/monadyssey-fetch.mjs.map +1 -1
- package/dist/monadyssey-fetch.umd.js +1 -1
- package/dist/monadyssey-fetch.umd.js.map +1 -1
- package/package.json +3 -3
- package/readme.md +3 -3
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require(`@openstage/monadyssey-core`);var t=e=>typeof e!=`object`||!e||e instanceof FormData||e instanceof Blob||e instanceof ArrayBuffer||e instanceof URLSearchParams||typeof ReadableStream<`u`&&e instanceof ReadableStream||ArrayBuffer.isView(e),n=e=>{let t={};for(let[n,r]of Object.entries(e))r!==void 0&&(t[n.toLowerCase()]=r);return t},r=(e,t,n)=>{let r=n;for(let t of[...e].reverse()){let e=r;r=n=>t.intercept(n,e)}return r(t)},i=async(e,t,n)=>{if(e.status===204||e.status===205)return null;try{switch(t){case`json`:return await e.json();case`text`:return await e.text();case`blob`:return await e.blob();case`arrayBuffer`:return await e.arrayBuffer();case`formData`:return await e.formData();default:throw a(`Unsupported response type: ${t}`,n,e)}}catch(t){throw t instanceof c?t:a(t,n,e)}},a=(e,t,n)=>{let r=o(n),i=n?n.body:null,a=e instanceof Error?e.message:typeof e==`string`?e:typeof e==`object`&&e&&`message`in e?String(e.message):`An unknown error occurred.`;return new c(n?.status||500,a,i,t,r)},o=e=>{if(!e)return{};try{return Object.fromEntries(e.headers.entries())}catch{return{}}},s=class{interceptors;baseUrl;defaultHeaders;defaultTimeout;defaultCredentials;constructor(e={}){this.interceptors=Object.freeze([...e.interceptors??[]]),this.baseUrl=e.baseUrl,this.defaultHeaders=e.defaultHeaders??{},this.defaultTimeout=e.timeout,this.defaultCredentials=e.credentials??`same-origin`}get(e,t){return this.request(e,`GET`,t)}post(e,t,n){return this.request(e,`POST`,{...n,body:t})}put(e,t,n){return this.request(e,`PUT`,{...n,body:t})}patch(e,t,n){return this.request(e,`PATCH`,{...n,body:t})}delete(e,t){return this.request(e,`DELETE`,t)}fetch(e,t,n={}){return this.request(e,t,n)}resolveUrl(e){if(!this.baseUrl)return e;try{return new URL(e),e}catch{return`${this.baseUrl.endsWith(`/`)?this.baseUrl.slice(0,-1):this.baseUrl}${e.startsWith(`/`)?e:`/${e}`}`}}request(s,l,u={}){let d=this.resolveUrl(s),f=u.timeout??this.defaultTimeout;return e.IO.cancellable(async e=>{let{headers:a={},body:s,responseType:p=`json`,credentials:m=this.defaultCredentials,observe:h=`body`,transform:g=e=>e}=u,_=n({...this.defaultHeaders,...a});typeof s==`object`&&s&&!t(s)&&!(`content-type`in _)&&(_[`content-type`]=`application/json`),s instanceof FormData&&delete _[`content-type`];let v=l===`GET`||l===`HEAD`||s==null?void 0:t(s)?s:_[`content-type`]===`application/json`?JSON.stringify(s):s,y,b,x=f==null?e:(y=new AbortController,e.addEventListener(`abort`,()=>y.abort(e.reason),{once:!0}),b=setTimeout(()=>y.abort(new DOMException(`Request timed out`,`TimeoutError`)),f),y.signal);try{let e={method:l,headers:_,credentials:m,body:v,signal:x},t=await r(this.interceptors,e,e=>fetch(d,e));if(!t.ok){let e=await i(t,p,d),n=o(t);throw new c(t.status,t.statusText,e,d,n)}return h===`response`?t:g(await i(t,p,d))}finally{b!=null&&clearTimeout(b)}},e=>e instanceof c?e:a(e,d))}},c=class extends Error{status;rawMessage;body;url;headers;constructor(e,t,n,r,i){super(`Request to '${r}' failed with status ${e} and message: ${t}.`),this.name=`HttpError`,this.status=e,this.rawMessage=t,this.body=n,this.url=r,this.headers=i}};exports.HttpClient=s,exports.HttpError=c;
|
|
1
|
+
Object.defineProperty(exports,Symbol.toStringTag,{value:`Module`});let e=require(`@openstage/monadyssey-core`);var t=e=>typeof e!=`object`||!e||e instanceof FormData||e instanceof Blob||e instanceof ArrayBuffer||e instanceof URLSearchParams||typeof ReadableStream<`u`&&e instanceof ReadableStream||ArrayBuffer.isView(e),n=e=>{let t={};for(let[n,r]of Object.entries(e))r!==void 0&&(t[n.toLowerCase()]=r);return t},r=(e,t,n)=>{let r=n;for(let t of[...e].reverse()){let e=r;r=n=>t.intercept(n,e)}return r(t)},i=async(e,t,n)=>{if(e.status===204||e.status===205)return null;try{switch(t){case`json`:return await e.json();case`text`:return await e.text();case`blob`:return await e.blob();case`arrayBuffer`:return await e.arrayBuffer();case`formData`:return await e.formData();default:throw a(`Unsupported response type: ${t}`,n,e)}}catch(t){throw t instanceof c?t:a(t,n,e)}},a=(e,t,n)=>{let r=o(n),i=n?n.body:null,a=e instanceof Error?e.message:typeof e==`string`?e:typeof e==`object`&&e&&`message`in e?String(e.message):`An unknown error occurred.`;return new c(n?.status||500,a,i,t,r)},o=e=>{if(!e)return{};try{return Object.fromEntries(e.headers.entries())}catch{return{}}},s=class{interceptors;baseUrl;defaultHeaders;defaultTimeout;defaultCredentials;constructor(e={}){this.interceptors=Object.freeze([...e.interceptors??[]]),this.baseUrl=e.baseUrl,this.defaultHeaders=e.defaultHeaders??{},this.defaultTimeout=e.timeout,this.defaultCredentials=e.credentials??`same-origin`}get(e,t){return this.request(e,`GET`,t)}post(e,t,n){return this.request(e,`POST`,{...n,body:t})}put(e,t,n){return this.request(e,`PUT`,{...n,body:t})}patch(e,t,n){return this.request(e,`PATCH`,{...n,body:t})}delete(e,t){return this.request(e,`DELETE`,t)}fetch(e,t,n={}){return this.request(e,t,n)}resolveUrl(e){if(!this.baseUrl)return e;try{return new URL(e),e}catch{return`${this.baseUrl.endsWith(`/`)?this.baseUrl.slice(0,-1):this.baseUrl}${e.startsWith(`/`)?e:`/${e}`}`}}request(s,l,u={}){let d=this.resolveUrl(s),f=u.timeout??this.defaultTimeout;return e.IO.cancellable(async e=>{let{headers:a={},body:s,responseType:p=`json`,credentials:m=this.defaultCredentials,observe:h=`body`,transform:g=e=>e}=u,_=n({...this.defaultHeaders,...a});typeof s==`object`&&s&&!t(s)&&!(`content-type`in _)&&(_[`content-type`]=`application/json`),s instanceof FormData&&delete _[`content-type`];let v=l===`GET`||l===`HEAD`||s==null?void 0:t(s)?s:_[`content-type`]===`application/json`?JSON.stringify(s):s,y,b,x=f==null?e:(y=new AbortController,e.addEventListener(`abort`,()=>y.abort(e.reason),{once:!0}),b=setTimeout(()=>y.abort(new DOMException(`Request timed out`,`TimeoutError`)),f),y.signal);try{let e={method:l,headers:_,credentials:m,body:v,signal:x},t=await r(this.interceptors,e,e=>fetch(d,e));if(!t.ok){let e=await i(t,p,d),n=o(t);throw new c(t.status,t.statusText,e,d,n)}return h===`response`?t:g(await i(t,p,d))}finally{b!=null&&clearTimeout(b)}},e=>e instanceof c?e:a(e,d))}},c=class extends Error{status;rawMessage;body;url;headers;constructor(e,t,n,r,i){super(`Request to '${r}' failed with status ${e} and message: ${t}.`),this.name=`HttpError`,this.status=e,this.rawMessage=t,this.body=n,this.url=r,this.headers=i}bodyAs(t){return t(this.body)?e.Some.pure(this.body):e.None.Instance}};exports.HttpClient=s,exports.HttpError=c;
|
|
2
2
|
//# sourceMappingURL=monadyssey-fetch.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"monadyssey-fetch.cjs","names":[],"sources":["../src/http-client.ts"],"sourcesContent":["import { IO } from \"@openstage/monadyssey-core\";\nimport { Credentials, HttpClientConfig, HttpInterceptor, Method, Options, ResponseType } from \"./options\";\n\n/**\n * Returns `true` if the body is a type that the fetch API knows how to send natively.\n * These types must NOT be JSON.stringified and must NOT have a Content-Type header auto-set\n * (the browser handles multipart boundaries for FormData, etc.).\n */\nconst isNativeBody = (body: unknown): boolean =>\n typeof body !== \"object\" ||\n body === null ||\n body instanceof FormData ||\n body instanceof Blob ||\n body instanceof ArrayBuffer ||\n body instanceof URLSearchParams ||\n (typeof ReadableStream !== \"undefined\" && body instanceof ReadableStream) ||\n ArrayBuffer.isView(body);\n\n/**\n * Normalizes header keys to lowercase for case-insensitive comparison.\n * HTTP header names are case-insensitive per RFC 7230.\n */\nconst normalizeHeaders = (headers: Record<string, string>): Record<string, string> => {\n const result: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n if (value !== undefined) {\n result[key.toLowerCase()] = value;\n }\n }\n return result;\n};\n\n/**\n * Builds the interceptor chain as a pure function. No global state.\n * Interceptors are applied in registration order — first registered is outermost.\n */\nconst runInterceptors = (\n interceptors: readonly HttpInterceptor[],\n req: RequestInit,\n fn: (req: RequestInit) => Promise<Response>\n): Promise<Response> => {\n let next = fn;\n for (const interceptor of [...interceptors].reverse()) {\n const currentNext = next;\n next = (r: RequestInit) => interceptor.intercept(r, currentNext);\n }\n return next(req);\n};\n\nconst parseBody = async (response: Response, responseType: ResponseType, url: string): Promise<unknown> => {\n if (response.status === 204 || response.status === 205) {\n return null;\n }\n try {\n switch (responseType) {\n case \"json\":\n return await response.json();\n case \"text\":\n return await response.text();\n case \"blob\":\n return await response.blob();\n case \"arrayBuffer\":\n return await response.arrayBuffer();\n case \"formData\":\n return await response.formData();\n default:\n throw toHttpError(`Unsupported response type: ${responseType}`, url, response);\n }\n } catch (e: unknown) {\n if (e instanceof HttpError) throw e;\n throw toHttpError(e, url, response);\n }\n};\n\nconst toHttpError = (e: unknown, uri: string, response?: Response): HttpError => {\n const headers = extractHeadersFrom(response);\n const body = response ? response.body : null;\n\n const message =\n e instanceof Error\n ? e.message\n : typeof e === \"string\"\n ? e\n : typeof e === \"object\" && e !== null && \"message\" in e\n ? String((e as any).message)\n : \"An unknown error occurred.\";\n\n return new HttpError(response?.status || 500, message, body, uri, headers);\n};\n\nconst extractHeadersFrom = (response?: Response): Record<string, string> => {\n if (!response) return {};\n try {\n return Object.fromEntries(response.headers.entries());\n } catch {\n return {};\n }\n};\n\n/**\n * A composable HTTP client that wraps the native `fetch` API, returning `IO` instances instead of Promises.\n *\n * Unlike v1, `HttpClient` is instantiable — each instance carries its own configuration (base URL,\n * interceptors, default headers, timeout, credentials). This allows different parts of an application\n * to use independently configured clients.\n *\n * All HTTP methods return `IO<HttpError, A | null>`, enabling lazy execution, functional composition,\n * and explicit error handling. Cancellation is supported: when an IO is cancelled (via fiber or\n * timeout), the underlying `fetch` call is aborted through `AbortSignal`.\n *\n * **On the `| null` in the return type:** when the server responds with `204 No Content` or\n * `205 Reset Content`, there is no body to parse and the IO succeeds with `null`. This is a\n * protocol-level fact (the HTTP spec says these statuses have no body), distinct from a\n * domain-level optionality. For that reason the methods return `IO<HttpError, A | null>` rather\n * than `IO<HttpError, Option<A>>`. `Option` is the right tool when a value might be absent at\n * the domain level; `| null` is honest when the absence is structural to the protocol. For the\n * 95% case where you control the endpoint and never receive 204, the `| null` is a brief\n * `?? defaultValue` away.\n *\n * **Default credentials:** `same-origin`, matching the platform `fetch` default. Set\n * `credentials: \"include\"` explicitly per-client or per-request if you need cookies to flow\n * cross-origin.\n *\n * @example\n * const api = new HttpClient({\n * baseUrl: \"https://api.example.com\",\n * interceptors: [authInterceptor],\n * defaultHeaders: { \"Accept\": \"application/json\" },\n * timeout: 5000,\n * });\n *\n * const users = api.get<User[]>(\"/users\");\n */\nexport class HttpClient {\n private readonly interceptors: readonly HttpInterceptor[];\n private readonly baseUrl: string | undefined;\n private readonly defaultHeaders: Record<string, string>;\n private readonly defaultTimeout: number | undefined;\n private readonly defaultCredentials: Credentials;\n\n /**\n * Creates a new HttpClient with the given configuration.\n *\n * @param {HttpClientConfig} config - Configuration for the client.\n */\n constructor(config: HttpClientConfig = {}) {\n this.interceptors = Object.freeze([...(config.interceptors ?? [])]);\n this.baseUrl = config.baseUrl;\n this.defaultHeaders = config.defaultHeaders ?? {};\n this.defaultTimeout = config.timeout;\n this.defaultCredentials = config.credentials ?? \"same-origin\";\n }\n\n /**\n * Performs a GET request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n get(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n get<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n get<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"GET\", options);\n }\n\n /**\n * Performs a POST request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n post(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n post<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n post<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"POST\", { ...options, body });\n }\n\n /**\n * Performs a PUT request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n put(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n put<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n put<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PUT\", { ...options, body });\n }\n\n /**\n * Performs a PATCH request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n patch(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n patch<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n patch<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PATCH\", { ...options, body });\n }\n\n /**\n * Performs a DELETE request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n delete(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n delete<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n delete<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"DELETE\", options);\n }\n\n /**\n * Performs a custom HTTP request with the specified method.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Method} method - The HTTP method.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n fetch(uri: string, method: Method, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n fetch<A = any>(uri: string, method: Method, options?: Options<A>): IO<HttpError, A | null>;\n fetch<A = any>(uri: string, method: Method, options: Options<A> = {}): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, method, options);\n }\n\n private resolveUrl(uri: string): string {\n if (!this.baseUrl) return uri;\n try {\n new URL(uri);\n return uri;\n } catch {\n const base = this.baseUrl.endsWith(\"/\") ? this.baseUrl.slice(0, -1) : this.baseUrl;\n const path = uri.startsWith(\"/\") ? uri : `/${uri}`;\n return `${base}${path}`;\n }\n }\n\n private request<A = any>(uri: string, method: Method, options: Options<A> = {}): IO<HttpError, Response | A | null> {\n const resolvedUrl = this.resolveUrl(uri);\n const timeout = options.timeout ?? this.defaultTimeout;\n\n return IO.cancellable<HttpError, Response | A | null>(\n async (signal: AbortSignal) => {\n const {\n headers = {},\n body,\n responseType = \"json\",\n credentials = this.defaultCredentials,\n observe = \"body\",\n transform = (data: unknown) => data as A,\n } = options;\n\n // Merge default headers + per-request headers, all lowercased\n const mergedHeaders = normalizeHeaders({ ...this.defaultHeaders, ...headers });\n\n // Auto-detect Content-Type only for plain objects (not FormData, Blob, etc.)\n const shouldAutoJson =\n body != null && typeof body === \"object\" && !isNativeBody(body) && !(\"content-type\" in mergedHeaders);\n\n if (shouldAutoJson) {\n mergedHeaders[\"content-type\"] = \"application/json\";\n }\n\n // For FormData, do NOT set Content-Type — the browser sets the multipart boundary\n if (body instanceof FormData) {\n delete mergedHeaders[\"content-type\"];\n }\n\n // Serialize body\n const serializedBody =\n method === \"GET\" || method === \"HEAD\" || body == null\n ? undefined\n : isNativeBody(body)\n ? body\n : mergedHeaders[\"content-type\"] === \"application/json\"\n ? JSON.stringify(body)\n : body;\n\n // Timeout support: create a child controller that aborts on timeout or parent signal\n let controller: AbortController | undefined;\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n\n const fetchSignal = (() => {\n if (timeout != null) {\n controller = new AbortController();\n const onParentAbort = () => controller!.abort(signal.reason);\n signal.addEventListener(\"abort\", onParentAbort, { once: true });\n timeoutId = setTimeout(\n () => controller!.abort(new DOMException(\"Request timed out\", \"TimeoutError\")),\n timeout\n );\n return controller.signal;\n }\n return signal;\n })();\n\n try {\n const requestInit: RequestInit = {\n method,\n headers: mergedHeaders,\n credentials,\n body: serializedBody as BodyInit | undefined,\n signal: fetchSignal,\n };\n\n const response = await runInterceptors(this.interceptors, requestInit, (req) => fetch(resolvedUrl, req));\n\n if (!response.ok) {\n const rb = await parseBody(response, responseType, resolvedUrl);\n const respHeaders = extractHeadersFrom(response);\n throw new HttpError(response.status, response.statusText, rb, resolvedUrl, respHeaders);\n }\n\n if (observe === \"response\") {\n return response;\n }\n\n const rb = await parseBody(response, responseType, resolvedUrl);\n return transform(rb);\n } finally {\n if (timeoutId != null) clearTimeout(timeoutId);\n }\n },\n (e: unknown) => (e instanceof HttpError ? e : toHttpError(e, resolvedUrl))\n );\n }\n}\n\n/**\n * Represents an HTTP error encountered during a request.\n *\n * Extends the native `Error` class with additional context: HTTP status code,\n * raw error message, response body, request URL, and response headers.\n */\nexport class HttpError extends Error {\n public readonly status: number;\n public readonly rawMessage: string;\n public readonly body: unknown;\n public readonly url: string;\n public readonly headers?: Record<string, string>;\n\n constructor(status: number, rawMessage: string, body: unknown, url: string, headers?: Record<string, string>) {\n super(`Request to '${url}' failed with status ${status} and message: ${rawMessage}.`);\n this.name = \"HttpError\";\n this.status = status;\n this.rawMessage = rawMessage;\n this.body = body;\n this.url = url;\n this.headers = headers;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"monadyssey-fetch.cjs","names":[],"sources":["../src/http-client.ts"],"sourcesContent":["import { IO, Option, Some, None } from \"@openstage/monadyssey-core\";\nimport { Credentials, HttpClientConfig, HttpInterceptor, Method, Options, ResponseType } from \"./options\";\n\n/**\n * Returns `true` if the body is a type that the fetch API knows how to send natively.\n * These types must NOT be JSON.stringified and must NOT have a Content-Type header auto-set\n * (the browser handles multipart boundaries for FormData, etc.).\n */\nconst isNativeBody = (body: unknown): boolean =>\n typeof body !== \"object\" ||\n body === null ||\n body instanceof FormData ||\n body instanceof Blob ||\n body instanceof ArrayBuffer ||\n body instanceof URLSearchParams ||\n (typeof ReadableStream !== \"undefined\" && body instanceof ReadableStream) ||\n ArrayBuffer.isView(body);\n\n/**\n * Normalizes header keys to lowercase for case-insensitive comparison.\n * HTTP header names are case-insensitive per RFC 7230.\n */\nconst normalizeHeaders = (headers: Record<string, string>): Record<string, string> => {\n const result: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n if (value !== undefined) {\n result[key.toLowerCase()] = value;\n }\n }\n return result;\n};\n\n/**\n * Builds the interceptor chain as a pure function. No global state.\n * Interceptors are applied in registration order — first registered is outermost.\n */\nconst runInterceptors = (\n interceptors: readonly HttpInterceptor[],\n req: RequestInit,\n fn: (req: RequestInit) => Promise<Response>\n): Promise<Response> => {\n let next = fn;\n for (const interceptor of [...interceptors].reverse()) {\n const currentNext = next;\n next = (r: RequestInit) => interceptor.intercept(r, currentNext);\n }\n return next(req);\n};\n\nconst parseBody = async (response: Response, responseType: ResponseType, url: string): Promise<unknown> => {\n if (response.status === 204 || response.status === 205) {\n return null;\n }\n try {\n switch (responseType) {\n case \"json\":\n return await response.json();\n case \"text\":\n return await response.text();\n case \"blob\":\n return await response.blob();\n case \"arrayBuffer\":\n return await response.arrayBuffer();\n case \"formData\":\n return await response.formData();\n default:\n throw toHttpError(`Unsupported response type: ${responseType}`, url, response);\n }\n } catch (e: unknown) {\n if (e instanceof HttpError) throw e;\n throw toHttpError(e, url, response);\n }\n};\n\nconst toHttpError = (e: unknown, uri: string, response?: Response): HttpError => {\n const headers = extractHeadersFrom(response);\n const body = response ? response.body : null;\n\n const message =\n e instanceof Error\n ? e.message\n : typeof e === \"string\"\n ? e\n : typeof e === \"object\" && e !== null && \"message\" in e\n ? String((e as any).message)\n : \"An unknown error occurred.\";\n\n return new HttpError(response?.status || 500, message, body, uri, headers);\n};\n\nconst extractHeadersFrom = (response?: Response): Record<string, string> => {\n if (!response) return {};\n try {\n return Object.fromEntries(response.headers.entries());\n } catch {\n return {};\n }\n};\n\n/**\n * A composable HTTP client that wraps the native `fetch` API, returning `IO` instances instead of Promises.\n *\n * Unlike v1, `HttpClient` is instantiable — each instance carries its own configuration (base URL,\n * interceptors, default headers, timeout, credentials). This allows different parts of an application\n * to use independently configured clients.\n *\n * All HTTP methods return `IO<HttpError, A | null>`, enabling lazy execution, functional composition,\n * and explicit error handling. Cancellation is supported: when an IO is cancelled (via fiber or\n * timeout), the underlying `fetch` call is aborted through `AbortSignal`.\n *\n * **On the `| null` in the return type:** when the server responds with `204 No Content` or\n * `205 Reset Content`, there is no body to parse and the IO succeeds with `null`. This is a\n * protocol-level fact (the HTTP spec says these statuses have no body), distinct from a\n * domain-level optionality. For that reason the methods return `IO<HttpError, A | null>` rather\n * than `IO<HttpError, Option<A>>`. `Option` is the right tool when a value might be absent at\n * the domain level; `| null` is honest when the absence is structural to the protocol. For the\n * 95% case where you control the endpoint and never receive 204, the `| null` is a brief\n * `?? defaultValue` away.\n *\n * **Default credentials:** `same-origin`, matching the platform `fetch` default. Set\n * `credentials: \"include\"` explicitly per-client or per-request if you need cookies to flow\n * cross-origin.\n *\n * @example\n * const api = new HttpClient({\n * baseUrl: \"https://api.example.com\",\n * interceptors: [authInterceptor],\n * defaultHeaders: { \"Accept\": \"application/json\" },\n * timeout: 5000,\n * });\n *\n * const users = api.get<User[]>(\"/users\");\n */\nexport class HttpClient {\n private readonly interceptors: readonly HttpInterceptor[];\n private readonly baseUrl: string | undefined;\n private readonly defaultHeaders: Record<string, string>;\n private readonly defaultTimeout: number | undefined;\n private readonly defaultCredentials: Credentials;\n\n /**\n * Creates a new HttpClient with the given configuration.\n *\n * @param {HttpClientConfig} config - Configuration for the client.\n */\n constructor(config: HttpClientConfig = {}) {\n this.interceptors = Object.freeze([...(config.interceptors ?? [])]);\n this.baseUrl = config.baseUrl;\n this.defaultHeaders = config.defaultHeaders ?? {};\n this.defaultTimeout = config.timeout;\n this.defaultCredentials = config.credentials ?? \"same-origin\";\n }\n\n /**\n * Performs a GET request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n get(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n get<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n get<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"GET\", options);\n }\n\n /**\n * Performs a POST request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n post(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n post<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n post<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"POST\", { ...options, body });\n }\n\n /**\n * Performs a PUT request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n put(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n put<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n put<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PUT\", { ...options, body });\n }\n\n /**\n * Performs a PATCH request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n patch(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n patch<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n patch<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PATCH\", { ...options, body });\n }\n\n /**\n * Performs a DELETE request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n delete(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n delete<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n delete<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"DELETE\", options);\n }\n\n /**\n * Performs a custom HTTP request with the specified method.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Method} method - The HTTP method.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n fetch(uri: string, method: Method, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n fetch<A = unknown>(uri: string, method: Method, options?: Options<A>): IO<HttpError, A | null>;\n fetch<A = unknown>(uri: string, method: Method, options: Options<A> = {}): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, method, options);\n }\n\n private resolveUrl(uri: string): string {\n if (!this.baseUrl) return uri;\n try {\n new URL(uri);\n return uri;\n } catch {\n const base = this.baseUrl.endsWith(\"/\") ? this.baseUrl.slice(0, -1) : this.baseUrl;\n const path = uri.startsWith(\"/\") ? uri : `/${uri}`;\n return `${base}${path}`;\n }\n }\n\n private request<A = unknown>(\n uri: string,\n method: Method,\n options: Options<A> = {}\n ): IO<HttpError, Response | A | null> {\n const resolvedUrl = this.resolveUrl(uri);\n const timeout = options.timeout ?? this.defaultTimeout;\n\n return IO.cancellable<HttpError, Response | A | null>(\n async (signal: AbortSignal) => {\n const {\n headers = {},\n body,\n responseType = \"json\",\n credentials = this.defaultCredentials,\n observe = \"body\",\n transform = (data: unknown) => data as A,\n } = options;\n\n const mergedHeaders = normalizeHeaders({ ...this.defaultHeaders, ...headers });\n\n const shouldAutoJson =\n body != null && typeof body === \"object\" && !isNativeBody(body) && !(\"content-type\" in mergedHeaders);\n\n if (shouldAutoJson) {\n mergedHeaders[\"content-type\"] = \"application/json\";\n }\n\n if (body instanceof FormData) {\n delete mergedHeaders[\"content-type\"];\n }\n\n const serializedBody =\n method === \"GET\" || method === \"HEAD\" || body == null\n ? undefined\n : isNativeBody(body)\n ? body\n : mergedHeaders[\"content-type\"] === \"application/json\"\n ? JSON.stringify(body)\n : body;\n\n let controller: AbortController | undefined;\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n\n const fetchSignal = (() => {\n if (timeout != null) {\n controller = new AbortController();\n const onParentAbort = () => controller!.abort(signal.reason);\n signal.addEventListener(\"abort\", onParentAbort, { once: true });\n timeoutId = setTimeout(\n () => controller!.abort(new DOMException(\"Request timed out\", \"TimeoutError\")),\n timeout\n );\n return controller.signal;\n }\n return signal;\n })();\n\n try {\n const requestInit: RequestInit = {\n method,\n headers: mergedHeaders,\n credentials,\n body: serializedBody as BodyInit | undefined,\n signal: fetchSignal,\n };\n\n const response = await runInterceptors(this.interceptors, requestInit, (req) => fetch(resolvedUrl, req));\n\n if (!response.ok) {\n const rb = await parseBody(response, responseType, resolvedUrl);\n const respHeaders = extractHeadersFrom(response);\n throw new HttpError(response.status, response.statusText, rb, resolvedUrl, respHeaders);\n }\n\n if (observe === \"response\") {\n return response;\n }\n\n const rb = await parseBody(response, responseType, resolvedUrl);\n return transform(rb);\n } finally {\n if (timeoutId != null) clearTimeout(timeoutId);\n }\n },\n (e: unknown) => (e instanceof HttpError ? e : toHttpError(e, resolvedUrl))\n );\n }\n}\n\n/**\n * Represents an HTTP error encountered during a request.\n *\n * Extends the native `Error` class with additional context: HTTP status code,\n * raw error message, response body, request URL, and response headers.\n */\nexport class HttpError extends Error {\n public readonly status: number;\n public readonly rawMessage: string;\n public readonly body: unknown;\n public readonly url: string;\n public readonly headers?: Record<string, string>;\n\n constructor(status: number, rawMessage: string, body: unknown, url: string, headers?: Record<string, string>) {\n super(`Request to '${url}' failed with status ${status} and message: ${rawMessage}.`);\n this.name = \"HttpError\";\n this.status = status;\n this.rawMessage = rawMessage;\n this.body = body;\n this.url = url;\n this.headers = headers;\n }\n\n /**\n * Narrows the `unknown` error body to a concrete type using a runtime type guard.\n * Returns `Some(body)` if the guard passes, `None` otherwise — so the narrow is *checked*,\n * not asserted. This keeps the unsafe cast out of consumer code: the body stays `unknown`\n * by default (forcing a conscious narrow), and this is the safe way to perform it.\n *\n * @template T The expected body shape.\n * @param {(x: unknown) => x is T} guard A type guard validating the body's shape.\n * @returns {Option<T>} `Some(body)` if the guard matches, `None` otherwise.\n *\n * @example\n * interface ApiError { code: string; message: string }\n * const isApiError = (x: unknown): x is ApiError =>\n * typeof x === \"object\" && x !== null && \"code\" in x && \"message\" in x;\n *\n * httpError.bodyAs(isApiError).fold(\n * () => \"unrecognized error shape\",\n * (e) => `${e.code}: ${e.message}`\n * );\n */\n bodyAs<T>(guard: (x: unknown) => x is T): Option<T> {\n return guard(this.body) ? (Some.pure(this.body as NonNullable<T>) as Option<T>) : (None.Instance as Option<T>);\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { IO } from '@openstage/monadyssey-core';
|
|
2
|
+
import { Option as Option_2 } from '@openstage/monadyssey-core';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Specifies the credentials policy for the request.
|
|
@@ -65,7 +66,7 @@ export declare class HttpClient {
|
|
|
65
66
|
get(uri: string, options: Omit<Options, "body"> & {
|
|
66
67
|
observe: "response";
|
|
67
68
|
}): IO<HttpError, Response>;
|
|
68
|
-
get<A =
|
|
69
|
+
get<A = unknown>(uri: string, options?: Omit<Options<A>, "body">): IO<HttpError, A | null>;
|
|
69
70
|
/**
|
|
70
71
|
* Performs a POST request.
|
|
71
72
|
*
|
|
@@ -78,7 +79,7 @@ export declare class HttpClient {
|
|
|
78
79
|
post(uri: string, body: unknown, options: Options & {
|
|
79
80
|
observe: "response";
|
|
80
81
|
}): IO<HttpError, Response>;
|
|
81
|
-
post<A =
|
|
82
|
+
post<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;
|
|
82
83
|
/**
|
|
83
84
|
* Performs a PUT request.
|
|
84
85
|
*
|
|
@@ -91,7 +92,7 @@ export declare class HttpClient {
|
|
|
91
92
|
put(uri: string, body: unknown, options: Options & {
|
|
92
93
|
observe: "response";
|
|
93
94
|
}): IO<HttpError, Response>;
|
|
94
|
-
put<A =
|
|
95
|
+
put<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;
|
|
95
96
|
/**
|
|
96
97
|
* Performs a PATCH request.
|
|
97
98
|
*
|
|
@@ -104,7 +105,7 @@ export declare class HttpClient {
|
|
|
104
105
|
patch(uri: string, body: unknown, options: Options & {
|
|
105
106
|
observe: "response";
|
|
106
107
|
}): IO<HttpError, Response>;
|
|
107
|
-
patch<A =
|
|
108
|
+
patch<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;
|
|
108
109
|
/**
|
|
109
110
|
* Performs a DELETE request.
|
|
110
111
|
*
|
|
@@ -116,7 +117,7 @@ export declare class HttpClient {
|
|
|
116
117
|
delete(uri: string, options: Omit<Options, "body"> & {
|
|
117
118
|
observe: "response";
|
|
118
119
|
}): IO<HttpError, Response>;
|
|
119
|
-
delete<A =
|
|
120
|
+
delete<A = unknown>(uri: string, options?: Omit<Options<A>, "body">): IO<HttpError, A | null>;
|
|
120
121
|
/**
|
|
121
122
|
* Performs a custom HTTP request with the specified method.
|
|
122
123
|
*
|
|
@@ -129,7 +130,7 @@ export declare class HttpClient {
|
|
|
129
130
|
fetch(uri: string, method: Method, options: Options & {
|
|
130
131
|
observe: "response";
|
|
131
132
|
}): IO<HttpError, Response>;
|
|
132
|
-
fetch<A =
|
|
133
|
+
fetch<A = unknown>(uri: string, method: Method, options?: Options<A>): IO<HttpError, A | null>;
|
|
133
134
|
private resolveUrl;
|
|
134
135
|
private request;
|
|
135
136
|
}
|
|
@@ -163,6 +164,27 @@ export declare class HttpError extends Error {
|
|
|
163
164
|
readonly url: string;
|
|
164
165
|
readonly headers?: Record<string, string>;
|
|
165
166
|
constructor(status: number, rawMessage: string, body: unknown, url: string, headers?: Record<string, string>);
|
|
167
|
+
/**
|
|
168
|
+
* Narrows the `unknown` error body to a concrete type using a runtime type guard.
|
|
169
|
+
* Returns `Some(body)` if the guard passes, `None` otherwise — so the narrow is *checked*,
|
|
170
|
+
* not asserted. This keeps the unsafe cast out of consumer code: the body stays `unknown`
|
|
171
|
+
* by default (forcing a conscious narrow), and this is the safe way to perform it.
|
|
172
|
+
*
|
|
173
|
+
* @template T The expected body shape.
|
|
174
|
+
* @param {(x: unknown) => x is T} guard A type guard validating the body's shape.
|
|
175
|
+
* @returns {Option<T>} `Some(body)` if the guard matches, `None` otherwise.
|
|
176
|
+
*
|
|
177
|
+
* @example
|
|
178
|
+
* interface ApiError { code: string; message: string }
|
|
179
|
+
* const isApiError = (x: unknown): x is ApiError =>
|
|
180
|
+
* typeof x === "object" && x !== null && "code" in x && "message" in x;
|
|
181
|
+
*
|
|
182
|
+
* httpError.bodyAs(isApiError).fold(
|
|
183
|
+
* () => "unrecognized error shape",
|
|
184
|
+
* (e) => `${e.code}: ${e.message}`
|
|
185
|
+
* );
|
|
186
|
+
*/
|
|
187
|
+
bodyAs<T>(guard: (x: unknown) => x is T): Option_2<T>;
|
|
166
188
|
}
|
|
167
189
|
|
|
168
190
|
/**
|
|
@@ -200,7 +222,7 @@ export declare type Observe = "body" | "response";
|
|
|
200
222
|
*
|
|
201
223
|
* @template A - The expected type of the response body after transformation.
|
|
202
224
|
*/
|
|
203
|
-
export declare type Options<A =
|
|
225
|
+
export declare type Options<A = unknown> = {
|
|
204
226
|
/** Custom headers for the request as key-value pairs. */
|
|
205
227
|
headers?: Record<string, string>;
|
|
206
228
|
/** The request payload. */
|
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import { IO as e } from "@openstage/monadyssey-core";
|
|
1
|
+
import { IO as e, None as t, Some as n } from "@openstage/monadyssey-core";
|
|
2
2
|
//#region src/http-client.ts
|
|
3
|
-
var
|
|
3
|
+
var r = (e) => typeof e != "object" || !e || e instanceof FormData || e instanceof Blob || e instanceof ArrayBuffer || e instanceof URLSearchParams || typeof ReadableStream < "u" && e instanceof ReadableStream || ArrayBuffer.isView(e), i = (e) => {
|
|
4
4
|
let t = {};
|
|
5
5
|
for (let [n, r] of Object.entries(e)) r !== void 0 && (t[n.toLowerCase()] = r);
|
|
6
6
|
return t;
|
|
7
|
-
},
|
|
7
|
+
}, a = (e, t, n) => {
|
|
8
8
|
let r = n;
|
|
9
9
|
for (let t of [...e].reverse()) {
|
|
10
10
|
let e = r;
|
|
11
11
|
r = (n) => t.intercept(n, e);
|
|
12
12
|
}
|
|
13
13
|
return r(t);
|
|
14
|
-
},
|
|
14
|
+
}, o = async (e, t, n) => {
|
|
15
15
|
if (e.status === 204 || e.status === 205) return null;
|
|
16
16
|
try {
|
|
17
17
|
switch (t) {
|
|
@@ -20,22 +20,22 @@ var t = (e) => typeof e != "object" || !e || e instanceof FormData || e instance
|
|
|
20
20
|
case "blob": return await e.blob();
|
|
21
21
|
case "arrayBuffer": return await e.arrayBuffer();
|
|
22
22
|
case "formData": return await e.formData();
|
|
23
|
-
default: throw
|
|
23
|
+
default: throw s(`Unsupported response type: ${t}`, n, e);
|
|
24
24
|
}
|
|
25
25
|
} catch (t) {
|
|
26
|
-
throw t instanceof
|
|
26
|
+
throw t instanceof u ? t : s(t, n, e);
|
|
27
27
|
}
|
|
28
|
-
},
|
|
29
|
-
let r =
|
|
30
|
-
return new
|
|
31
|
-
},
|
|
28
|
+
}, s = (e, t, n) => {
|
|
29
|
+
let r = c(n), i = n ? n.body : null, a = e instanceof Error ? e.message : typeof e == "string" ? e : typeof e == "object" && e && "message" in e ? String(e.message) : "An unknown error occurred.";
|
|
30
|
+
return new u(n?.status || 500, a, i, t, r);
|
|
31
|
+
}, c = (e) => {
|
|
32
32
|
if (!e) return {};
|
|
33
33
|
try {
|
|
34
34
|
return Object.fromEntries(e.headers.entries());
|
|
35
35
|
} catch {
|
|
36
36
|
return {};
|
|
37
37
|
}
|
|
38
|
-
},
|
|
38
|
+
}, l = class {
|
|
39
39
|
interceptors;
|
|
40
40
|
baseUrl;
|
|
41
41
|
defaultHeaders;
|
|
@@ -79,34 +79,34 @@ var t = (e) => typeof e != "object" || !e || e instanceof FormData || e instance
|
|
|
79
79
|
return `${this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl}${e.startsWith("/") ? e : `/${e}`}`;
|
|
80
80
|
}
|
|
81
81
|
}
|
|
82
|
-
request(
|
|
83
|
-
let d = this.resolveUrl(
|
|
82
|
+
request(t, n, l = {}) {
|
|
83
|
+
let d = this.resolveUrl(t), f = l.timeout ?? this.defaultTimeout;
|
|
84
84
|
return e.cancellable(async (e) => {
|
|
85
|
-
let { headers:
|
|
85
|
+
let { headers: t = {}, body: s, responseType: p = "json", credentials: m = this.defaultCredentials, observe: h = "body", transform: g = (e) => e } = l, _ = i({
|
|
86
86
|
...this.defaultHeaders,
|
|
87
|
-
...
|
|
87
|
+
...t
|
|
88
88
|
});
|
|
89
|
-
typeof s == "object" && s && !
|
|
90
|
-
let v =
|
|
89
|
+
typeof s == "object" && s && !r(s) && !("content-type" in _) && (_["content-type"] = "application/json"), s instanceof FormData && delete _["content-type"];
|
|
90
|
+
let v = n === "GET" || n === "HEAD" || s == null ? void 0 : r(s) ? s : _["content-type"] === "application/json" ? JSON.stringify(s) : s, y, b, x = f == null ? e : (y = new AbortController(), e.addEventListener("abort", () => y.abort(e.reason), { once: !0 }), b = setTimeout(() => y.abort(new DOMException("Request timed out", "TimeoutError")), f), y.signal);
|
|
91
91
|
try {
|
|
92
92
|
let e = {
|
|
93
|
-
method:
|
|
93
|
+
method: n,
|
|
94
94
|
headers: _,
|
|
95
95
|
credentials: m,
|
|
96
96
|
body: v,
|
|
97
97
|
signal: x
|
|
98
|
-
}, t = await
|
|
98
|
+
}, t = await a(this.interceptors, e, (e) => fetch(d, e));
|
|
99
99
|
if (!t.ok) {
|
|
100
|
-
let e = await
|
|
101
|
-
throw new
|
|
100
|
+
let e = await o(t, p, d), n = c(t);
|
|
101
|
+
throw new u(t.status, t.statusText, e, d, n);
|
|
102
102
|
}
|
|
103
|
-
return h === "response" ? t : g(await
|
|
103
|
+
return h === "response" ? t : g(await o(t, p, d));
|
|
104
104
|
} finally {
|
|
105
105
|
b != null && clearTimeout(b);
|
|
106
106
|
}
|
|
107
|
-
}, (e) => e instanceof
|
|
107
|
+
}, (e) => e instanceof u ? e : s(e, d));
|
|
108
108
|
}
|
|
109
|
-
},
|
|
109
|
+
}, u = class extends Error {
|
|
110
110
|
status;
|
|
111
111
|
rawMessage;
|
|
112
112
|
body;
|
|
@@ -115,8 +115,11 @@ var t = (e) => typeof e != "object" || !e || e instanceof FormData || e instance
|
|
|
115
115
|
constructor(e, t, n, r, i) {
|
|
116
116
|
super(`Request to '${r}' failed with status ${e} and message: ${t}.`), this.name = "HttpError", this.status = e, this.rawMessage = t, this.body = n, this.url = r, this.headers = i;
|
|
117
117
|
}
|
|
118
|
+
bodyAs(e) {
|
|
119
|
+
return e(this.body) ? n.pure(this.body) : t.Instance;
|
|
120
|
+
}
|
|
118
121
|
};
|
|
119
122
|
//#endregion
|
|
120
|
-
export {
|
|
123
|
+
export { l as HttpClient, u as HttpError };
|
|
121
124
|
|
|
122
125
|
//# sourceMappingURL=monadyssey-fetch.mjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"monadyssey-fetch.mjs","names":[],"sources":["../src/http-client.ts"],"sourcesContent":["import { IO } from \"@openstage/monadyssey-core\";\nimport { Credentials, HttpClientConfig, HttpInterceptor, Method, Options, ResponseType } from \"./options\";\n\n/**\n * Returns `true` if the body is a type that the fetch API knows how to send natively.\n * These types must NOT be JSON.stringified and must NOT have a Content-Type header auto-set\n * (the browser handles multipart boundaries for FormData, etc.).\n */\nconst isNativeBody = (body: unknown): boolean =>\n typeof body !== \"object\" ||\n body === null ||\n body instanceof FormData ||\n body instanceof Blob ||\n body instanceof ArrayBuffer ||\n body instanceof URLSearchParams ||\n (typeof ReadableStream !== \"undefined\" && body instanceof ReadableStream) ||\n ArrayBuffer.isView(body);\n\n/**\n * Normalizes header keys to lowercase for case-insensitive comparison.\n * HTTP header names are case-insensitive per RFC 7230.\n */\nconst normalizeHeaders = (headers: Record<string, string>): Record<string, string> => {\n const result: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n if (value !== undefined) {\n result[key.toLowerCase()] = value;\n }\n }\n return result;\n};\n\n/**\n * Builds the interceptor chain as a pure function. No global state.\n * Interceptors are applied in registration order — first registered is outermost.\n */\nconst runInterceptors = (\n interceptors: readonly HttpInterceptor[],\n req: RequestInit,\n fn: (req: RequestInit) => Promise<Response>\n): Promise<Response> => {\n let next = fn;\n for (const interceptor of [...interceptors].reverse()) {\n const currentNext = next;\n next = (r: RequestInit) => interceptor.intercept(r, currentNext);\n }\n return next(req);\n};\n\nconst parseBody = async (response: Response, responseType: ResponseType, url: string): Promise<unknown> => {\n if (response.status === 204 || response.status === 205) {\n return null;\n }\n try {\n switch (responseType) {\n case \"json\":\n return await response.json();\n case \"text\":\n return await response.text();\n case \"blob\":\n return await response.blob();\n case \"arrayBuffer\":\n return await response.arrayBuffer();\n case \"formData\":\n return await response.formData();\n default:\n throw toHttpError(`Unsupported response type: ${responseType}`, url, response);\n }\n } catch (e: unknown) {\n if (e instanceof HttpError) throw e;\n throw toHttpError(e, url, response);\n }\n};\n\nconst toHttpError = (e: unknown, uri: string, response?: Response): HttpError => {\n const headers = extractHeadersFrom(response);\n const body = response ? response.body : null;\n\n const message =\n e instanceof Error\n ? e.message\n : typeof e === \"string\"\n ? e\n : typeof e === \"object\" && e !== null && \"message\" in e\n ? String((e as any).message)\n : \"An unknown error occurred.\";\n\n return new HttpError(response?.status || 500, message, body, uri, headers);\n};\n\nconst extractHeadersFrom = (response?: Response): Record<string, string> => {\n if (!response) return {};\n try {\n return Object.fromEntries(response.headers.entries());\n } catch {\n return {};\n }\n};\n\n/**\n * A composable HTTP client that wraps the native `fetch` API, returning `IO` instances instead of Promises.\n *\n * Unlike v1, `HttpClient` is instantiable — each instance carries its own configuration (base URL,\n * interceptors, default headers, timeout, credentials). This allows different parts of an application\n * to use independently configured clients.\n *\n * All HTTP methods return `IO<HttpError, A | null>`, enabling lazy execution, functional composition,\n * and explicit error handling. Cancellation is supported: when an IO is cancelled (via fiber or\n * timeout), the underlying `fetch` call is aborted through `AbortSignal`.\n *\n * **On the `| null` in the return type:** when the server responds with `204 No Content` or\n * `205 Reset Content`, there is no body to parse and the IO succeeds with `null`. This is a\n * protocol-level fact (the HTTP spec says these statuses have no body), distinct from a\n * domain-level optionality. For that reason the methods return `IO<HttpError, A | null>` rather\n * than `IO<HttpError, Option<A>>`. `Option` is the right tool when a value might be absent at\n * the domain level; `| null` is honest when the absence is structural to the protocol. For the\n * 95% case where you control the endpoint and never receive 204, the `| null` is a brief\n * `?? defaultValue` away.\n *\n * **Default credentials:** `same-origin`, matching the platform `fetch` default. Set\n * `credentials: \"include\"` explicitly per-client or per-request if you need cookies to flow\n * cross-origin.\n *\n * @example\n * const api = new HttpClient({\n * baseUrl: \"https://api.example.com\",\n * interceptors: [authInterceptor],\n * defaultHeaders: { \"Accept\": \"application/json\" },\n * timeout: 5000,\n * });\n *\n * const users = api.get<User[]>(\"/users\");\n */\nexport class HttpClient {\n private readonly interceptors: readonly HttpInterceptor[];\n private readonly baseUrl: string | undefined;\n private readonly defaultHeaders: Record<string, string>;\n private readonly defaultTimeout: number | undefined;\n private readonly defaultCredentials: Credentials;\n\n /**\n * Creates a new HttpClient with the given configuration.\n *\n * @param {HttpClientConfig} config - Configuration for the client.\n */\n constructor(config: HttpClientConfig = {}) {\n this.interceptors = Object.freeze([...(config.interceptors ?? [])]);\n this.baseUrl = config.baseUrl;\n this.defaultHeaders = config.defaultHeaders ?? {};\n this.defaultTimeout = config.timeout;\n this.defaultCredentials = config.credentials ?? \"same-origin\";\n }\n\n /**\n * Performs a GET request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n get(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n get<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n get<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"GET\", options);\n }\n\n /**\n * Performs a POST request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n post(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n post<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n post<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"POST\", { ...options, body });\n }\n\n /**\n * Performs a PUT request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n put(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n put<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n put<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PUT\", { ...options, body });\n }\n\n /**\n * Performs a PATCH request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n patch(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n patch<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n patch<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PATCH\", { ...options, body });\n }\n\n /**\n * Performs a DELETE request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n delete(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n delete<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n delete<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"DELETE\", options);\n }\n\n /**\n * Performs a custom HTTP request with the specified method.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Method} method - The HTTP method.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n fetch(uri: string, method: Method, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n fetch<A = any>(uri: string, method: Method, options?: Options<A>): IO<HttpError, A | null>;\n fetch<A = any>(uri: string, method: Method, options: Options<A> = {}): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, method, options);\n }\n\n private resolveUrl(uri: string): string {\n if (!this.baseUrl) return uri;\n try {\n new URL(uri);\n return uri;\n } catch {\n const base = this.baseUrl.endsWith(\"/\") ? this.baseUrl.slice(0, -1) : this.baseUrl;\n const path = uri.startsWith(\"/\") ? uri : `/${uri}`;\n return `${base}${path}`;\n }\n }\n\n private request<A = any>(uri: string, method: Method, options: Options<A> = {}): IO<HttpError, Response | A | null> {\n const resolvedUrl = this.resolveUrl(uri);\n const timeout = options.timeout ?? this.defaultTimeout;\n\n return IO.cancellable<HttpError, Response | A | null>(\n async (signal: AbortSignal) => {\n const {\n headers = {},\n body,\n responseType = \"json\",\n credentials = this.defaultCredentials,\n observe = \"body\",\n transform = (data: unknown) => data as A,\n } = options;\n\n // Merge default headers + per-request headers, all lowercased\n const mergedHeaders = normalizeHeaders({ ...this.defaultHeaders, ...headers });\n\n // Auto-detect Content-Type only for plain objects (not FormData, Blob, etc.)\n const shouldAutoJson =\n body != null && typeof body === \"object\" && !isNativeBody(body) && !(\"content-type\" in mergedHeaders);\n\n if (shouldAutoJson) {\n mergedHeaders[\"content-type\"] = \"application/json\";\n }\n\n // For FormData, do NOT set Content-Type — the browser sets the multipart boundary\n if (body instanceof FormData) {\n delete mergedHeaders[\"content-type\"];\n }\n\n // Serialize body\n const serializedBody =\n method === \"GET\" || method === \"HEAD\" || body == null\n ? undefined\n : isNativeBody(body)\n ? body\n : mergedHeaders[\"content-type\"] === \"application/json\"\n ? JSON.stringify(body)\n : body;\n\n // Timeout support: create a child controller that aborts on timeout or parent signal\n let controller: AbortController | undefined;\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n\n const fetchSignal = (() => {\n if (timeout != null) {\n controller = new AbortController();\n const onParentAbort = () => controller!.abort(signal.reason);\n signal.addEventListener(\"abort\", onParentAbort, { once: true });\n timeoutId = setTimeout(\n () => controller!.abort(new DOMException(\"Request timed out\", \"TimeoutError\")),\n timeout\n );\n return controller.signal;\n }\n return signal;\n })();\n\n try {\n const requestInit: RequestInit = {\n method,\n headers: mergedHeaders,\n credentials,\n body: serializedBody as BodyInit | undefined,\n signal: fetchSignal,\n };\n\n const response = await runInterceptors(this.interceptors, requestInit, (req) => fetch(resolvedUrl, req));\n\n if (!response.ok) {\n const rb = await parseBody(response, responseType, resolvedUrl);\n const respHeaders = extractHeadersFrom(response);\n throw new HttpError(response.status, response.statusText, rb, resolvedUrl, respHeaders);\n }\n\n if (observe === \"response\") {\n return response;\n }\n\n const rb = await parseBody(response, responseType, resolvedUrl);\n return transform(rb);\n } finally {\n if (timeoutId != null) clearTimeout(timeoutId);\n }\n },\n (e: unknown) => (e instanceof HttpError ? e : toHttpError(e, resolvedUrl))\n );\n }\n}\n\n/**\n * Represents an HTTP error encountered during a request.\n *\n * Extends the native `Error` class with additional context: HTTP status code,\n * raw error message, response body, request URL, and response headers.\n */\nexport class HttpError extends Error {\n public readonly status: number;\n public readonly rawMessage: string;\n public readonly body: unknown;\n public readonly url: string;\n public readonly headers?: Record<string, string>;\n\n constructor(status: number, rawMessage: string, body: unknown, url: string, headers?: Record<string, string>) {\n super(`Request to '${url}' failed with status ${status} and message: ${rawMessage}.`);\n this.name = \"HttpError\";\n this.status = status;\n this.rawMessage = rawMessage;\n this.body = body;\n this.url = url;\n this.headers = headers;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"monadyssey-fetch.mjs","names":[],"sources":["../src/http-client.ts"],"sourcesContent":["import { IO, Option, Some, None } from \"@openstage/monadyssey-core\";\nimport { Credentials, HttpClientConfig, HttpInterceptor, Method, Options, ResponseType } from \"./options\";\n\n/**\n * Returns `true` if the body is a type that the fetch API knows how to send natively.\n * These types must NOT be JSON.stringified and must NOT have a Content-Type header auto-set\n * (the browser handles multipart boundaries for FormData, etc.).\n */\nconst isNativeBody = (body: unknown): boolean =>\n typeof body !== \"object\" ||\n body === null ||\n body instanceof FormData ||\n body instanceof Blob ||\n body instanceof ArrayBuffer ||\n body instanceof URLSearchParams ||\n (typeof ReadableStream !== \"undefined\" && body instanceof ReadableStream) ||\n ArrayBuffer.isView(body);\n\n/**\n * Normalizes header keys to lowercase for case-insensitive comparison.\n * HTTP header names are case-insensitive per RFC 7230.\n */\nconst normalizeHeaders = (headers: Record<string, string>): Record<string, string> => {\n const result: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n if (value !== undefined) {\n result[key.toLowerCase()] = value;\n }\n }\n return result;\n};\n\n/**\n * Builds the interceptor chain as a pure function. No global state.\n * Interceptors are applied in registration order — first registered is outermost.\n */\nconst runInterceptors = (\n interceptors: readonly HttpInterceptor[],\n req: RequestInit,\n fn: (req: RequestInit) => Promise<Response>\n): Promise<Response> => {\n let next = fn;\n for (const interceptor of [...interceptors].reverse()) {\n const currentNext = next;\n next = (r: RequestInit) => interceptor.intercept(r, currentNext);\n }\n return next(req);\n};\n\nconst parseBody = async (response: Response, responseType: ResponseType, url: string): Promise<unknown> => {\n if (response.status === 204 || response.status === 205) {\n return null;\n }\n try {\n switch (responseType) {\n case \"json\":\n return await response.json();\n case \"text\":\n return await response.text();\n case \"blob\":\n return await response.blob();\n case \"arrayBuffer\":\n return await response.arrayBuffer();\n case \"formData\":\n return await response.formData();\n default:\n throw toHttpError(`Unsupported response type: ${responseType}`, url, response);\n }\n } catch (e: unknown) {\n if (e instanceof HttpError) throw e;\n throw toHttpError(e, url, response);\n }\n};\n\nconst toHttpError = (e: unknown, uri: string, response?: Response): HttpError => {\n const headers = extractHeadersFrom(response);\n const body = response ? response.body : null;\n\n const message =\n e instanceof Error\n ? e.message\n : typeof e === \"string\"\n ? e\n : typeof e === \"object\" && e !== null && \"message\" in e\n ? String((e as any).message)\n : \"An unknown error occurred.\";\n\n return new HttpError(response?.status || 500, message, body, uri, headers);\n};\n\nconst extractHeadersFrom = (response?: Response): Record<string, string> => {\n if (!response) return {};\n try {\n return Object.fromEntries(response.headers.entries());\n } catch {\n return {};\n }\n};\n\n/**\n * A composable HTTP client that wraps the native `fetch` API, returning `IO` instances instead of Promises.\n *\n * Unlike v1, `HttpClient` is instantiable — each instance carries its own configuration (base URL,\n * interceptors, default headers, timeout, credentials). This allows different parts of an application\n * to use independently configured clients.\n *\n * All HTTP methods return `IO<HttpError, A | null>`, enabling lazy execution, functional composition,\n * and explicit error handling. Cancellation is supported: when an IO is cancelled (via fiber or\n * timeout), the underlying `fetch` call is aborted through `AbortSignal`.\n *\n * **On the `| null` in the return type:** when the server responds with `204 No Content` or\n * `205 Reset Content`, there is no body to parse and the IO succeeds with `null`. This is a\n * protocol-level fact (the HTTP spec says these statuses have no body), distinct from a\n * domain-level optionality. For that reason the methods return `IO<HttpError, A | null>` rather\n * than `IO<HttpError, Option<A>>`. `Option` is the right tool when a value might be absent at\n * the domain level; `| null` is honest when the absence is structural to the protocol. For the\n * 95% case where you control the endpoint and never receive 204, the `| null` is a brief\n * `?? defaultValue` away.\n *\n * **Default credentials:** `same-origin`, matching the platform `fetch` default. Set\n * `credentials: \"include\"` explicitly per-client or per-request if you need cookies to flow\n * cross-origin.\n *\n * @example\n * const api = new HttpClient({\n * baseUrl: \"https://api.example.com\",\n * interceptors: [authInterceptor],\n * defaultHeaders: { \"Accept\": \"application/json\" },\n * timeout: 5000,\n * });\n *\n * const users = api.get<User[]>(\"/users\");\n */\nexport class HttpClient {\n private readonly interceptors: readonly HttpInterceptor[];\n private readonly baseUrl: string | undefined;\n private readonly defaultHeaders: Record<string, string>;\n private readonly defaultTimeout: number | undefined;\n private readonly defaultCredentials: Credentials;\n\n /**\n * Creates a new HttpClient with the given configuration.\n *\n * @param {HttpClientConfig} config - Configuration for the client.\n */\n constructor(config: HttpClientConfig = {}) {\n this.interceptors = Object.freeze([...(config.interceptors ?? [])]);\n this.baseUrl = config.baseUrl;\n this.defaultHeaders = config.defaultHeaders ?? {};\n this.defaultTimeout = config.timeout;\n this.defaultCredentials = config.credentials ?? \"same-origin\";\n }\n\n /**\n * Performs a GET request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n get(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n get<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n get<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"GET\", options);\n }\n\n /**\n * Performs a POST request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n post(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n post<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n post<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"POST\", { ...options, body });\n }\n\n /**\n * Performs a PUT request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n put(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n put<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n put<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PUT\", { ...options, body });\n }\n\n /**\n * Performs a PATCH request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n patch(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n patch<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n patch<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PATCH\", { ...options, body });\n }\n\n /**\n * Performs a DELETE request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n delete(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n delete<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n delete<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"DELETE\", options);\n }\n\n /**\n * Performs a custom HTTP request with the specified method.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Method} method - The HTTP method.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n fetch(uri: string, method: Method, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n fetch<A = unknown>(uri: string, method: Method, options?: Options<A>): IO<HttpError, A | null>;\n fetch<A = unknown>(uri: string, method: Method, options: Options<A> = {}): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, method, options);\n }\n\n private resolveUrl(uri: string): string {\n if (!this.baseUrl) return uri;\n try {\n new URL(uri);\n return uri;\n } catch {\n const base = this.baseUrl.endsWith(\"/\") ? this.baseUrl.slice(0, -1) : this.baseUrl;\n const path = uri.startsWith(\"/\") ? uri : `/${uri}`;\n return `${base}${path}`;\n }\n }\n\n private request<A = unknown>(\n uri: string,\n method: Method,\n options: Options<A> = {}\n ): IO<HttpError, Response | A | null> {\n const resolvedUrl = this.resolveUrl(uri);\n const timeout = options.timeout ?? this.defaultTimeout;\n\n return IO.cancellable<HttpError, Response | A | null>(\n async (signal: AbortSignal) => {\n const {\n headers = {},\n body,\n responseType = \"json\",\n credentials = this.defaultCredentials,\n observe = \"body\",\n transform = (data: unknown) => data as A,\n } = options;\n\n const mergedHeaders = normalizeHeaders({ ...this.defaultHeaders, ...headers });\n\n const shouldAutoJson =\n body != null && typeof body === \"object\" && !isNativeBody(body) && !(\"content-type\" in mergedHeaders);\n\n if (shouldAutoJson) {\n mergedHeaders[\"content-type\"] = \"application/json\";\n }\n\n if (body instanceof FormData) {\n delete mergedHeaders[\"content-type\"];\n }\n\n const serializedBody =\n method === \"GET\" || method === \"HEAD\" || body == null\n ? undefined\n : isNativeBody(body)\n ? body\n : mergedHeaders[\"content-type\"] === \"application/json\"\n ? JSON.stringify(body)\n : body;\n\n let controller: AbortController | undefined;\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n\n const fetchSignal = (() => {\n if (timeout != null) {\n controller = new AbortController();\n const onParentAbort = () => controller!.abort(signal.reason);\n signal.addEventListener(\"abort\", onParentAbort, { once: true });\n timeoutId = setTimeout(\n () => controller!.abort(new DOMException(\"Request timed out\", \"TimeoutError\")),\n timeout\n );\n return controller.signal;\n }\n return signal;\n })();\n\n try {\n const requestInit: RequestInit = {\n method,\n headers: mergedHeaders,\n credentials,\n body: serializedBody as BodyInit | undefined,\n signal: fetchSignal,\n };\n\n const response = await runInterceptors(this.interceptors, requestInit, (req) => fetch(resolvedUrl, req));\n\n if (!response.ok) {\n const rb = await parseBody(response, responseType, resolvedUrl);\n const respHeaders = extractHeadersFrom(response);\n throw new HttpError(response.status, response.statusText, rb, resolvedUrl, respHeaders);\n }\n\n if (observe === \"response\") {\n return response;\n }\n\n const rb = await parseBody(response, responseType, resolvedUrl);\n return transform(rb);\n } finally {\n if (timeoutId != null) clearTimeout(timeoutId);\n }\n },\n (e: unknown) => (e instanceof HttpError ? e : toHttpError(e, resolvedUrl))\n );\n }\n}\n\n/**\n * Represents an HTTP error encountered during a request.\n *\n * Extends the native `Error` class with additional context: HTTP status code,\n * raw error message, response body, request URL, and response headers.\n */\nexport class HttpError extends Error {\n public readonly status: number;\n public readonly rawMessage: string;\n public readonly body: unknown;\n public readonly url: string;\n public readonly headers?: Record<string, string>;\n\n constructor(status: number, rawMessage: string, body: unknown, url: string, headers?: Record<string, string>) {\n super(`Request to '${url}' failed with status ${status} and message: ${rawMessage}.`);\n this.name = \"HttpError\";\n this.status = status;\n this.rawMessage = rawMessage;\n this.body = body;\n this.url = url;\n this.headers = headers;\n }\n\n /**\n * Narrows the `unknown` error body to a concrete type using a runtime type guard.\n * Returns `Some(body)` if the guard passes, `None` otherwise — so the narrow is *checked*,\n * not asserted. This keeps the unsafe cast out of consumer code: the body stays `unknown`\n * by default (forcing a conscious narrow), and this is the safe way to perform it.\n *\n * @template T The expected body shape.\n * @param {(x: unknown) => x is T} guard A type guard validating the body's shape.\n * @returns {Option<T>} `Some(body)` if the guard matches, `None` otherwise.\n *\n * @example\n * interface ApiError { code: string; message: string }\n * const isApiError = (x: unknown): x is ApiError =>\n * typeof x === \"object\" && x !== null && \"code\" in x && \"message\" in x;\n *\n * httpError.bodyAs(isApiError).fold(\n * () => \"unrecognized error shape\",\n * (e) => `${e.code}: ${e.message}`\n * );\n */\n bodyAs<T>(guard: (x: unknown) => x is T): Option<T> {\n return guard(this.body) ? (Some.pure(this.body as NonNullable<T>) as Option<T>) : (None.Instance as Option<T>);\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
(function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports,require(`@openstage/monadyssey-core`)):typeof define==`function`&&define.amd?define([`exports`,`@openstage/monadyssey-core`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e[`monadyssey-fetch`]={},e.Monadyssey))})(this,function(e,t){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var n=e=>typeof e!=`object`||!e||e instanceof FormData||e instanceof Blob||e instanceof ArrayBuffer||e instanceof URLSearchParams||typeof ReadableStream<`u`&&e instanceof ReadableStream||ArrayBuffer.isView(e),r=e=>{let t={};for(let[n,r]of Object.entries(e))r!==void 0&&(t[n.toLowerCase()]=r);return t},i=(e,t,n)=>{let r=n;for(let t of[...e].reverse()){let e=r;r=n=>t.intercept(n,e)}return r(t)},a=async(e,t,n)=>{if(e.status===204||e.status===205)return null;try{switch(t){case`json`:return await e.json();case`text`:return await e.text();case`blob`:return await e.blob();case`arrayBuffer`:return await e.arrayBuffer();case`formData`:return await e.formData();default:throw o(`Unsupported response type: ${t}`,n,e)}}catch(t){throw t instanceof l?t:o(t,n,e)}},o=(e,t,n)=>{let r=s(n),i=n?n.body:null,a=e instanceof Error?e.message:typeof e==`string`?e:typeof e==`object`&&e&&`message`in e?String(e.message):`An unknown error occurred.`;return new l(n?.status||500,a,i,t,r)},s=e=>{if(!e)return{};try{return Object.fromEntries(e.headers.entries())}catch{return{}}},c=class{interceptors;baseUrl;defaultHeaders;defaultTimeout;defaultCredentials;constructor(e={}){this.interceptors=Object.freeze([...e.interceptors??[]]),this.baseUrl=e.baseUrl,this.defaultHeaders=e.defaultHeaders??{},this.defaultTimeout=e.timeout,this.defaultCredentials=e.credentials??`same-origin`}get(e,t){return this.request(e,`GET`,t)}post(e,t,n){return this.request(e,`POST`,{...n,body:t})}put(e,t,n){return this.request(e,`PUT`,{...n,body:t})}patch(e,t,n){return this.request(e,`PATCH`,{...n,body:t})}delete(e,t){return this.request(e,`DELETE`,t)}fetch(e,t,n={}){return this.request(e,t,n)}resolveUrl(e){if(!this.baseUrl)return e;try{return new URL(e),e}catch{return`${this.baseUrl.endsWith(`/`)?this.baseUrl.slice(0,-1):this.baseUrl}${e.startsWith(`/`)?e:`/${e}`}`}}request(e,c,u={}){let d=this.resolveUrl(e),f=u.timeout??this.defaultTimeout;return t.IO.cancellable(async e=>{let{headers:t={},body:o,responseType:p=`json`,credentials:m=this.defaultCredentials,observe:h=`body`,transform:g=e=>e}=u,_=r({...this.defaultHeaders,...t});typeof o==`object`&&o&&!n(o)&&!(`content-type`in _)&&(_[`content-type`]=`application/json`),o instanceof FormData&&delete _[`content-type`];let v=c===`GET`||c===`HEAD`||o==null?void 0:n(o)?o:_[`content-type`]===`application/json`?JSON.stringify(o):o,y,b,x=f==null?e:(y=new AbortController,e.addEventListener(`abort`,()=>y.abort(e.reason),{once:!0}),b=setTimeout(()=>y.abort(new DOMException(`Request timed out`,`TimeoutError`)),f),y.signal);try{let e={method:c,headers:_,credentials:m,body:v,signal:x},t=await i(this.interceptors,e,e=>fetch(d,e));if(!t.ok){let e=await a(t,p,d),n=s(t);throw new l(t.status,t.statusText,e,d,n)}return h===`response`?t:g(await a(t,p,d))}finally{b!=null&&clearTimeout(b)}},e=>e instanceof l?e:o(e,d))}},l=class extends Error{status;rawMessage;body;url;headers;constructor(e,t,n,r,i){super(`Request to '${r}' failed with status ${e} and message: ${t}.`),this.name=`HttpError`,this.status=e,this.rawMessage=t,this.body=n,this.url=r,this.headers=i}};e.HttpClient=c,e.HttpError=l});
|
|
1
|
+
(function(e,t){typeof exports==`object`&&typeof module<`u`?t(exports,require(`@openstage/monadyssey-core`)):typeof define==`function`&&define.amd?define([`exports`,`@openstage/monadyssey-core`],t):(e=typeof globalThis<`u`?globalThis:e||self,t(e[`monadyssey-fetch`]={},e.Monadyssey))})(this,function(e,t){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var n=e=>typeof e!=`object`||!e||e instanceof FormData||e instanceof Blob||e instanceof ArrayBuffer||e instanceof URLSearchParams||typeof ReadableStream<`u`&&e instanceof ReadableStream||ArrayBuffer.isView(e),r=e=>{let t={};for(let[n,r]of Object.entries(e))r!==void 0&&(t[n.toLowerCase()]=r);return t},i=(e,t,n)=>{let r=n;for(let t of[...e].reverse()){let e=r;r=n=>t.intercept(n,e)}return r(t)},a=async(e,t,n)=>{if(e.status===204||e.status===205)return null;try{switch(t){case`json`:return await e.json();case`text`:return await e.text();case`blob`:return await e.blob();case`arrayBuffer`:return await e.arrayBuffer();case`formData`:return await e.formData();default:throw o(`Unsupported response type: ${t}`,n,e)}}catch(t){throw t instanceof l?t:o(t,n,e)}},o=(e,t,n)=>{let r=s(n),i=n?n.body:null,a=e instanceof Error?e.message:typeof e==`string`?e:typeof e==`object`&&e&&`message`in e?String(e.message):`An unknown error occurred.`;return new l(n?.status||500,a,i,t,r)},s=e=>{if(!e)return{};try{return Object.fromEntries(e.headers.entries())}catch{return{}}},c=class{interceptors;baseUrl;defaultHeaders;defaultTimeout;defaultCredentials;constructor(e={}){this.interceptors=Object.freeze([...e.interceptors??[]]),this.baseUrl=e.baseUrl,this.defaultHeaders=e.defaultHeaders??{},this.defaultTimeout=e.timeout,this.defaultCredentials=e.credentials??`same-origin`}get(e,t){return this.request(e,`GET`,t)}post(e,t,n){return this.request(e,`POST`,{...n,body:t})}put(e,t,n){return this.request(e,`PUT`,{...n,body:t})}patch(e,t,n){return this.request(e,`PATCH`,{...n,body:t})}delete(e,t){return this.request(e,`DELETE`,t)}fetch(e,t,n={}){return this.request(e,t,n)}resolveUrl(e){if(!this.baseUrl)return e;try{return new URL(e),e}catch{return`${this.baseUrl.endsWith(`/`)?this.baseUrl.slice(0,-1):this.baseUrl}${e.startsWith(`/`)?e:`/${e}`}`}}request(e,c,u={}){let d=this.resolveUrl(e),f=u.timeout??this.defaultTimeout;return t.IO.cancellable(async e=>{let{headers:t={},body:o,responseType:p=`json`,credentials:m=this.defaultCredentials,observe:h=`body`,transform:g=e=>e}=u,_=r({...this.defaultHeaders,...t});typeof o==`object`&&o&&!n(o)&&!(`content-type`in _)&&(_[`content-type`]=`application/json`),o instanceof FormData&&delete _[`content-type`];let v=c===`GET`||c===`HEAD`||o==null?void 0:n(o)?o:_[`content-type`]===`application/json`?JSON.stringify(o):o,y,b,x=f==null?e:(y=new AbortController,e.addEventListener(`abort`,()=>y.abort(e.reason),{once:!0}),b=setTimeout(()=>y.abort(new DOMException(`Request timed out`,`TimeoutError`)),f),y.signal);try{let e={method:c,headers:_,credentials:m,body:v,signal:x},t=await i(this.interceptors,e,e=>fetch(d,e));if(!t.ok){let e=await a(t,p,d),n=s(t);throw new l(t.status,t.statusText,e,d,n)}return h===`response`?t:g(await a(t,p,d))}finally{b!=null&&clearTimeout(b)}},e=>e instanceof l?e:o(e,d))}},l=class extends Error{status;rawMessage;body;url;headers;constructor(e,t,n,r,i){super(`Request to '${r}' failed with status ${e} and message: ${t}.`),this.name=`HttpError`,this.status=e,this.rawMessage=t,this.body=n,this.url=r,this.headers=i}bodyAs(e){return e(this.body)?t.Some.pure(this.body):t.None.Instance}};e.HttpClient=c,e.HttpError=l});
|
|
2
2
|
//# sourceMappingURL=monadyssey-fetch.umd.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"monadyssey-fetch.umd.js","names":[],"sources":["../src/http-client.ts"],"sourcesContent":["import { IO } from \"@openstage/monadyssey-core\";\nimport { Credentials, HttpClientConfig, HttpInterceptor, Method, Options, ResponseType } from \"./options\";\n\n/**\n * Returns `true` if the body is a type that the fetch API knows how to send natively.\n * These types must NOT be JSON.stringified and must NOT have a Content-Type header auto-set\n * (the browser handles multipart boundaries for FormData, etc.).\n */\nconst isNativeBody = (body: unknown): boolean =>\n typeof body !== \"object\" ||\n body === null ||\n body instanceof FormData ||\n body instanceof Blob ||\n body instanceof ArrayBuffer ||\n body instanceof URLSearchParams ||\n (typeof ReadableStream !== \"undefined\" && body instanceof ReadableStream) ||\n ArrayBuffer.isView(body);\n\n/**\n * Normalizes header keys to lowercase for case-insensitive comparison.\n * HTTP header names are case-insensitive per RFC 7230.\n */\nconst normalizeHeaders = (headers: Record<string, string>): Record<string, string> => {\n const result: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n if (value !== undefined) {\n result[key.toLowerCase()] = value;\n }\n }\n return result;\n};\n\n/**\n * Builds the interceptor chain as a pure function. No global state.\n * Interceptors are applied in registration order — first registered is outermost.\n */\nconst runInterceptors = (\n interceptors: readonly HttpInterceptor[],\n req: RequestInit,\n fn: (req: RequestInit) => Promise<Response>\n): Promise<Response> => {\n let next = fn;\n for (const interceptor of [...interceptors].reverse()) {\n const currentNext = next;\n next = (r: RequestInit) => interceptor.intercept(r, currentNext);\n }\n return next(req);\n};\n\nconst parseBody = async (response: Response, responseType: ResponseType, url: string): Promise<unknown> => {\n if (response.status === 204 || response.status === 205) {\n return null;\n }\n try {\n switch (responseType) {\n case \"json\":\n return await response.json();\n case \"text\":\n return await response.text();\n case \"blob\":\n return await response.blob();\n case \"arrayBuffer\":\n return await response.arrayBuffer();\n case \"formData\":\n return await response.formData();\n default:\n throw toHttpError(`Unsupported response type: ${responseType}`, url, response);\n }\n } catch (e: unknown) {\n if (e instanceof HttpError) throw e;\n throw toHttpError(e, url, response);\n }\n};\n\nconst toHttpError = (e: unknown, uri: string, response?: Response): HttpError => {\n const headers = extractHeadersFrom(response);\n const body = response ? response.body : null;\n\n const message =\n e instanceof Error\n ? e.message\n : typeof e === \"string\"\n ? e\n : typeof e === \"object\" && e !== null && \"message\" in e\n ? String((e as any).message)\n : \"An unknown error occurred.\";\n\n return new HttpError(response?.status || 500, message, body, uri, headers);\n};\n\nconst extractHeadersFrom = (response?: Response): Record<string, string> => {\n if (!response) return {};\n try {\n return Object.fromEntries(response.headers.entries());\n } catch {\n return {};\n }\n};\n\n/**\n * A composable HTTP client that wraps the native `fetch` API, returning `IO` instances instead of Promises.\n *\n * Unlike v1, `HttpClient` is instantiable — each instance carries its own configuration (base URL,\n * interceptors, default headers, timeout, credentials). This allows different parts of an application\n * to use independently configured clients.\n *\n * All HTTP methods return `IO<HttpError, A | null>`, enabling lazy execution, functional composition,\n * and explicit error handling. Cancellation is supported: when an IO is cancelled (via fiber or\n * timeout), the underlying `fetch` call is aborted through `AbortSignal`.\n *\n * **On the `| null` in the return type:** when the server responds with `204 No Content` or\n * `205 Reset Content`, there is no body to parse and the IO succeeds with `null`. This is a\n * protocol-level fact (the HTTP spec says these statuses have no body), distinct from a\n * domain-level optionality. For that reason the methods return `IO<HttpError, A | null>` rather\n * than `IO<HttpError, Option<A>>`. `Option` is the right tool when a value might be absent at\n * the domain level; `| null` is honest when the absence is structural to the protocol. For the\n * 95% case where you control the endpoint and never receive 204, the `| null` is a brief\n * `?? defaultValue` away.\n *\n * **Default credentials:** `same-origin`, matching the platform `fetch` default. Set\n * `credentials: \"include\"` explicitly per-client or per-request if you need cookies to flow\n * cross-origin.\n *\n * @example\n * const api = new HttpClient({\n * baseUrl: \"https://api.example.com\",\n * interceptors: [authInterceptor],\n * defaultHeaders: { \"Accept\": \"application/json\" },\n * timeout: 5000,\n * });\n *\n * const users = api.get<User[]>(\"/users\");\n */\nexport class HttpClient {\n private readonly interceptors: readonly HttpInterceptor[];\n private readonly baseUrl: string | undefined;\n private readonly defaultHeaders: Record<string, string>;\n private readonly defaultTimeout: number | undefined;\n private readonly defaultCredentials: Credentials;\n\n /**\n * Creates a new HttpClient with the given configuration.\n *\n * @param {HttpClientConfig} config - Configuration for the client.\n */\n constructor(config: HttpClientConfig = {}) {\n this.interceptors = Object.freeze([...(config.interceptors ?? [])]);\n this.baseUrl = config.baseUrl;\n this.defaultHeaders = config.defaultHeaders ?? {};\n this.defaultTimeout = config.timeout;\n this.defaultCredentials = config.credentials ?? \"same-origin\";\n }\n\n /**\n * Performs a GET request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n get(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n get<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n get<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"GET\", options);\n }\n\n /**\n * Performs a POST request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n post(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n post<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n post<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"POST\", { ...options, body });\n }\n\n /**\n * Performs a PUT request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n put(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n put<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n put<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PUT\", { ...options, body });\n }\n\n /**\n * Performs a PATCH request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n patch(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n patch<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n patch<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PATCH\", { ...options, body });\n }\n\n /**\n * Performs a DELETE request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n delete(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n delete<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n delete<A = any>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"DELETE\", options);\n }\n\n /**\n * Performs a custom HTTP request with the specified method.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Method} method - The HTTP method.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n fetch(uri: string, method: Method, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n fetch<A = any>(uri: string, method: Method, options?: Options<A>): IO<HttpError, A | null>;\n fetch<A = any>(uri: string, method: Method, options: Options<A> = {}): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, method, options);\n }\n\n private resolveUrl(uri: string): string {\n if (!this.baseUrl) return uri;\n try {\n new URL(uri);\n return uri;\n } catch {\n const base = this.baseUrl.endsWith(\"/\") ? this.baseUrl.slice(0, -1) : this.baseUrl;\n const path = uri.startsWith(\"/\") ? uri : `/${uri}`;\n return `${base}${path}`;\n }\n }\n\n private request<A = any>(uri: string, method: Method, options: Options<A> = {}): IO<HttpError, Response | A | null> {\n const resolvedUrl = this.resolveUrl(uri);\n const timeout = options.timeout ?? this.defaultTimeout;\n\n return IO.cancellable<HttpError, Response | A | null>(\n async (signal: AbortSignal) => {\n const {\n headers = {},\n body,\n responseType = \"json\",\n credentials = this.defaultCredentials,\n observe = \"body\",\n transform = (data: unknown) => data as A,\n } = options;\n\n // Merge default headers + per-request headers, all lowercased\n const mergedHeaders = normalizeHeaders({ ...this.defaultHeaders, ...headers });\n\n // Auto-detect Content-Type only for plain objects (not FormData, Blob, etc.)\n const shouldAutoJson =\n body != null && typeof body === \"object\" && !isNativeBody(body) && !(\"content-type\" in mergedHeaders);\n\n if (shouldAutoJson) {\n mergedHeaders[\"content-type\"] = \"application/json\";\n }\n\n // For FormData, do NOT set Content-Type — the browser sets the multipart boundary\n if (body instanceof FormData) {\n delete mergedHeaders[\"content-type\"];\n }\n\n // Serialize body\n const serializedBody =\n method === \"GET\" || method === \"HEAD\" || body == null\n ? undefined\n : isNativeBody(body)\n ? body\n : mergedHeaders[\"content-type\"] === \"application/json\"\n ? JSON.stringify(body)\n : body;\n\n // Timeout support: create a child controller that aborts on timeout or parent signal\n let controller: AbortController | undefined;\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n\n const fetchSignal = (() => {\n if (timeout != null) {\n controller = new AbortController();\n const onParentAbort = () => controller!.abort(signal.reason);\n signal.addEventListener(\"abort\", onParentAbort, { once: true });\n timeoutId = setTimeout(\n () => controller!.abort(new DOMException(\"Request timed out\", \"TimeoutError\")),\n timeout\n );\n return controller.signal;\n }\n return signal;\n })();\n\n try {\n const requestInit: RequestInit = {\n method,\n headers: mergedHeaders,\n credentials,\n body: serializedBody as BodyInit | undefined,\n signal: fetchSignal,\n };\n\n const response = await runInterceptors(this.interceptors, requestInit, (req) => fetch(resolvedUrl, req));\n\n if (!response.ok) {\n const rb = await parseBody(response, responseType, resolvedUrl);\n const respHeaders = extractHeadersFrom(response);\n throw new HttpError(response.status, response.statusText, rb, resolvedUrl, respHeaders);\n }\n\n if (observe === \"response\") {\n return response;\n }\n\n const rb = await parseBody(response, responseType, resolvedUrl);\n return transform(rb);\n } finally {\n if (timeoutId != null) clearTimeout(timeoutId);\n }\n },\n (e: unknown) => (e instanceof HttpError ? e : toHttpError(e, resolvedUrl))\n );\n }\n}\n\n/**\n * Represents an HTTP error encountered during a request.\n *\n * Extends the native `Error` class with additional context: HTTP status code,\n * raw error message, response body, request URL, and response headers.\n */\nexport class HttpError extends Error {\n public readonly status: number;\n public readonly rawMessage: string;\n public readonly body: unknown;\n public readonly url: string;\n public readonly headers?: Record<string, string>;\n\n constructor(status: number, rawMessage: string, body: unknown, url: string, headers?: Record<string, string>) {\n super(`Request to '${url}' failed with status ${status} and message: ${rawMessage}.`);\n this.name = \"HttpError\";\n this.status = status;\n this.rawMessage = rawMessage;\n this.body = body;\n this.url = url;\n this.headers = headers;\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"monadyssey-fetch.umd.js","names":[],"sources":["../src/http-client.ts"],"sourcesContent":["import { IO, Option, Some, None } from \"@openstage/monadyssey-core\";\nimport { Credentials, HttpClientConfig, HttpInterceptor, Method, Options, ResponseType } from \"./options\";\n\n/**\n * Returns `true` if the body is a type that the fetch API knows how to send natively.\n * These types must NOT be JSON.stringified and must NOT have a Content-Type header auto-set\n * (the browser handles multipart boundaries for FormData, etc.).\n */\nconst isNativeBody = (body: unknown): boolean =>\n typeof body !== \"object\" ||\n body === null ||\n body instanceof FormData ||\n body instanceof Blob ||\n body instanceof ArrayBuffer ||\n body instanceof URLSearchParams ||\n (typeof ReadableStream !== \"undefined\" && body instanceof ReadableStream) ||\n ArrayBuffer.isView(body);\n\n/**\n * Normalizes header keys to lowercase for case-insensitive comparison.\n * HTTP header names are case-insensitive per RFC 7230.\n */\nconst normalizeHeaders = (headers: Record<string, string>): Record<string, string> => {\n const result: Record<string, string> = {};\n for (const [key, value] of Object.entries(headers)) {\n if (value !== undefined) {\n result[key.toLowerCase()] = value;\n }\n }\n return result;\n};\n\n/**\n * Builds the interceptor chain as a pure function. No global state.\n * Interceptors are applied in registration order — first registered is outermost.\n */\nconst runInterceptors = (\n interceptors: readonly HttpInterceptor[],\n req: RequestInit,\n fn: (req: RequestInit) => Promise<Response>\n): Promise<Response> => {\n let next = fn;\n for (const interceptor of [...interceptors].reverse()) {\n const currentNext = next;\n next = (r: RequestInit) => interceptor.intercept(r, currentNext);\n }\n return next(req);\n};\n\nconst parseBody = async (response: Response, responseType: ResponseType, url: string): Promise<unknown> => {\n if (response.status === 204 || response.status === 205) {\n return null;\n }\n try {\n switch (responseType) {\n case \"json\":\n return await response.json();\n case \"text\":\n return await response.text();\n case \"blob\":\n return await response.blob();\n case \"arrayBuffer\":\n return await response.arrayBuffer();\n case \"formData\":\n return await response.formData();\n default:\n throw toHttpError(`Unsupported response type: ${responseType}`, url, response);\n }\n } catch (e: unknown) {\n if (e instanceof HttpError) throw e;\n throw toHttpError(e, url, response);\n }\n};\n\nconst toHttpError = (e: unknown, uri: string, response?: Response): HttpError => {\n const headers = extractHeadersFrom(response);\n const body = response ? response.body : null;\n\n const message =\n e instanceof Error\n ? e.message\n : typeof e === \"string\"\n ? e\n : typeof e === \"object\" && e !== null && \"message\" in e\n ? String((e as any).message)\n : \"An unknown error occurred.\";\n\n return new HttpError(response?.status || 500, message, body, uri, headers);\n};\n\nconst extractHeadersFrom = (response?: Response): Record<string, string> => {\n if (!response) return {};\n try {\n return Object.fromEntries(response.headers.entries());\n } catch {\n return {};\n }\n};\n\n/**\n * A composable HTTP client that wraps the native `fetch` API, returning `IO` instances instead of Promises.\n *\n * Unlike v1, `HttpClient` is instantiable — each instance carries its own configuration (base URL,\n * interceptors, default headers, timeout, credentials). This allows different parts of an application\n * to use independently configured clients.\n *\n * All HTTP methods return `IO<HttpError, A | null>`, enabling lazy execution, functional composition,\n * and explicit error handling. Cancellation is supported: when an IO is cancelled (via fiber or\n * timeout), the underlying `fetch` call is aborted through `AbortSignal`.\n *\n * **On the `| null` in the return type:** when the server responds with `204 No Content` or\n * `205 Reset Content`, there is no body to parse and the IO succeeds with `null`. This is a\n * protocol-level fact (the HTTP spec says these statuses have no body), distinct from a\n * domain-level optionality. For that reason the methods return `IO<HttpError, A | null>` rather\n * than `IO<HttpError, Option<A>>`. `Option` is the right tool when a value might be absent at\n * the domain level; `| null` is honest when the absence is structural to the protocol. For the\n * 95% case where you control the endpoint and never receive 204, the `| null` is a brief\n * `?? defaultValue` away.\n *\n * **Default credentials:** `same-origin`, matching the platform `fetch` default. Set\n * `credentials: \"include\"` explicitly per-client or per-request if you need cookies to flow\n * cross-origin.\n *\n * @example\n * const api = new HttpClient({\n * baseUrl: \"https://api.example.com\",\n * interceptors: [authInterceptor],\n * defaultHeaders: { \"Accept\": \"application/json\" },\n * timeout: 5000,\n * });\n *\n * const users = api.get<User[]>(\"/users\");\n */\nexport class HttpClient {\n private readonly interceptors: readonly HttpInterceptor[];\n private readonly baseUrl: string | undefined;\n private readonly defaultHeaders: Record<string, string>;\n private readonly defaultTimeout: number | undefined;\n private readonly defaultCredentials: Credentials;\n\n /**\n * Creates a new HttpClient with the given configuration.\n *\n * @param {HttpClientConfig} config - Configuration for the client.\n */\n constructor(config: HttpClientConfig = {}) {\n this.interceptors = Object.freeze([...(config.interceptors ?? [])]);\n this.baseUrl = config.baseUrl;\n this.defaultHeaders = config.defaultHeaders ?? {};\n this.defaultTimeout = config.timeout;\n this.defaultCredentials = config.credentials ?? \"same-origin\";\n }\n\n /**\n * Performs a GET request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n get(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n get<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n get<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"GET\", options);\n }\n\n /**\n * Performs a POST request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n post(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n post<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n post<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"POST\", { ...options, body });\n }\n\n /**\n * Performs a PUT request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n put(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n put<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n put<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PUT\", { ...options, body });\n }\n\n /**\n * Performs a PATCH request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {unknown} [body] - The request payload.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n patch(uri: string, body: unknown, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n patch<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;\n patch<A = unknown>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"PATCH\", { ...options, body });\n }\n\n /**\n * Performs a DELETE request.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Omit<Options<A>, \"body\">} [options] - Request options (excluding body).\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n delete(uri: string, options: Omit<Options, \"body\"> & { observe: \"response\" }): IO<HttpError, Response>;\n delete<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, A | null>;\n delete<A = unknown>(uri: string, options?: Omit<Options<A>, \"body\">): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, \"DELETE\", options);\n }\n\n /**\n * Performs a custom HTTP request with the specified method.\n *\n * @template A - The expected type of the response body.\n * @param {string} uri - The URL or path to request.\n * @param {Method} method - The HTTP method.\n * @param {Options<A>} [options] - Request options.\n * @returns {IO<HttpError, A | null>} An IO representing the result.\n */\n fetch(uri: string, method: Method, options: Options & { observe: \"response\" }): IO<HttpError, Response>;\n fetch<A = unknown>(uri: string, method: Method, options?: Options<A>): IO<HttpError, A | null>;\n fetch<A = unknown>(uri: string, method: Method, options: Options<A> = {}): IO<HttpError, Response | A | null> {\n return this.request<A>(uri, method, options);\n }\n\n private resolveUrl(uri: string): string {\n if (!this.baseUrl) return uri;\n try {\n new URL(uri);\n return uri;\n } catch {\n const base = this.baseUrl.endsWith(\"/\") ? this.baseUrl.slice(0, -1) : this.baseUrl;\n const path = uri.startsWith(\"/\") ? uri : `/${uri}`;\n return `${base}${path}`;\n }\n }\n\n private request<A = unknown>(\n uri: string,\n method: Method,\n options: Options<A> = {}\n ): IO<HttpError, Response | A | null> {\n const resolvedUrl = this.resolveUrl(uri);\n const timeout = options.timeout ?? this.defaultTimeout;\n\n return IO.cancellable<HttpError, Response | A | null>(\n async (signal: AbortSignal) => {\n const {\n headers = {},\n body,\n responseType = \"json\",\n credentials = this.defaultCredentials,\n observe = \"body\",\n transform = (data: unknown) => data as A,\n } = options;\n\n const mergedHeaders = normalizeHeaders({ ...this.defaultHeaders, ...headers });\n\n const shouldAutoJson =\n body != null && typeof body === \"object\" && !isNativeBody(body) && !(\"content-type\" in mergedHeaders);\n\n if (shouldAutoJson) {\n mergedHeaders[\"content-type\"] = \"application/json\";\n }\n\n if (body instanceof FormData) {\n delete mergedHeaders[\"content-type\"];\n }\n\n const serializedBody =\n method === \"GET\" || method === \"HEAD\" || body == null\n ? undefined\n : isNativeBody(body)\n ? body\n : mergedHeaders[\"content-type\"] === \"application/json\"\n ? JSON.stringify(body)\n : body;\n\n let controller: AbortController | undefined;\n let timeoutId: ReturnType<typeof setTimeout> | undefined;\n\n const fetchSignal = (() => {\n if (timeout != null) {\n controller = new AbortController();\n const onParentAbort = () => controller!.abort(signal.reason);\n signal.addEventListener(\"abort\", onParentAbort, { once: true });\n timeoutId = setTimeout(\n () => controller!.abort(new DOMException(\"Request timed out\", \"TimeoutError\")),\n timeout\n );\n return controller.signal;\n }\n return signal;\n })();\n\n try {\n const requestInit: RequestInit = {\n method,\n headers: mergedHeaders,\n credentials,\n body: serializedBody as BodyInit | undefined,\n signal: fetchSignal,\n };\n\n const response = await runInterceptors(this.interceptors, requestInit, (req) => fetch(resolvedUrl, req));\n\n if (!response.ok) {\n const rb = await parseBody(response, responseType, resolvedUrl);\n const respHeaders = extractHeadersFrom(response);\n throw new HttpError(response.status, response.statusText, rb, resolvedUrl, respHeaders);\n }\n\n if (observe === \"response\") {\n return response;\n }\n\n const rb = await parseBody(response, responseType, resolvedUrl);\n return transform(rb);\n } finally {\n if (timeoutId != null) clearTimeout(timeoutId);\n }\n },\n (e: unknown) => (e instanceof HttpError ? e : toHttpError(e, resolvedUrl))\n );\n }\n}\n\n/**\n * Represents an HTTP error encountered during a request.\n *\n * Extends the native `Error` class with additional context: HTTP status code,\n * raw error message, response body, request URL, and response headers.\n */\nexport class HttpError extends Error {\n public readonly status: number;\n public readonly rawMessage: string;\n public readonly body: unknown;\n public readonly url: string;\n public readonly headers?: Record<string, string>;\n\n constructor(status: number, rawMessage: string, body: unknown, url: string, headers?: Record<string, string>) {\n super(`Request to '${url}' failed with status ${status} and message: ${rawMessage}.`);\n this.name = \"HttpError\";\n this.status = status;\n this.rawMessage = rawMessage;\n this.body = body;\n this.url = url;\n this.headers = headers;\n }\n\n /**\n * Narrows the `unknown` error body to a concrete type using a runtime type guard.\n * Returns `Some(body)` if the guard passes, `None` otherwise — so the narrow is *checked*,\n * not asserted. This keeps the unsafe cast out of consumer code: the body stays `unknown`\n * by default (forcing a conscious narrow), and this is the safe way to perform it.\n *\n * @template T The expected body shape.\n * @param {(x: unknown) => x is T} guard A type guard validating the body's shape.\n * @returns {Option<T>} `Some(body)` if the guard matches, `None` otherwise.\n *\n * @example\n * interface ApiError { code: string; message: string }\n * const isApiError = (x: unknown): x is ApiError =>\n * typeof x === \"object\" && x !== null && \"code\" in x && \"message\" in x;\n *\n * httpError.bodyAs(isApiError).fold(\n * () => \"unrecognized error shape\",\n * (e) => `${e.code}: ${e.message}`\n * );\n */\n bodyAs<T>(guard: (x: unknown) => x is T): Option<T> {\n return guard(this.body) ? (Some.pure(this.body as NonNullable<T>) as Option<T>) : (None.Instance as Option<T>);\n }\n}\n"],"mappings":"6WAQA,IAAM,EAAgB,GACpB,OAAO,GAAS,WAChB,GACA,aAAgB,UAChB,aAAgB,MAChB,aAAgB,aAChB,aAAgB,iBACf,OAAO,eAAmB,KAAe,aAAgB,gBAC1D,YAAY,OAAO,EAAK,CAMpB,EAAoB,GAA4D,CACpF,IAAM,EAAiC,EAAE,CACzC,IAAK,GAAM,CAAC,EAAK,KAAU,OAAO,QAAQ,EAAQ,CAC5C,IAAU,IAAA,KACZ,EAAO,EAAI,aAAa,EAAI,GAGhC,OAAO,GAOH,GACJ,EACA,EACA,IACsB,CACtB,IAAI,EAAO,EACX,IAAK,IAAM,IAAe,CAAC,GAAG,EAAa,CAAC,SAAS,CAAE,CACrD,IAAM,EAAc,EACpB,EAAQ,GAAmB,EAAY,UAAU,EAAG,EAAY,CAElE,OAAO,EAAK,EAAI,EAGZ,EAAY,MAAO,EAAoB,EAA4B,IAAkC,CACzG,GAAI,EAAS,SAAW,KAAO,EAAS,SAAW,IACjD,OAAO,KAET,GAAI,CACF,OAAQ,EAAR,CACE,IAAK,OACH,OAAO,MAAM,EAAS,MAAM,CAC9B,IAAK,OACH,OAAO,MAAM,EAAS,MAAM,CAC9B,IAAK,OACH,OAAO,MAAM,EAAS,MAAM,CAC9B,IAAK,cACH,OAAO,MAAM,EAAS,aAAa,CACrC,IAAK,WACH,OAAO,MAAM,EAAS,UAAU,CAClC,QACE,MAAM,EAAY,8BAA8B,IAAgB,EAAK,EAAS,QAE3E,EAAY,CAEnB,MADI,aAAa,EAAiB,EAC5B,EAAY,EAAG,EAAK,EAAS,GAIjC,GAAe,EAAY,EAAa,IAAmC,CAC/E,IAAM,EAAU,EAAmB,EAAS,CACtC,EAAO,EAAW,EAAS,KAAO,KAElC,EACJ,aAAa,MACT,EAAE,QACF,OAAO,GAAM,SACX,EACA,OAAO,GAAM,UAAY,GAAc,YAAa,EAClD,OAAQ,EAAU,QAAQ,CAC1B,6BAEV,OAAO,IAAI,EAAU,GAAU,QAAU,IAAK,EAAS,EAAM,EAAK,EAAQ,EAGtE,EAAsB,GAAgD,CAC1E,GAAI,CAAC,EAAU,MAAO,EAAE,CACxB,GAAI,CACF,OAAO,OAAO,YAAY,EAAS,QAAQ,SAAS,CAAC,MAC/C,CACN,MAAO,EAAE,GAsCA,EAAb,KAAwB,CACtB,aACA,QACA,eACA,eACA,mBAOA,YAAY,EAA2B,EAAE,CAAE,CACzC,KAAK,aAAe,OAAO,OAAO,CAAC,GAAI,EAAO,cAAgB,EAAE,CAAE,CAAC,CACnE,KAAK,QAAU,EAAO,QACtB,KAAK,eAAiB,EAAO,gBAAkB,EAAE,CACjD,KAAK,eAAiB,EAAO,QAC7B,KAAK,mBAAqB,EAAO,aAAe,cAalD,IAAiB,EAAa,EAAwE,CACpG,OAAO,KAAK,QAAW,EAAK,MAAO,EAAQ,CAc7C,KAAkB,EAAa,EAAgB,EAA0D,CACvG,OAAO,KAAK,QAAW,EAAK,OAAQ,CAAE,GAAG,EAAS,OAAM,CAAC,CAc3D,IAAiB,EAAa,EAAgB,EAA0D,CACtG,OAAO,KAAK,QAAW,EAAK,MAAO,CAAE,GAAG,EAAS,OAAM,CAAC,CAc1D,MAAmB,EAAa,EAAgB,EAA0D,CACxG,OAAO,KAAK,QAAW,EAAK,QAAS,CAAE,GAAG,EAAS,OAAM,CAAC,CAa5D,OAAoB,EAAa,EAAwE,CACvG,OAAO,KAAK,QAAW,EAAK,SAAU,EAAQ,CAchD,MAAmB,EAAa,EAAgB,EAAsB,EAAE,CAAsC,CAC5G,OAAO,KAAK,QAAW,EAAK,EAAQ,EAAQ,CAG9C,WAAmB,EAAqB,CACtC,GAAI,CAAC,KAAK,QAAS,OAAO,EAC1B,GAAI,CAEF,OADA,IAAI,IAAI,EAAI,CACL,OACD,CAGN,MAAO,GAFM,KAAK,QAAQ,SAAS,IAAI,CAAG,KAAK,QAAQ,MAAM,EAAG,GAAG,CAAG,KAAK,UAC9D,EAAI,WAAW,IAAI,CAAG,EAAM,IAAI,OAKjD,QACE,EACA,EACA,EAAsB,EAAE,CACY,CACpC,IAAM,EAAc,KAAK,WAAW,EAAI,CAClC,EAAU,EAAQ,SAAW,KAAK,eAExC,OAAO,EAAA,GAAG,YACR,KAAO,IAAwB,CAC7B,GAAM,CACJ,UAAU,EAAE,CACZ,OACA,eAAe,OACf,cAAc,KAAK,mBACnB,UAAU,OACV,YAAa,GAAkB,GAC7B,EAEE,EAAgB,EAAiB,CAAE,GAAG,KAAK,eAAgB,GAAG,EAAS,CAAC,CAG5D,OAAO,GAAS,UAAhC,GAA4C,CAAC,EAAa,EAAK,EAAI,EAAE,iBAAkB,KAGvF,EAAc,gBAAkB,oBAG9B,aAAgB,UAClB,OAAO,EAAc,gBAGvB,IAAM,EACJ,IAAW,OAAS,IAAW,QAAU,GAAQ,KAC7C,IAAA,GACA,EAAa,EAAK,CAChB,EACA,EAAc,kBAAoB,mBAChC,KAAK,UAAU,EAAK,CACpB,EAEN,EACA,EAEE,EACA,GAAW,KAUR,GATL,EAAa,IAAI,gBAEjB,EAAO,iBAAiB,YADI,EAAY,MAAM,EAAO,OAAO,CACZ,CAAE,KAAM,GAAM,CAAC,CAC/D,EAAY,eACJ,EAAY,MAAM,IAAI,aAAa,oBAAqB,eAAe,CAAC,CAC9E,EACD,CACM,EAAW,QAKtB,GAAI,CACF,IAAM,EAA2B,CAC/B,SACA,QAAS,EACT,cACA,KAAM,EACN,OAAQ,EACT,CAEK,EAAW,MAAM,EAAgB,KAAK,aAAc,EAAc,GAAQ,MAAM,EAAa,EAAI,CAAC,CAExG,GAAI,CAAC,EAAS,GAAI,CAChB,IAAM,EAAK,MAAM,EAAU,EAAU,EAAc,EAAY,CACzD,EAAc,EAAmB,EAAS,CAChD,MAAM,IAAI,EAAU,EAAS,OAAQ,EAAS,WAAY,EAAI,EAAa,EAAY,CAQzF,OALI,IAAY,WACP,EAIF,EADI,MAAM,EAAU,EAAU,EAAc,EAAY,CAC3C,QACZ,CACJ,GAAa,MAAM,aAAa,EAAU,GAGjD,GAAgB,aAAa,EAAY,EAAI,EAAY,EAAG,EAAY,CAC1E,GAUQ,EAAb,cAA+B,KAAM,CACnC,OACA,WACA,KACA,IACA,QAEA,YAAY,EAAgB,EAAoB,EAAe,EAAa,EAAkC,CAC5G,MAAM,eAAe,EAAI,uBAAuB,EAAO,gBAAgB,EAAW,GAAG,CACrF,KAAK,KAAO,YACZ,KAAK,OAAS,EACd,KAAK,WAAa,EAClB,KAAK,KAAO,EACZ,KAAK,IAAM,EACX,KAAK,QAAU,EAuBjB,OAAU,EAA0C,CAClD,OAAO,EAAM,KAAK,KAAK,CAAI,EAAA,KAAK,KAAK,KAAK,KAAuB,CAAkB,EAAA,KAAK"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openstage/monadyssey-fetch",
|
|
3
|
-
"version": "3.0.0-beta.
|
|
3
|
+
"version": "3.0.0-beta.3",
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
@@ -41,6 +41,6 @@
|
|
|
41
41
|
"prettier:fix": "npm run prettier -- --write"
|
|
42
42
|
},
|
|
43
43
|
"peerDependencies": {
|
|
44
|
-
"@openstage/monadyssey-core": "^3.0.0-beta.
|
|
44
|
+
"@openstage/monadyssey-core": "^3.0.0-beta.2"
|
|
45
45
|
}
|
|
46
|
-
}
|
|
46
|
+
}
|
package/readme.md
CHANGED
|
@@ -100,11 +100,11 @@ await api.fetch<{ message: string }>("/custom", "OPTIONS", {
|
|
|
100
100
|
| **Option** | **Type** | **Description** |
|
|
101
101
|
|----------------|-------------------------------------------------------------|-------------------------------------------------------------------------------|
|
|
102
102
|
| `headers` | `Record<string, string>` | Custom headers. Merged with defaults, keys normalized to lowercase |
|
|
103
|
-
| `body` | `
|
|
103
|
+
| `body` | `unknown` | Request payload. Objects auto-JSON; FormData/Blob passed as-is |
|
|
104
104
|
| `responseType`| `"json"`, `"text"`, `"blob"`, `"arrayBuffer"`, `"formData"` | Expected response type. **Defaults to `"json"`** |
|
|
105
105
|
| `credentials` | `"omit"`, `"same-origin"`, `"include"` | Credential policy. **Defaults to client setting (`"same-origin"`)** |
|
|
106
106
|
| `observe` | `"body"` or `"response"` | Return parsed body or full `Response`. **Defaults to `"body"`** |
|
|
107
|
-
| `transform` | `(data:
|
|
107
|
+
| `transform` | `(data: unknown) => A` | Transform the response data |
|
|
108
108
|
| `timeout` | `number` | Per-request timeout in milliseconds. Overrides client-level timeout |
|
|
109
109
|
|
|
110
110
|
### Error Handling
|
|
@@ -114,7 +114,7 @@ Errors are encapsulated in the `HttpError` type, which includes:
|
|
|
114
114
|
* `status`: The HTTP status code (500 for network errors)
|
|
115
115
|
* `message`: Formatted error message containing the URL, status, and raw message
|
|
116
116
|
* `rawMessage`: The raw error description
|
|
117
|
-
* `body`: The response body if available
|
|
117
|
+
* `body`: The response body if available, typed `unknown`. Narrow it safely with `error.bodyAs(guard)`, which returns `Option<T>`
|
|
118
118
|
* `url`: The request URL
|
|
119
119
|
* `headers`: The response headers if available
|
|
120
120
|
|