@openstage/monadyssey-fetch 3.0.0-beta.1
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 +2 -0
- package/dist/monadyssey-fetch.cjs.map +1 -0
- package/dist/monadyssey-fetch.d.ts +231 -0
- package/dist/monadyssey-fetch.mjs +122 -0
- package/dist/monadyssey-fetch.mjs.map +1 -0
- package/dist/monadyssey-fetch.umd.js +2 -0
- package/dist/monadyssey-fetch.umd.js.map +1 -0
- package/package.json +46 -0
- package/readme.md +123 -0
|
@@ -0,0 +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;
|
|
2
|
+
//# sourceMappingURL=monadyssey-fetch.cjs.map
|
|
@@ -0,0 +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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"}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { IO } from '@openstage/monadyssey-core';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Specifies the credentials policy for the request.
|
|
5
|
+
* - `"omit"`: Do not send credentials with the request.
|
|
6
|
+
* - `"same-origin"`: Send credentials only if the request is to the same origin.
|
|
7
|
+
* - `"include"`: Always send credentials with the request.
|
|
8
|
+
*/
|
|
9
|
+
export declare type Credentials = "omit" | "same-origin" | "include";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A composable HTTP client that wraps the native `fetch` API, returning `IO` instances instead of Promises.
|
|
13
|
+
*
|
|
14
|
+
* Unlike v1, `HttpClient` is instantiable — each instance carries its own configuration (base URL,
|
|
15
|
+
* interceptors, default headers, timeout, credentials). This allows different parts of an application
|
|
16
|
+
* to use independently configured clients.
|
|
17
|
+
*
|
|
18
|
+
* All HTTP methods return `IO<HttpError, A | null>`, enabling lazy execution, functional composition,
|
|
19
|
+
* and explicit error handling. Cancellation is supported: when an IO is cancelled (via fiber or
|
|
20
|
+
* timeout), the underlying `fetch` call is aborted through `AbortSignal`.
|
|
21
|
+
*
|
|
22
|
+
* **On the `| null` in the return type:** when the server responds with `204 No Content` or
|
|
23
|
+
* `205 Reset Content`, there is no body to parse and the IO succeeds with `null`. This is a
|
|
24
|
+
* protocol-level fact (the HTTP spec says these statuses have no body), distinct from a
|
|
25
|
+
* domain-level optionality. For that reason the methods return `IO<HttpError, A | null>` rather
|
|
26
|
+
* than `IO<HttpError, Option<A>>`. `Option` is the right tool when a value might be absent at
|
|
27
|
+
* the domain level; `| null` is honest when the absence is structural to the protocol. For the
|
|
28
|
+
* 95% case where you control the endpoint and never receive 204, the `| null` is a brief
|
|
29
|
+
* `?? defaultValue` away.
|
|
30
|
+
*
|
|
31
|
+
* **Default credentials:** `same-origin`, matching the platform `fetch` default. Set
|
|
32
|
+
* `credentials: "include"` explicitly per-client or per-request if you need cookies to flow
|
|
33
|
+
* cross-origin.
|
|
34
|
+
*
|
|
35
|
+
* @example
|
|
36
|
+
* const api = new HttpClient({
|
|
37
|
+
* baseUrl: "https://api.example.com",
|
|
38
|
+
* interceptors: [authInterceptor],
|
|
39
|
+
* defaultHeaders: { "Accept": "application/json" },
|
|
40
|
+
* timeout: 5000,
|
|
41
|
+
* });
|
|
42
|
+
*
|
|
43
|
+
* const users = api.get<User[]>("/users");
|
|
44
|
+
*/
|
|
45
|
+
export declare class HttpClient {
|
|
46
|
+
private readonly interceptors;
|
|
47
|
+
private readonly baseUrl;
|
|
48
|
+
private readonly defaultHeaders;
|
|
49
|
+
private readonly defaultTimeout;
|
|
50
|
+
private readonly defaultCredentials;
|
|
51
|
+
/**
|
|
52
|
+
* Creates a new HttpClient with the given configuration.
|
|
53
|
+
*
|
|
54
|
+
* @param {HttpClientConfig} config - Configuration for the client.
|
|
55
|
+
*/
|
|
56
|
+
constructor(config?: HttpClientConfig);
|
|
57
|
+
/**
|
|
58
|
+
* Performs a GET request.
|
|
59
|
+
*
|
|
60
|
+
* @template A - The expected type of the response body.
|
|
61
|
+
* @param {string} uri - The URL or path to request.
|
|
62
|
+
* @param {Omit<Options<A>, "body">} [options] - Request options (excluding body).
|
|
63
|
+
* @returns {IO<HttpError, A | null>} An IO representing the result.
|
|
64
|
+
*/
|
|
65
|
+
get(uri: string, options: Omit<Options, "body"> & {
|
|
66
|
+
observe: "response";
|
|
67
|
+
}): IO<HttpError, Response>;
|
|
68
|
+
get<A = any>(uri: string, options?: Omit<Options<A>, "body">): IO<HttpError, A | null>;
|
|
69
|
+
/**
|
|
70
|
+
* Performs a POST request.
|
|
71
|
+
*
|
|
72
|
+
* @template A - The expected type of the response body.
|
|
73
|
+
* @param {string} uri - The URL or path to request.
|
|
74
|
+
* @param {unknown} [body] - The request payload.
|
|
75
|
+
* @param {Options<A>} [options] - Request options.
|
|
76
|
+
* @returns {IO<HttpError, A | null>} An IO representing the result.
|
|
77
|
+
*/
|
|
78
|
+
post(uri: string, body: unknown, options: Options & {
|
|
79
|
+
observe: "response";
|
|
80
|
+
}): IO<HttpError, Response>;
|
|
81
|
+
post<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;
|
|
82
|
+
/**
|
|
83
|
+
* Performs a PUT request.
|
|
84
|
+
*
|
|
85
|
+
* @template A - The expected type of the response body.
|
|
86
|
+
* @param {string} uri - The URL or path to request.
|
|
87
|
+
* @param {unknown} [body] - The request payload.
|
|
88
|
+
* @param {Options<A>} [options] - Request options.
|
|
89
|
+
* @returns {IO<HttpError, A | null>} An IO representing the result.
|
|
90
|
+
*/
|
|
91
|
+
put(uri: string, body: unknown, options: Options & {
|
|
92
|
+
observe: "response";
|
|
93
|
+
}): IO<HttpError, Response>;
|
|
94
|
+
put<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;
|
|
95
|
+
/**
|
|
96
|
+
* Performs a PATCH request.
|
|
97
|
+
*
|
|
98
|
+
* @template A - The expected type of the response body.
|
|
99
|
+
* @param {string} uri - The URL or path to request.
|
|
100
|
+
* @param {unknown} [body] - The request payload.
|
|
101
|
+
* @param {Options<A>} [options] - Request options.
|
|
102
|
+
* @returns {IO<HttpError, A | null>} An IO representing the result.
|
|
103
|
+
*/
|
|
104
|
+
patch(uri: string, body: unknown, options: Options & {
|
|
105
|
+
observe: "response";
|
|
106
|
+
}): IO<HttpError, Response>;
|
|
107
|
+
patch<A = any>(uri: string, body?: unknown, options?: Options<A>): IO<HttpError, A | null>;
|
|
108
|
+
/**
|
|
109
|
+
* Performs a DELETE request.
|
|
110
|
+
*
|
|
111
|
+
* @template A - The expected type of the response body.
|
|
112
|
+
* @param {string} uri - The URL or path to request.
|
|
113
|
+
* @param {Omit<Options<A>, "body">} [options] - Request options (excluding body).
|
|
114
|
+
* @returns {IO<HttpError, A | null>} An IO representing the result.
|
|
115
|
+
*/
|
|
116
|
+
delete(uri: string, options: Omit<Options, "body"> & {
|
|
117
|
+
observe: "response";
|
|
118
|
+
}): IO<HttpError, Response>;
|
|
119
|
+
delete<A = any>(uri: string, options?: Omit<Options<A>, "body">): IO<HttpError, A | null>;
|
|
120
|
+
/**
|
|
121
|
+
* Performs a custom HTTP request with the specified method.
|
|
122
|
+
*
|
|
123
|
+
* @template A - The expected type of the response body.
|
|
124
|
+
* @param {string} uri - The URL or path to request.
|
|
125
|
+
* @param {Method} method - The HTTP method.
|
|
126
|
+
* @param {Options<A>} [options] - Request options.
|
|
127
|
+
* @returns {IO<HttpError, A | null>} An IO representing the result.
|
|
128
|
+
*/
|
|
129
|
+
fetch(uri: string, method: Method, options: Options & {
|
|
130
|
+
observe: "response";
|
|
131
|
+
}): IO<HttpError, Response>;
|
|
132
|
+
fetch<A = any>(uri: string, method: Method, options?: Options<A>): IO<HttpError, A | null>;
|
|
133
|
+
private resolveUrl;
|
|
134
|
+
private request;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Configuration for creating an HttpClient instance.
|
|
139
|
+
*/
|
|
140
|
+
export declare type HttpClientConfig = {
|
|
141
|
+
/** Base URL prepended to relative paths. */
|
|
142
|
+
baseUrl?: string;
|
|
143
|
+
/** Interceptors applied in registration order (first registered = outermost). Immutable after construction. */
|
|
144
|
+
interceptors?: HttpInterceptor[];
|
|
145
|
+
/** Default headers merged into every request. Per-request headers take precedence. */
|
|
146
|
+
defaultHeaders?: Record<string, string>;
|
|
147
|
+
/** Default timeout in milliseconds for all requests. Can be overridden per-request. */
|
|
148
|
+
timeout?: number;
|
|
149
|
+
/** Default credentials policy. Defaults to `"same-origin"` (matches the platform `fetch` default). */
|
|
150
|
+
credentials?: Credentials;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Represents an HTTP error encountered during a request.
|
|
155
|
+
*
|
|
156
|
+
* Extends the native `Error` class with additional context: HTTP status code,
|
|
157
|
+
* raw error message, response body, request URL, and response headers.
|
|
158
|
+
*/
|
|
159
|
+
export declare class HttpError extends Error {
|
|
160
|
+
readonly status: number;
|
|
161
|
+
readonly rawMessage: string;
|
|
162
|
+
readonly body: unknown;
|
|
163
|
+
readonly url: string;
|
|
164
|
+
readonly headers?: Record<string, string>;
|
|
165
|
+
constructor(status: number, rawMessage: string, body: unknown, url: string, headers?: Record<string, string>);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* An HTTP interceptor that can modify or handle request and response data
|
|
170
|
+
* before and after an outgoing `fetch` call.
|
|
171
|
+
*/
|
|
172
|
+
export declare interface HttpInterceptor {
|
|
173
|
+
/**
|
|
174
|
+
* Intercepts an outgoing HTTP request.
|
|
175
|
+
*
|
|
176
|
+
* Call `next(request)` to continue the chain with the (optionally modified) request.
|
|
177
|
+
* Return a `Promise<Response>` without calling `next` to short-circuit the request.
|
|
178
|
+
*
|
|
179
|
+
* @param request - The configuration object for the pending `fetch` call.
|
|
180
|
+
* @param next - Forwards the request to the next interceptor or to `fetch`.
|
|
181
|
+
* @returns A Promise of the resulting `Response`.
|
|
182
|
+
*/
|
|
183
|
+
intercept(request: RequestInit, next: (req: RequestInit) => Promise<Response>): Promise<Response>;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Represents the HTTP method for the request.
|
|
188
|
+
*/
|
|
189
|
+
export declare type Method = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS" | "HEAD";
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Specifies how the response should be observed.
|
|
193
|
+
* - `"body"`: Return the response body.
|
|
194
|
+
* - `"response"`: Return the full `Response` object.
|
|
195
|
+
*/
|
|
196
|
+
export declare type Observe = "body" | "response";
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Options for configuring an individual HTTP request.
|
|
200
|
+
*
|
|
201
|
+
* @template A - The expected type of the response body after transformation.
|
|
202
|
+
*/
|
|
203
|
+
export declare type Options<A = any> = {
|
|
204
|
+
/** Custom headers for the request as key-value pairs. */
|
|
205
|
+
headers?: Record<string, string>;
|
|
206
|
+
/** The request payload. */
|
|
207
|
+
body?: unknown;
|
|
208
|
+
/** The expected type of the response body. Defaults to `"json"`. */
|
|
209
|
+
responseType?: ResponseType_2;
|
|
210
|
+
/** The credential policy for the request. Defaults to the client-level setting. */
|
|
211
|
+
credentials?: Credentials;
|
|
212
|
+
/** Specifies how the response should be observed. Defaults to `"body"`. */
|
|
213
|
+
observe?: Observe;
|
|
214
|
+
/** A function to transform the response body into the desired type. */
|
|
215
|
+
transform?: (data: unknown) => A;
|
|
216
|
+
/** Per-request timeout in milliseconds. Overrides the client-level timeout. */
|
|
217
|
+
timeout?: number;
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Specifies the expected response type.
|
|
222
|
+
* - `"json"`: Parse the response as JSON.
|
|
223
|
+
* - `"text"`: Parse the response as text.
|
|
224
|
+
* - `"blob"`: Parse the response as a Blob.
|
|
225
|
+
* - `"arrayBuffer"`: Parse the response as an ArrayBuffer.
|
|
226
|
+
* - `"formData"`: Parse the response as FormData.
|
|
227
|
+
*/
|
|
228
|
+
declare type ResponseType_2 = "json" | "text" | "blob" | "arrayBuffer" | "formData";
|
|
229
|
+
export { ResponseType_2 as ResponseType }
|
|
230
|
+
|
|
231
|
+
export { }
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import { IO as e } from "@openstage/monadyssey-core";
|
|
2
|
+
//#region src/http-client.ts
|
|
3
|
+
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) => {
|
|
4
|
+
let t = {};
|
|
5
|
+
for (let [n, r] of Object.entries(e)) r !== void 0 && (t[n.toLowerCase()] = r);
|
|
6
|
+
return t;
|
|
7
|
+
}, r = (e, t, n) => {
|
|
8
|
+
let r = n;
|
|
9
|
+
for (let t of [...e].reverse()) {
|
|
10
|
+
let e = r;
|
|
11
|
+
r = (n) => t.intercept(n, e);
|
|
12
|
+
}
|
|
13
|
+
return r(t);
|
|
14
|
+
}, i = async (e, t, n) => {
|
|
15
|
+
if (e.status === 204 || e.status === 205) return null;
|
|
16
|
+
try {
|
|
17
|
+
switch (t) {
|
|
18
|
+
case "json": return await e.json();
|
|
19
|
+
case "text": return await e.text();
|
|
20
|
+
case "blob": return await e.blob();
|
|
21
|
+
case "arrayBuffer": return await e.arrayBuffer();
|
|
22
|
+
case "formData": return await e.formData();
|
|
23
|
+
default: throw a(`Unsupported response type: ${t}`, n, e);
|
|
24
|
+
}
|
|
25
|
+
} catch (t) {
|
|
26
|
+
throw t instanceof c ? t : a(t, n, e);
|
|
27
|
+
}
|
|
28
|
+
}, a = (e, t, n) => {
|
|
29
|
+
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.";
|
|
30
|
+
return new c(n?.status || 500, a, i, t, r);
|
|
31
|
+
}, o = (e) => {
|
|
32
|
+
if (!e) return {};
|
|
33
|
+
try {
|
|
34
|
+
return Object.fromEntries(e.headers.entries());
|
|
35
|
+
} catch {
|
|
36
|
+
return {};
|
|
37
|
+
}
|
|
38
|
+
}, s = class {
|
|
39
|
+
interceptors;
|
|
40
|
+
baseUrl;
|
|
41
|
+
defaultHeaders;
|
|
42
|
+
defaultTimeout;
|
|
43
|
+
defaultCredentials;
|
|
44
|
+
constructor(e = {}) {
|
|
45
|
+
this.interceptors = Object.freeze([...e.interceptors ?? []]), this.baseUrl = e.baseUrl, this.defaultHeaders = e.defaultHeaders ?? {}, this.defaultTimeout = e.timeout, this.defaultCredentials = e.credentials ?? "same-origin";
|
|
46
|
+
}
|
|
47
|
+
get(e, t) {
|
|
48
|
+
return this.request(e, "GET", t);
|
|
49
|
+
}
|
|
50
|
+
post(e, t, n) {
|
|
51
|
+
return this.request(e, "POST", {
|
|
52
|
+
...n,
|
|
53
|
+
body: t
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
put(e, t, n) {
|
|
57
|
+
return this.request(e, "PUT", {
|
|
58
|
+
...n,
|
|
59
|
+
body: t
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
patch(e, t, n) {
|
|
63
|
+
return this.request(e, "PATCH", {
|
|
64
|
+
...n,
|
|
65
|
+
body: t
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
delete(e, t) {
|
|
69
|
+
return this.request(e, "DELETE", t);
|
|
70
|
+
}
|
|
71
|
+
fetch(e, t, n = {}) {
|
|
72
|
+
return this.request(e, t, n);
|
|
73
|
+
}
|
|
74
|
+
resolveUrl(e) {
|
|
75
|
+
if (!this.baseUrl) return e;
|
|
76
|
+
try {
|
|
77
|
+
return new URL(e), e;
|
|
78
|
+
} catch {
|
|
79
|
+
return `${this.baseUrl.endsWith("/") ? this.baseUrl.slice(0, -1) : this.baseUrl}${e.startsWith("/") ? e : `/${e}`}`;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
request(s, l, u = {}) {
|
|
83
|
+
let d = this.resolveUrl(s), f = u.timeout ?? this.defaultTimeout;
|
|
84
|
+
return e.cancellable(async (e) => {
|
|
85
|
+
let { headers: a = {}, body: s, responseType: p = "json", credentials: m = this.defaultCredentials, observe: h = "body", transform: g = (e) => e } = u, _ = n({
|
|
86
|
+
...this.defaultHeaders,
|
|
87
|
+
...a
|
|
88
|
+
});
|
|
89
|
+
typeof s == "object" && s && !t(s) && !("content-type" in _) && (_["content-type"] = "application/json"), s instanceof FormData && delete _["content-type"];
|
|
90
|
+
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);
|
|
91
|
+
try {
|
|
92
|
+
let e = {
|
|
93
|
+
method: l,
|
|
94
|
+
headers: _,
|
|
95
|
+
credentials: m,
|
|
96
|
+
body: v,
|
|
97
|
+
signal: x
|
|
98
|
+
}, t = await r(this.interceptors, e, (e) => fetch(d, e));
|
|
99
|
+
if (!t.ok) {
|
|
100
|
+
let e = await i(t, p, d), n = o(t);
|
|
101
|
+
throw new c(t.status, t.statusText, e, d, n);
|
|
102
|
+
}
|
|
103
|
+
return h === "response" ? t : g(await i(t, p, d));
|
|
104
|
+
} finally {
|
|
105
|
+
b != null && clearTimeout(b);
|
|
106
|
+
}
|
|
107
|
+
}, (e) => e instanceof c ? e : a(e, d));
|
|
108
|
+
}
|
|
109
|
+
}, c = class extends Error {
|
|
110
|
+
status;
|
|
111
|
+
rawMessage;
|
|
112
|
+
body;
|
|
113
|
+
url;
|
|
114
|
+
headers;
|
|
115
|
+
constructor(e, t, n, r, i) {
|
|
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
|
+
}
|
|
118
|
+
};
|
|
119
|
+
//#endregion
|
|
120
|
+
export { s as HttpClient, c as HttpError };
|
|
121
|
+
|
|
122
|
+
//# sourceMappingURL=monadyssey-fetch.mjs.map
|
|
@@ -0,0 +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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"}
|
|
@@ -0,0 +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});
|
|
2
|
+
//# sourceMappingURL=monadyssey-fetch.umd.js.map
|
|
@@ -0,0 +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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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openstage/monadyssey-fetch",
|
|
3
|
+
"version": "3.0.0-beta.1",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"license": "MIT",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"repository": {
|
|
10
|
+
"type": "git",
|
|
11
|
+
"url": "https://codeberg.org/open-stage/monadyssey.git"
|
|
12
|
+
},
|
|
13
|
+
"keywords": [
|
|
14
|
+
"functional programming",
|
|
15
|
+
"typescript",
|
|
16
|
+
"http",
|
|
17
|
+
"fetch"
|
|
18
|
+
],
|
|
19
|
+
"author": "Gabriel Bornea",
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"main": "./dist/monadyssey-fetch.cjs",
|
|
24
|
+
"module": "./dist/monadyssey-fetch.mjs",
|
|
25
|
+
"typings": "./dist/monadyssey-fetch.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/monadyssey-fetch.d.ts",
|
|
29
|
+
"import": "./dist/monadyssey-fetch.mjs",
|
|
30
|
+
"require": "./dist/monadyssey-fetch.cjs"
|
|
31
|
+
},
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"scripts": {
|
|
35
|
+
"test": "npx jest --verbose --coverage --config ../../jest.config.cjs --detectOpenHandles",
|
|
36
|
+
"build": "npx tsc && npx vite build --config vite.config.ts",
|
|
37
|
+
"format": "npm run lint:fix && npm run prettier:fix",
|
|
38
|
+
"lint": "npx eslint \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\"",
|
|
39
|
+
"lint:fix": "npm run lint -- --fix",
|
|
40
|
+
"prettier": "npx prettier \"src/**/*.{ts,tsx}\" \"test/**/*.{ts,tsx}\" --check",
|
|
41
|
+
"prettier:fix": "npm run prettier -- --write"
|
|
42
|
+
},
|
|
43
|
+
"peerDependencies": {
|
|
44
|
+
"@openstage/monadyssey-core": "^3.0.0-beta.1"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/readme.md
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
[](https://www.npmjs.com/package/@openstage/monadyssey-fetch)
|
|
2
|
+
[](../../docs/monadyssey-fetch)
|
|
3
|
+
|
|
4
|
+
### Overview
|
|
5
|
+
|
|
6
|
+
**@openstage/monadyssey-fetch** is an HTTP client module designed to provide a functional and composable interface for making
|
|
7
|
+
HTTP requests. It leverages `IO` and other functional constructs from **@openstage/monadyssey-core** to ensure predictable
|
|
8
|
+
error handling, declarative workflows, and type safety when interacting with APIs.
|
|
9
|
+
|
|
10
|
+
### Documentation
|
|
11
|
+
|
|
12
|
+
Explore the documentation for specific features:
|
|
13
|
+
|
|
14
|
+
- [HttpClient](../../docs/monadyssey-fetch/http-client.md): Composable HTTP client with base URL, interceptors, cancellation, timeout, and native body type support.
|
|
15
|
+
|
|
16
|
+
### Installation
|
|
17
|
+
|
|
18
|
+
To use `@openstage/monadyssey-fetch` in your project, install it via npm:
|
|
19
|
+
|
|
20
|
+
```
|
|
21
|
+
npm install @openstage/monadyssey-fetch @openstage/monadyssey-core
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
### Features
|
|
25
|
+
|
|
26
|
+
#### Instance-Based Configuration
|
|
27
|
+
|
|
28
|
+
Each `HttpClient` instance carries its own configuration — base URL, interceptors, default headers, timeout, and
|
|
29
|
+
credentials. Different parts of an application can use independently configured clients.
|
|
30
|
+
|
|
31
|
+
#### Cancellation
|
|
32
|
+
|
|
33
|
+
Cancelling an IO (via `fiber.cancel()` or `IO.timeout`) aborts the underlying `fetch` call through `AbortSignal`.
|
|
34
|
+
No network resources are wasted on cancelled requests.
|
|
35
|
+
|
|
36
|
+
#### Timeout
|
|
37
|
+
|
|
38
|
+
Configurable at both client level and per-request. When a timeout fires, the HTTP request is aborted.
|
|
39
|
+
|
|
40
|
+
#### Native Body Types
|
|
41
|
+
|
|
42
|
+
`FormData`, `Blob`, `File`, `ArrayBuffer`, `URLSearchParams`, and `ReadableStream` are passed directly to `fetch`
|
|
43
|
+
without JSON serialization. For `FormData`, no `Content-Type` header is set — the browser handles the multipart
|
|
44
|
+
boundary automatically.
|
|
45
|
+
|
|
46
|
+
#### Interceptors
|
|
47
|
+
|
|
48
|
+
Interceptors are passed at construction time and are immutable. They can transform requests, short-circuit responses,
|
|
49
|
+
retry on failure, or modify responses. Different client instances can have independent interceptor stacks.
|
|
50
|
+
|
|
51
|
+
#### Explicit Error Handling
|
|
52
|
+
|
|
53
|
+
All requests return `IO<HttpError, T>`, explicitly modeling success and failure. Errors include HTTP status, response
|
|
54
|
+
body, headers, and URL for full diagnostic context.
|
|
55
|
+
|
|
56
|
+
### Usage
|
|
57
|
+
|
|
58
|
+
**Creating a Client**
|
|
59
|
+
```typescript
|
|
60
|
+
import { HttpClient } from "@openstage/monadyssey-fetch";
|
|
61
|
+
|
|
62
|
+
const api = new HttpClient({
|
|
63
|
+
baseUrl: "https://api.example.com",
|
|
64
|
+
interceptors: [authInterceptor],
|
|
65
|
+
defaultHeaders: { "Accept": "application/json" },
|
|
66
|
+
timeout: 5000,
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**GET Request**
|
|
71
|
+
```typescript
|
|
72
|
+
const result = await api.get<User>("/users/1").unsafeRun();
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
**POST Request with Body**
|
|
76
|
+
```typescript
|
|
77
|
+
await api.post<User>("/users", { name: "New User" })
|
|
78
|
+
.tap((user) => console.log(user))
|
|
79
|
+
.mapErr((error) => console.error(error))
|
|
80
|
+
.unsafeRun();
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**File Upload with FormData**
|
|
84
|
+
```typescript
|
|
85
|
+
const formData = new FormData();
|
|
86
|
+
formData.append("file", file);
|
|
87
|
+
|
|
88
|
+
await api.post("/upload", formData).unsafeRun();
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
**Custom Request with `fetch`**
|
|
92
|
+
```typescript
|
|
93
|
+
await api.fetch<{ message: string }>("/custom", "OPTIONS", {
|
|
94
|
+
headers: { "X-Custom-Header": "value" },
|
|
95
|
+
}).unsafeRun();
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Options
|
|
99
|
+
|
|
100
|
+
| **Option** | **Type** | **Description** |
|
|
101
|
+
|----------------|-------------------------------------------------------------|-------------------------------------------------------------------------------|
|
|
102
|
+
| `headers` | `Record<string, string>` | Custom headers. Merged with defaults, keys normalized to lowercase |
|
|
103
|
+
| `body` | `any` | Request payload. Objects auto-JSON; FormData/Blob passed as-is |
|
|
104
|
+
| `responseType`| `"json"`, `"text"`, `"blob"`, `"arrayBuffer"`, `"formData"` | Expected response type. **Defaults to `"json"`** |
|
|
105
|
+
| `credentials` | `"omit"`, `"same-origin"`, `"include"` | Credential policy. **Defaults to client setting (`"same-origin"`)** |
|
|
106
|
+
| `observe` | `"body"` or `"response"` | Return parsed body or full `Response`. **Defaults to `"body"`** |
|
|
107
|
+
| `transform` | `(data: any) => A` | Transform the response data |
|
|
108
|
+
| `timeout` | `number` | Per-request timeout in milliseconds. Overrides client-level timeout |
|
|
109
|
+
|
|
110
|
+
### Error Handling
|
|
111
|
+
|
|
112
|
+
Errors are encapsulated in the `HttpError` type, which includes:
|
|
113
|
+
|
|
114
|
+
* `status`: The HTTP status code (500 for network errors)
|
|
115
|
+
* `message`: Formatted error message containing the URL, status, and raw message
|
|
116
|
+
* `rawMessage`: The raw error description
|
|
117
|
+
* `body`: The response body if available
|
|
118
|
+
* `url`: The request URL
|
|
119
|
+
* `headers`: The response headers if available
|
|
120
|
+
|
|
121
|
+
### License
|
|
122
|
+
|
|
123
|
+
This project is licensed under the MIT License.
|