From 34977ccebdc49662d94c4b94fab07fd3eb7273dc Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 22 Sep 2026 09:25:18 -0400 Subject: [PATCH 1/8] feat(fetch): add result-based SafeFetch wrapper --- docs/content/docs/api-surface.mdx | 27 ++++++ rescript.json | 3 +- src/fetch/Fetch.res | 2 + src/fetch/SafeFetch.res | 57 ++++++++++++ src/fetch/SafeFetch.resi | 33 +++++++ tests/FetchAPI/SafeFetch__test.res | 144 +++++++++++++++++++++++++++++ tests/index.js | 1 + 7 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 src/fetch/SafeFetch.res create mode 100644 src/fetch/SafeFetch.resi create mode 100644 tests/FetchAPI/SafeFetch__test.res diff --git a/docs/content/docs/api-surface.mdx b/docs/content/docs/api-surface.mdx index 798bc3ec..f7026243 100644 --- a/docs/content/docs/api-surface.mdx +++ b/docs/content/docs/api-surface.mdx @@ -113,6 +113,33 @@ let response = await WebAPI.Fetch.fetch( let text = await response->WebAPI.Response.text ``` +### Result-based workflows + +`WebAPI.Fetch` and `WebAPI.Response` are the raw browser bindings. Use the opt-in +`WebAPI.SafeFetch` helpers when expected request, HTTP status, and body-reading failures should be +handled as `result` values instead of rejected promises. + +```ReScript +switch await WebAPI.SafeFetch.fetch("https://example.com/api/users") { +| Error(WebAPI.SafeFetch.FetchRejected(cause)) => handleUnavailable(cause) +| Error(WebAPI.SafeFetch.ResponseNotOk({response})) => handleHttpError(response.status) +| Ok(response) => + switch await response->WebAPI.SafeFetch.json { + | Error({response, cause}) => handleInvalidBody(response.status, cause) + | Ok({response, body}) => handleJson(response.headers, body) + } +} +``` + +`FetchRejected` means the native Fetch operation rejected before producing a response. +`ResponseNotOk` means Fetch produced a response whose `ok` field is `false`, so its headers and body +remain available. A body-reader error retains both the response and the original rejection cause. +Successful readers also retain the response, whose body has then been consumed according to the +native Fetch contract. + +`SafeFetch.json` returns `JSON.t`; validating that JSON against an application's domain types remains +the application's responsibility. + `BodyInit` has constructors for the common JavaScript body shapes. ```ReScript diff --git a/rescript.json b/rescript.json index ce2f933b..1e25cd67 100644 --- a/rescript.json +++ b/rescript.json @@ -330,7 +330,8 @@ "Headers", "HeadersInit", "Request", - "Response" + "Response", + "SafeFetch" ] }, { diff --git a/src/fetch/Fetch.res b/src/fetch/Fetch.res index 05fdb315..c005e083 100644 --- a/src/fetch/Fetch.res +++ b/src/fetch/Fetch.res @@ -2,11 +2,13 @@ Starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ +@scope("globalThis") external fetch: (string, ~init: Request.requestInit=?) => promise = "fetch" /** Starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ +@scope("globalThis") external fetchWithRequest: (Request.t, ~init: Request.requestInit=?) => promise = "fetch" diff --git a/src/fetch/SafeFetch.res b/src/fetch/SafeFetch.res new file mode 100644 index 00000000..dc418b4a --- /dev/null +++ b/src/fetch/SafeFetch.res @@ -0,0 +1,57 @@ +type httpError = { + response: Response.t, +} + +type fetchError = + | FetchRejected(exn) + | ResponseNotOk(httpError) + +type readError = { + response: Response.t, + cause: exn, +} + +type response<'body> = { + response: Response.t, + body: 'body, +} + +let checkOk = (response: Response.t): result => + if response.ok { + Ok(response) + } else { + Error({response: response}) + } + +let fetch = async (url: string, ~init: option=?) => { + try { + let response = await Fetch.fetch(url, ~init?) + response->checkOk->Result.mapError(error => ResponseNotOk(error)) + } catch { + | cause => Error(FetchRejected(cause)) + } +} + +let fetchWithRequest = async (request: Request.t, ~init: option=?) => { + try { + let response = await Fetch.fetchWithRequest(request, ~init?) + response->checkOk->Result.mapError(error => ResponseNotOk(error)) + } catch { + | cause => Error(FetchRejected(cause)) + } +} + +let read = async (response: Response.t, readBody: Response.t => promise<'body>) => { + try { + Ok({response, body: await readBody(response)}) + } catch { + | cause => Error({response, cause}) + } +} + +let arrayBuffer = response => read(response, Response.arrayBuffer) +let blob = response => read(response, Response.blob) +let bytes = response => read(response, Response.bytes) +let formData = response => read(response, Response.formData) +let json = response => read(response, Response.json) +let text = response => read(response, Response.text) diff --git a/src/fetch/SafeFetch.resi b/src/fetch/SafeFetch.resi new file mode 100644 index 00000000..98e927de --- /dev/null +++ b/src/fetch/SafeFetch.resi @@ -0,0 +1,33 @@ +type httpError = { + response: Response.t, +} + +type fetchError = + | FetchRejected(exn) + | ResponseNotOk(httpError) + +type readError = { + response: Response.t, + cause: exn, +} + +type response<'body> = { + response: Response.t, + body: 'body, +} + +let checkOk: Response.t => result + +let fetch: (string, ~init: Request.requestInit=?) => promise> + +let fetchWithRequest: ( + Request.t, + ~init: Request.requestInit=?, +) => promise> + +let arrayBuffer: Response.t => promise, readError>> +let blob: Response.t => promise, readError>> +let bytes: Response.t => promise>, readError>> +let formData: Response.t => promise, readError>> +let json: Response.t => promise, readError>> +let text: Response.t => promise, readError>> diff --git a/tests/FetchAPI/SafeFetch__test.res b/tests/FetchAPI/SafeFetch__test.res new file mode 100644 index 00000000..59d5298f --- /dev/null +++ b/tests/FetchAPI/SafeFetch__test.res @@ -0,0 +1,144 @@ +@scope("Object") +external isSame: ('value, 'value) => bool = "is" + +let okResponse = Response.fromString("ok", ~init={status: 200}) +switch okResponse->SafeFetch.checkOk { +| Ok(response) => assert(isSame(response, okResponse)) +| Error(_) => assert(false) +} + +let notOkResponse = Response.fromString("missing", ~init={status: 404}) +switch notOkResponse->SafeFetch.checkOk { +| Error({response}) => assert(isSame(response, notOkResponse)) +| Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response("ok", {status: 200}) +globalThis.fetch = (_input, init) => { + globalThis.__safeFetchInit = init + return Promise.resolve(globalThis.__safeFetchResponse) +} +`) + +switch await SafeFetch.fetch("https://example.com/ok", ~init={method: "POST"}) { +| Ok(response) => { + let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) + let method: string = %raw(`globalThis.__safeFetchInit.method`) + assert(isSame(response, expected)) + assert(method == "POST") + } +| Error(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response("missing", {status: 404}) +globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +`) + +switch await SafeFetch.fetch("https://example.com/missing") { +| Error(ResponseNotOk({response})) => { + let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) + assert(isSame(response, expected)) + } +| Error(FetchRejected(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchCause = new TypeError("request rejected") +globalThis.fetch = () => Promise.reject(globalThis.__safeFetchCause) +`) + +switch await SafeFetch.fetch("https://example.com/rejected") { +| Error(FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +} + +let request = Request.fromURL("https://example.com/synchronous") + +%%raw(` +globalThis.__safeFetchCause = new TypeError("request threw") +globalThis.fetch = () => { throw globalThis.__safeFetchCause } +`) + +switch await SafeFetch.fetchWithRequest(request) { +| Error(FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +} + +let textResponse = Response.fromString("hello") +switch await textResponse->SafeFetch.text { +| Ok({response, body}) => { + assert(isSame(response, textResponse)) + assert(body == "hello") + } +| Error(_) => assert(false) +} + +let jsonResponse = Response.fromString(`{"name":"Ada"}`) +switch await jsonResponse->SafeFetch.json { +| Ok({response, body}) => { + let _json: JSON.t = body + assert(isSame(response, jsonResponse)) + } +| Error(_) => assert(false) +} + +let malformedResponse = Response.fromString("not json") +switch await malformedResponse->SafeFetch.json { +| Error({response, cause: _}) => assert(isSame(response, malformedResponse)) +| Ok(_) => assert(false) +} + +let consumedResponse = Response.fromString("once") +let _ = await consumedResponse->Response.text +switch await consumedResponse->SafeFetch.text { +| Error({response, cause: _}) => assert(isSame(response, consumedResponse)) +| Ok(_) => assert(false) +} + +let errorBodyResponse = Response.fromString("details", ~init={status: 500}) +switch await errorBodyResponse->SafeFetch.text { +| Ok({response, body}) => { + assert(isSame(response, errorBodyResponse)) + assert(body == "details") + } +| Error(_) => assert(false) +} + +let arrayBufferResult: result< + SafeFetch.response, + SafeFetch.readError, +> = await Response.fromString("buffer")->SafeFetch.arrayBuffer +assert(arrayBufferResult->Result.isOk) + +let blobResult: result, SafeFetch.readError> = await Response.fromString( + "blob", +)->SafeFetch.blob +assert(blobResult->Result.isOk) + +let bytesResult: result< + SafeFetch.response>, + SafeFetch.readError, +> = await Response.fromString("bytes")->SafeFetch.bytes +assert(bytesResult->Result.isOk) + +let formDataResponse = Response.fromString( + "fruit=peach", + ~init={ + headers: HeadersInit.fromDict(dict{"Content-Type": "application/x-www-form-urlencoded"}), + }, +) +let formDataResult: result< + SafeFetch.response, + SafeFetch.readError, +> = await formDataResponse->SafeFetch.formData +assert(formDataResult->Result.isOk) diff --git a/tests/index.js b/tests/index.js index c989c737..1547bb4f 100644 --- a/tests/index.js +++ b/tests/index.js @@ -16,6 +16,7 @@ const runtimeTests = [ "FetchAPI/Headers__test.res", "FetchAPI/Request__test.res", "FetchAPI/Response__test.res", + "FetchAPI/SafeFetch__test.res", "FetchAPI/URLSearchParams__test.res", "URLAPI/URL__test.res", ]; From 3cceaa8cc5123bc625e200845f3de516176f4fcd Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 22 Sep 2026 09:56:40 -0400 Subject: [PATCH 2/8] fix(fetch): keep raw Fetch bindings unchanged --- src/fetch/Fetch.res | 2 -- src/fetch/SafeFetch.res | 4 ++-- src/fetch/SafeFetchRaw.res | 4 ++++ 3 files changed, 6 insertions(+), 4 deletions(-) create mode 100644 src/fetch/SafeFetchRaw.res diff --git a/src/fetch/Fetch.res b/src/fetch/Fetch.res index c005e083..05fdb315 100644 --- a/src/fetch/Fetch.res +++ b/src/fetch/Fetch.res @@ -2,13 +2,11 @@ Starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ -@scope("globalThis") external fetch: (string, ~init: Request.requestInit=?) => promise = "fetch" /** Starts the process of fetching a resource from the network, returning a promise that is fulfilled once the response is available. [Read more on MDN](https://developer.mozilla.org/docs/Web/API/Window/fetch) */ -@scope("globalThis") external fetchWithRequest: (Request.t, ~init: Request.requestInit=?) => promise = "fetch" diff --git a/src/fetch/SafeFetch.res b/src/fetch/SafeFetch.res index dc418b4a..fabffd05 100644 --- a/src/fetch/SafeFetch.res +++ b/src/fetch/SafeFetch.res @@ -25,7 +25,7 @@ let checkOk = (response: Response.t): result => let fetch = async (url: string, ~init: option=?) => { try { - let response = await Fetch.fetch(url, ~init?) + let response = await SafeFetchRaw.fromUrl(url, ~init?) response->checkOk->Result.mapError(error => ResponseNotOk(error)) } catch { | cause => Error(FetchRejected(cause)) @@ -34,7 +34,7 @@ let fetch = async (url: string, ~init: option=?) => { let fetchWithRequest = async (request: Request.t, ~init: option=?) => { try { - let response = await Fetch.fetchWithRequest(request, ~init?) + let response = await SafeFetchRaw.fromRequest(request, ~init?) response->checkOk->Result.mapError(error => ResponseNotOk(error)) } catch { | cause => Error(FetchRejected(cause)) diff --git a/src/fetch/SafeFetchRaw.res b/src/fetch/SafeFetchRaw.res new file mode 100644 index 00000000..d0dd3590 --- /dev/null +++ b/src/fetch/SafeFetchRaw.res @@ -0,0 +1,4 @@ +let fromUrl = (url: string, ~init: option=?) => Fetch.fetch(url, ~init?) + +let fromRequest = (request: Request.t, ~init: option=?) => + Fetch.fetchWithRequest(request, ~init?) From eb25639f8de1cb3051151caa7eeb4f63efa57d08 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 22 Sep 2026 09:58:55 -0400 Subject: [PATCH 3/8] docs(fetch): document SafeFetch workflows --- docs/content/docs/api-surface.mdx | 53 +++++++++++++++++--- src/fetch/SafeFetch.resi | 81 +++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+), 8 deletions(-) diff --git a/docs/content/docs/api-surface.mdx b/docs/content/docs/api-surface.mdx index f7026243..d2eed2ff 100644 --- a/docs/content/docs/api-surface.mdx +++ b/docs/content/docs/api-surface.mdx @@ -116,8 +116,20 @@ let text = await response->WebAPI.Response.text ### Result-based workflows `WebAPI.Fetch` and `WebAPI.Response` are the raw browser bindings. Use the opt-in -`WebAPI.SafeFetch` helpers when expected request, HTTP status, and body-reading failures should be -handled as `result` values instead of rejected promises. +`WebAPI.SafeFetch` helpers when request, HTTP status, and body-reading failures should be handled as +`result` values. The wrapper keeps those failure stages separate and preserves the native values +needed to inspect each outcome. + +| Stage | Result | Preserved context | +| ----------------------- | ---------------------------------- | ------------------------------- | +| Fetch throws or rejects | `Error(FetchRejected(cause))` | Original rejection as `exn` | +| Response is not OK | `Error(ResponseNotOk({response}))` | Complete response | +| Body reader rejects | `Error({response, cause})` | Response and original rejection | + +`SafeFetch.fetch` accepts a URL, while `SafeFetch.fetchWithRequest` accepts an existing +`WebAPI.Request.t`. Both promises fulfill with a `result` for these expected failures. A fulfilled +response is classified with the platform's `response.ok` value, so a 404 or 500 is distinct from a +request that did not produce a response. ```ReScript switch await WebAPI.SafeFetch.fetch("https://example.com/api/users") { @@ -131,14 +143,39 @@ switch await WebAPI.SafeFetch.fetch("https://example.com/api/users") { } ``` -`FetchRejected` means the native Fetch operation rejected before producing a response. -`ResponseNotOk` means Fetch produced a response whose `ok` field is `false`, so its headers and body -remain available. A body-reader error retains both the response and the original rejection cause. -Successful readers also retain the response, whose body has then been consumed according to the -native Fetch contract. +Use `checkOk` to classify a response obtained from the raw API without reading its body. + +```ReScript +let response = await WebAPI.Fetch.fetch("https://example.com/api/users") + +switch response->WebAPI.SafeFetch.checkOk { +| Ok(response) => useResponse(response) +| Error({response}) => handleHttpError(response.status) +} +``` + +A non-OK response remains readable. This is useful when a server returns structured error details. + +```ReScript +switch await WebAPI.SafeFetch.fetch("https://example.com/api/users/42") { +| Error(WebAPI.SafeFetch.ResponseNotOk({response})) => + switch await response->WebAPI.SafeFetch.text { + | Ok({body}) => Console.error(body) + | Error(_) => Console.error(`Request failed with ${Int.toString(response.status)}`) + } +| Error(WebAPI.SafeFetch.FetchRejected(cause)) => reportUnavailable(cause) +| Ok(response) => useResponse(response) +} +``` + +The safe body readers cover `arrayBuffer`, `blob`, `bytes`, `formData`, `json`, and `text`. A +successful read contains both the body and original response, preserving status, headers, URL, and +other metadata. Reading consumes the native body; clone the response before reading when another +consumer also needs it. `SafeFetch.json` returns `JSON.t`; validating that JSON against an application's domain types remains -the application's responsibility. +the application's responsibility. This keeps malformed JSON text, which becomes a `readError`, +separate from valid JSON that does not match an application schema. `BodyInit` has constructors for the common JavaScript body shapes. diff --git a/src/fetch/SafeFetch.resi b/src/fetch/SafeFetch.resi index 98e927de..848d085d 100644 --- a/src/fetch/SafeFetch.resi +++ b/src/fetch/SafeFetch.resi @@ -1,33 +1,114 @@ +/** +Describes a response whose `ok` field is `false`. + +The original response is retained so callers can inspect its status, headers, and body. +*/ type httpError = { response: Response.t, } +/** +Describes an expected failure while producing or classifying a response. + +- `FetchRejected(cause)` means the native Fetch operation threw or its promise rejected. Fetch does + not expose a reliable browser-independent distinction between network, abort, permission, and + other request failures. +- `ResponseNotOk({response})` means Fetch fulfilled with a response whose `ok` field is `false`. + The response remains available for inspection and body reading. +*/ type fetchError = | FetchRejected(exn) | ResponseNotOk(httpError) +/** +Describes a rejected response body reader. + +The record retains both the response whose body was being consumed and the original rejection +cause. +*/ type readError = { response: Response.t, cause: exn, } +/** +A successfully read body paired with its original response. + +The response body has been consumed. Clone the response before reading when another consumer also +needs the body. +*/ type response<'body> = { response: Response.t, body: 'body, } +/** +Classifies an existing response using its native `ok` field. + +Returns `Ok(response)` for successful responses and `Error({response})` otherwise. This function +does not read the body and is useful with responses obtained from the raw `Fetch` API. +*/ let checkOk: Response.t => result +/** +Fetches a URL and returns expected request and HTTP status failures as values. + +The returned promise fulfills with `Error(FetchRejected(cause))` when the native operation throws or +rejects, `Error(ResponseNotOk({response}))` when the response is not OK, or `Ok(response)` for an OK +response. +*/ let fetch: (string, ~init: Request.requestInit=?) => promise> +/** +Fetches an existing `Request.t` and returns expected request and HTTP status failures as values. + +This has the same result contract as `fetch` and accepts an optional request initializer that is +passed to the native Fetch operation. +*/ let fetchWithRequest: ( Request.t, ~init: Request.requestInit=?, ) => promise> +/** +Reads the response body as an `ArrayBuffer.t`. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ let arrayBuffer: Response.t => promise, readError>> + +/** +Reads the response body as a `Blob.t`. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ let blob: Response.t => promise, readError>> + +/** +Reads the response body as an array of bytes. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ let bytes: Response.t => promise>, readError>> + +/** +Reads the response body as `FormData.t`. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ let formData: Response.t => promise, readError>> + +/** +Parses the response body as `JSON.t`. + +Malformed JSON and other native reader rejections become `readError` values. This function does not +validate the parsed JSON against an application domain type. +*/ let json: Response.t => promise, readError>> + +/** +Reads the response body as a string. + +Returns the body with the original response, or a `readError` when the native reader rejects. +*/ let text: Response.t => promise, readError>> From fd61238eaae19be1f18f028aee27339c2ea42f06 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 13 Oct 2026 10:08:07 -0400 Subject: [PATCH 4/8] Slim SafeFetch emitted code and remove raw adapter --- src/fetch/SafeFetch.res | 21 +++++++++++++---- src/fetch/SafeFetchRaw.res | 4 ---- tests/FetchAPI/SafeFetch__test.res | 38 +++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 10 deletions(-) delete mode 100644 src/fetch/SafeFetchRaw.res diff --git a/src/fetch/SafeFetch.res b/src/fetch/SafeFetch.res index fabffd05..f9f5e550 100644 --- a/src/fetch/SafeFetch.res +++ b/src/fetch/SafeFetch.res @@ -23,10 +23,14 @@ let checkOk = (response: Response.t): result => Error({response: response}) } -let fetch = async (url: string, ~init: option=?) => { +let fetchUrl = async (url: string, ~init: option=?) => { try { - let response = await SafeFetchRaw.fromUrl(url, ~init?) - response->checkOk->Result.mapError(error => ResponseNotOk(error)) + let response = await Fetch.fetch(url, ~init?) + if response.ok { + Ok(response) + } else { + Error(ResponseNotOk({response: response})) + } } catch { | cause => Error(FetchRejected(cause)) } @@ -34,13 +38,20 @@ let fetch = async (url: string, ~init: option=?) => { let fetchWithRequest = async (request: Request.t, ~init: option=?) => { try { - let response = await SafeFetchRaw.fromRequest(request, ~init?) - response->checkOk->Result.mapError(error => ResponseNotOk(error)) + let response = await Fetch.fetchWithRequest(request, ~init?) + if response.ok { + Ok(response) + } else { + Error(ResponseNotOk({response: response})) + } } catch { | cause => Error(FetchRejected(cause)) } } +// Bind the public name after both native calls so it cannot shadow JavaScript fetch. +let fetch = fetchUrl + let read = async (response: Response.t, readBody: Response.t => promise<'body>) => { try { Ok({response, body: await readBody(response)}) diff --git a/src/fetch/SafeFetchRaw.res b/src/fetch/SafeFetchRaw.res deleted file mode 100644 index d0dd3590..00000000 --- a/src/fetch/SafeFetchRaw.res +++ /dev/null @@ -1,4 +0,0 @@ -let fromUrl = (url: string, ~init: option=?) => Fetch.fetch(url, ~init?) - -let fromRequest = (request: Request.t, ~init: option=?) => - Fetch.fetchWithRequest(request, ~init?) diff --git a/tests/FetchAPI/SafeFetch__test.res b/tests/FetchAPI/SafeFetch__test.res index 59d5298f..c3ad4829 100644 --- a/tests/FetchAPI/SafeFetch__test.res +++ b/tests/FetchAPI/SafeFetch__test.res @@ -21,6 +21,8 @@ globalThis.fetch = (_input, init) => { } `) +let request = Request.fromURL("https://example.com/request") + switch await SafeFetch.fetch("https://example.com/ok", ~init={method: "POST"}) { | Ok(response) => { let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) @@ -31,6 +33,16 @@ switch await SafeFetch.fetch("https://example.com/ok", ~init={method: "POST"}) { | Error(_) => assert(false) } +switch await SafeFetch.fetchWithRequest(request, ~init={method: "POST"}) { +| Ok(response) => { + let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) + let method: string = %raw(`globalThis.__safeFetchInit.method`) + assert(isSame(response, expected)) + assert(method == "POST") + } +| Error(_) => assert(false) +} + %%raw(` globalThis.__safeFetchResponse = new Response("missing", {status: 404}) globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) @@ -44,6 +56,14 @@ switch await SafeFetch.fetch("https://example.com/missing") { | Error(FetchRejected(_)) | Ok(_) => assert(false) } +switch await SafeFetch.fetchWithRequest(request) { +| Error(ResponseNotOk({response})) => { + let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) + assert(isSame(response, expected)) + } +| Error(FetchRejected(_)) | Ok(_) => assert(false) +} + %%raw(` globalThis.__safeFetchCause = new TypeError("request rejected") globalThis.fetch = () => Promise.reject(globalThis.__safeFetchCause) @@ -58,7 +78,14 @@ switch await SafeFetch.fetch("https://example.com/rejected") { | Error(ResponseNotOk(_)) | Ok(_) => assert(false) } -let request = Request.fromURL("https://example.com/synchronous") +switch await SafeFetch.fetchWithRequest(request) { +| Error(FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +} %%raw(` globalThis.__safeFetchCause = new TypeError("request threw") @@ -74,6 +101,15 @@ switch await SafeFetch.fetchWithRequest(request) { | Error(ResponseNotOk(_)) | Ok(_) => assert(false) } +switch await SafeFetch.fetch("https://example.com/synchronous") { +| Error(FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +} + let textResponse = Response.fromString("hello") switch await textResponse->SafeFetch.text { | Ok({response, body}) => { From 2ccb9733d398ce874bbb0fdd8130d1dd4f8ccefa Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 13 Oct 2026 10:15:26 -0400 Subject: [PATCH 5/8] Remove SafeFetch option runtime with explicit init matching --- src/fetch/SafeFetch.res | 14 ++++++++++++-- tests/FetchAPI/SafeFetch__test.res | 9 ++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/fetch/SafeFetch.res b/src/fetch/SafeFetch.res index f9f5e550..c506e397 100644 --- a/src/fetch/SafeFetch.res +++ b/src/fetch/SafeFetch.res @@ -25,7 +25,12 @@ let checkOk = (response: Response.t): result => let fetchUrl = async (url: string, ~init: option=?) => { try { - let response = await Fetch.fetch(url, ~init?) + let response = await ( + switch init { + | Some(init) => Fetch.fetch(url, ~init) + | None => Fetch.fetch(url) + } + ) if response.ok { Ok(response) } else { @@ -38,7 +43,12 @@ let fetchUrl = async (url: string, ~init: option=?) => { let fetchWithRequest = async (request: Request.t, ~init: option=?) => { try { - let response = await Fetch.fetchWithRequest(request, ~init?) + let response = await ( + switch init { + | Some(init) => Fetch.fetchWithRequest(request, ~init) + | None => Fetch.fetchWithRequest(request) + } + ) if response.ok { Ok(response) } else { diff --git a/tests/FetchAPI/SafeFetch__test.res b/tests/FetchAPI/SafeFetch__test.res index c3ad4829..40a9c172 100644 --- a/tests/FetchAPI/SafeFetch__test.res +++ b/tests/FetchAPI/SafeFetch__test.res @@ -45,13 +45,18 @@ switch await SafeFetch.fetchWithRequest(request, ~init={method: "POST"}) { %%raw(` globalThis.__safeFetchResponse = new Response("missing", {status: 404}) -globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +globalThis.fetch = (_input, init) => { + globalThis.__safeFetchInit = init + return Promise.resolve(globalThis.__safeFetchResponse) +} `) switch await SafeFetch.fetch("https://example.com/missing") { | Error(ResponseNotOk({response})) => { let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) assert(isSame(response, expected)) + let initOmitted: bool = %raw(`globalThis.__safeFetchInit === undefined`) + assert(initOmitted) } | Error(FetchRejected(_)) | Ok(_) => assert(false) } @@ -60,6 +65,8 @@ switch await SafeFetch.fetchWithRequest(request) { | Error(ResponseNotOk({response})) => { let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) assert(isSame(response, expected)) + let initOmitted: bool = %raw(`globalThis.__safeFetchInit === undefined`) + assert(initOmitted) } | Error(FetchRejected(_)) | Ok(_) => assert(false) } From 518c165b977ef01b775523644362db538fa2d1e8 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Tue, 13 Oct 2026 10:33:57 -0400 Subject: [PATCH 6/8] Verify native Fetch abort causes and document exception handling --- src/fetch/SafeFetch.resi | 4 +++- tests/FetchAPI/SafeFetch__test.res | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/fetch/SafeFetch.resi b/src/fetch/SafeFetch.resi index 848d085d..b58b50b2 100644 --- a/src/fetch/SafeFetch.resi +++ b/src/fetch/SafeFetch.resi @@ -12,7 +12,9 @@ Describes an expected failure while producing or classifying a response. - `FetchRejected(cause)` means the native Fetch operation threw or its promise rejected. Fetch does not expose a reliable browser-independent distinction between network, abort, permission, and - other request failures. + other request failures. The `exn` preserves the original JavaScript rejection value; use + `JsExn.fromException` to retrieve it. A custom abort reason can be any JavaScript value, so the + cause is not restricted to `JsError.t`. - `ResponseNotOk({response})` means Fetch fulfilled with a response whose `ok` field is `false`. The response remains available for inspection and body reading. */ diff --git a/tests/FetchAPI/SafeFetch__test.res b/tests/FetchAPI/SafeFetch__test.res index 40a9c172..ff8e3289 100644 --- a/tests/FetchAPI/SafeFetch__test.res +++ b/tests/FetchAPI/SafeFetch__test.res @@ -13,6 +13,33 @@ switch notOkResponse->SafeFetch.checkOk { | Ok(_) => assert(false) } +// Exercise native Fetch with already-aborted signals before installing the mocks. +// Fetch must not make a network request for either signal. +switch await SafeFetch.fetch( + "https://example.com/aborted", + ~init={signal: Nullable.make(AbortSignal.abort())}, +) { +| Error(FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(JsExn.name(rawCause) == Some("AbortError")) + | None => assert(false) + } +| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +} + +let customAbortSignal = AbortSignal.abort(~reason=JSON.Encode.string("cancelled")) +switch await SafeFetch.fetch( + "https://example.com/aborted", + ~init={signal: Nullable.make(customAbortSignal)}, +) { +| Error(FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`"cancelled"`))) + | None => assert(false) + } +| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +} + %%raw(` globalThis.__safeFetchResponse = new Response("ok", {status: 200}) globalThis.fetch = (_input, init) => { From 708ac6bfb3ded3380b6105f1c376b0eb6dea3817 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 5 Nov 2026 10:11:44 -0500 Subject: [PATCH 7/8] feat(fetch): make SafeFetch errors composable and separately opt-in --- rescript.json | 13 +- scripts/check-features.mjs | 104 ++++++++++++++- scripts/check-features.test.mjs | 19 ++- scripts/consumer-fetch.res | 5 + scripts/consumer-safe-fetch-disabled.res | 1 + scripts/consumer-safe-fetch.res | 48 +++++++ src/{fetch => safe-fetch}/SafeFetch.res | 26 ++-- src/{fetch => safe-fetch}/SafeFetch.resi | 56 +++++---- tests/FetchAPI/SafeFetch__test.res | 154 +++++++++++++++++++---- 9 files changed, 358 insertions(+), 68 deletions(-) create mode 100644 scripts/consumer-fetch.res create mode 100644 scripts/consumer-safe-fetch-disabled.res create mode 100644 scripts/consumer-safe-fetch.res rename src/{fetch => safe-fetch}/SafeFetch.res (65%) rename src/{fetch => safe-fetch}/SafeFetch.resi (55%) diff --git a/rescript.json b/rescript.json index 1e25cd67..8ac1cd9c 100644 --- a/rescript.json +++ b/rescript.json @@ -1,6 +1,9 @@ { "name": "@rescript/webapi", "namespace": "WebAPI", + "experimental-features": { + "LetUnwrap": true + }, "features": { "WebAPI.DOM": ["DOM"], "WebAPI.Event": ["WebAPI.DOM", "Event"], @@ -55,6 +58,7 @@ "WebAPI.File": ["WebAPI.Event", "File"], "WebAPI.URL": ["WebAPI.DOM", "URL"], "WebAPI.Fetch": ["WebAPI.Event", "WebAPI.File", "WebAPI.URL", "Fetch"], + "WebAPI.SafeFetch": ["WebAPI.Fetch", "SafeFetch"], "WebAPI.CSSFontLoading": ["WebAPI.Event", "CSSFontLoading"], "WebAPI.Device": ["WebAPI.Event", "Device"], "WebAPI.Messaging": ["WebAPI.Event", "WebAPI.File", "Messaging"], @@ -330,10 +334,15 @@ "Headers", "HeadersInit", "Request", - "Response", - "SafeFetch" + "Response" ] }, + { + "dir": "src/safe-fetch", + "subdirs": true, + "feature": "SafeFetch", + "public": ["SafeFetch"] + }, { "dir": "src/file", "subdirs": true, diff --git a/scripts/check-features.mjs b/scripts/check-features.mjs index df400647..03ddf84b 100644 --- a/scripts/check-features.mjs +++ b/scripts/check-features.mjs @@ -1,4 +1,15 @@ -import { existsSync, readFileSync } from "node:fs"; +import { + cpSync, + existsSync, + mkdtempSync, + mkdirSync, + readFileSync, + rmSync, + statSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import os from "node:os"; import path from "node:path"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; @@ -25,6 +36,7 @@ const expectedFeatureOwners = new Map([ ["WebAPI.Canvas", "Canvas"], ["WebAPI.URL", "URL"], ["WebAPI.Fetch", "Fetch"], + ["WebAPI.SafeFetch", "SafeFetch"], ["WebAPI.UIEvents", "UIEvents"], ["WebAPI.Observers", "Observers"], ["WebAPI.Media", "Media"], @@ -129,7 +141,9 @@ const validateSources = (sourceEntries) => { return [ ...(sameMembers(sourceFeatures, expectedInternalFeatures) ? [] - : ["Source features do not match the 27 expected internal folder features."]), + : [ + `Source features do not match the ${expectedInternalFeatures.length} expected internal folder features.`, + ]), ...(duplicateFeatures.length === 0 ? [] : [`Duplicate source features: ${duplicateFeatures.join(", ")}`]), @@ -246,6 +260,64 @@ const runRescript = (args) => encoding: "utf8", }); +const prepareConsumer = (directory, features, fixture) => { + const packageDirectory = path.join(directory, "node_modules", "@rescript", "webapi"); + mkdirSync(packageDirectory, { recursive: true }); + cpSync(path.join(repoRoot, "src"), path.join(packageDirectory, "src"), { + recursive: true, + filter: (source) => statSync(source).isDirectory() || /\.resi?$/.test(source), + }); + for (const file of ["package.json", "rescript.json"]) { + cpSync(path.join(repoRoot, file), path.join(packageDirectory, file)); + } + for (const dependency of ["rescript", "@rescript/runtime"]) { + symlinkSync( + path.join(repoRoot, "node_modules", dependency), + path.join(directory, "node_modules", dependency), + "junction", + ); + } + mkdirSync(path.join(directory, "src")); + cpSync(path.join(path.dirname(scriptPath), fixture), path.join(directory, "src", "Consumer.res")); + writeFileSync( + path.join(directory, "rescript.json"), + JSON.stringify({ + name: "safe-fetch-consumer-check", + sources: [{ dir: "src" }], + dependencies: [{ name: "@rescript/webapi", features }], + "experimental-features": { LetUnwrap: true }, + warnings: { error: "+8" }, + "package-specs": { module: "esmodule", "in-source": true }, + }), + ); +}; + +export const isMissingSafeFetchDiagnostic = (output) => + /The module or file (?:WebAPI\.)?SafeFetch can't be found\./.test(output.replace(/\s+/g, " ")); + +const runConsumerCheck = ({ name, features, fixture, shouldCompile }) => { + const directory = mkdtempSync(path.join(os.tmpdir(), "webapi-feature-consumer-")); + try { + prepareConsumer(directory, features, fixture); + const result = spawnSync(process.execPath, [rescriptCliPath, "build", "--prod"], { + cwd: directory, + encoding: "utf8", + }); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + const passed = shouldCompile + ? result.status === 0 + : result.status !== null && result.status !== 0 && isMissingSafeFetchDiagnostic(output); + return passed + ? { _tag: "Success" } + : { + _tag: "Failure", + message: `${name} did not ${shouldCompile ? "compile" : "reject the unavailable SafeFetch module"}.\n${result.error?.message ?? ""}\n${output.trim()}`, + }; + } finally { + rmSync(directory, { recursive: true, force: true }); + } +}; + const formatProcessFailure = (featureName, command, result) => [`${featureName} failed during ${command}.`, result.stdout?.trim(), result.stderr?.trim()] .filter(Boolean) @@ -286,4 +358,32 @@ if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === scriptPat } console.log(`[ok] ${featureName}`); } + + for (const consumer of [ + { + name: "SafeFetch consumer", + features: ["WebAPI.SafeFetch"], + fixture: "consumer-safe-fetch.res", + shouldCompile: true, + }, + { + name: "Raw Fetch consumer", + features: ["WebAPI.Fetch"], + fixture: "consumer-fetch.res", + shouldCompile: true, + }, + { + name: "Fetch-only consumer rejection", + features: ["WebAPI.Fetch"], + fixture: "consumer-safe-fetch-disabled.res", + shouldCompile: false, + }, + ]) { + const result = runConsumerCheck(consumer); + if (result._tag === "Failure") { + console.error(result.message); + process.exit(1); + } + console.log(`[ok] ${consumer.name}`); + } } diff --git a/scripts/check-features.test.mjs b/scripts/check-features.test.mjs index 71dfd088..590b42c8 100644 --- a/scripts/check-features.test.mjs +++ b/scripts/check-features.test.mjs @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { parsePublicTypeAlias } from "./check-features.mjs"; +import { isMissingSafeFetchDiagnostic, parsePublicTypeAlias } from "./check-features.mjs"; test("parses a public type alias without type parameters", () => { assert.deepEqual(parsePublicTypeAlias("type t = DOMTree.element"), { @@ -16,3 +16,20 @@ test("parses a public type alias with type parameters", () => { backingType: "readableStream", }); }); + +test("recognizes the unavailable SafeFetch module diagnostic across line breaks", () => { + assert.equal( + isMissingSafeFetchDiagnostic("The module or file WebAPI.SafeFetch\ncan't be found."), + true, + ); +}); + +test("does not accept an unrelated compiler failure mentioning SafeFetch", () => { + assert.equal( + isMissingSafeFetchDiagnostic( + "let fetch = WebAPI.SafeFetch.fetch\nThe module or file WebAPI.Request can't be found.", + ), + false, + ); + assert.equal(isMissingSafeFetchDiagnostic("SafeFetch consumer has a type error."), false); +}); diff --git a/scripts/consumer-fetch.res b/scripts/consumer-fetch.res new file mode 100644 index 00000000..e941af88 --- /dev/null +++ b/scripts/consumer-fetch.res @@ -0,0 +1,5 @@ +let request = WebAPI.Request.fromURL("https://example.invalid") + +let fetch = () => WebAPI.Fetch.fetchWithRequest(request) + +let read = (response: WebAPI.Response.t) => response->WebAPI.Response.text diff --git a/scripts/consumer-safe-fetch-disabled.res b/scripts/consumer-safe-fetch-disabled.res new file mode 100644 index 00000000..d90da7cd --- /dev/null +++ b/scripts/consumer-safe-fetch-disabled.res @@ -0,0 +1 @@ +let fetch = WebAPI.SafeFetch.fetch diff --git a/scripts/consumer-safe-fetch.res b/scripts/consumer-safe-fetch.res new file mode 100644 index 00000000..002da71a --- /dev/null +++ b/scripts/consumer-safe-fetch.res @@ -0,0 +1,48 @@ +type user = {name: string} + +let decodeUser = json => + switch json { + | JSON.Object(dict{"name": JSON.String(name)}) => Ok({name: name}) + | _ => Error(#InvalidUser) + } + +let loadUser = async url => { + let? Ok(response) = await WebAPI.SafeFetch.fetch(url) + let? Ok({body}) = await response->WebAPI.SafeFetch.json + let? Ok(user) = body->decodeUser + Ok(user) +} + +let loadUserWithRequest = async request => { + let? Ok(response) = await WebAPI.SafeFetch.fetchWithRequest(request) + let? Ok({body}) = await response->WebAPI.SafeFetch.json + let? Ok(user) = body->decodeUser + Ok(user) +} + +type error = [ + | WebAPI.SafeFetch.fetchError + | WebAPI.SafeFetch.bodyError + | #InvalidUser +] + +let classify = (result: result) => + switch result { + | Ok({name}) => name + | Error(#FetchRejected(_)) => "fetch" + | Error(#ResponseNotOk(_)) => "status" + | Error(#BodyReadRejected(_)) => "body" + | Error(#InvalidUser) => "user" + } + +let consume = async () => { + let request = WebAPI.Request.fromURL("https://example.invalid") + let _ = classify(await loadUser("https://example.invalid")) + classify(await loadUserWithRequest(request)) +} + +let readCheckedText = async (response: WebAPI.Response.t) => { + let? Ok(response) = response->WebAPI.SafeFetch.checkOk + let? Ok({body}) = await response->WebAPI.SafeFetch.text + Ok(body) +} diff --git a/src/fetch/SafeFetch.res b/src/safe-fetch/SafeFetch.res similarity index 65% rename from src/fetch/SafeFetch.res rename to src/safe-fetch/SafeFetch.res index c506e397..0d02588c 100644 --- a/src/fetch/SafeFetch.res +++ b/src/safe-fetch/SafeFetch.res @@ -2,28 +2,28 @@ type httpError = { response: Response.t, } -type fetchError = - | FetchRejected(exn) - | ResponseNotOk(httpError) +type fetchError = [#FetchRejected(exn) | #ResponseNotOk(httpError)] type readError = { response: Response.t, cause: exn, } +type bodyError = [#BodyReadRejected(readError)] + type response<'body> = { response: Response.t, body: 'body, } -let checkOk = (response: Response.t): result => +let checkOk = (response: Response.t) => if response.ok { Ok(response) } else { - Error({response: response}) + Error(#ResponseNotOk({response: response})) } -let fetchUrl = async (url: string, ~init: option=?) => { +let fetchUrl = async (url, ~init=?) => { try { let response = await ( switch init { @@ -34,14 +34,14 @@ let fetchUrl = async (url: string, ~init: option=?) => { if response.ok { Ok(response) } else { - Error(ResponseNotOk({response: response})) + Error(#ResponseNotOk({response: response})) } } catch { - | cause => Error(FetchRejected(cause)) + | cause => Error(#FetchRejected(cause)) } } -let fetchWithRequest = async (request: Request.t, ~init: option=?) => { +let fetchWithRequest = async (request, ~init=?) => { try { let response = await ( switch init { @@ -52,21 +52,21 @@ let fetchWithRequest = async (request: Request.t, ~init: option Error(FetchRejected(cause)) + | cause => Error(#FetchRejected(cause)) } } // Bind the public name after both native calls so it cannot shadow JavaScript fetch. let fetch = fetchUrl -let read = async (response: Response.t, readBody: Response.t => promise<'body>) => { +let read = async (response, readBody) => { try { Ok({response, body: await readBody(response)}) } catch { - | cause => Error({response, cause}) + | cause => Error(#BodyReadRejected({response, cause})) } } diff --git a/src/fetch/SafeFetch.resi b/src/safe-fetch/SafeFetch.resi similarity index 55% rename from src/fetch/SafeFetch.resi rename to src/safe-fetch/SafeFetch.resi index b58b50b2..b3c76c52 100644 --- a/src/fetch/SafeFetch.resi +++ b/src/safe-fetch/SafeFetch.resi @@ -10,17 +10,18 @@ type httpError = { /** Describes an expected failure while producing or classifying a response. -- `FetchRejected(cause)` means the native Fetch operation threw or its promise rejected. Fetch does +- `#FetchRejected(cause)` means the native Fetch operation threw or its promise rejected. Fetch does not expose a reliable browser-independent distinction between network, abort, permission, and other request failures. The `exn` preserves the original JavaScript rejection value; use `JsExn.fromException` to retrieve it. A custom abort reason can be any JavaScript value, so the cause is not restricted to `JsError.t`. -- `ResponseNotOk({response})` means Fetch fulfilled with a response whose `ok` field is `false`. +- `#ResponseNotOk({response})` means Fetch fulfilled with a response whose `ok` field is `false`. The response remains available for inspection and body reading. + +Function results expose `[> fetchError]` so these errors can compose with errors from body readers +and application decoders. */ -type fetchError = - | FetchRejected(exn) - | ResponseNotOk(httpError) +type fetchError = [#FetchRejected(exn) | #ResponseNotOk(httpError)] /** Describes a rejected response body reader. @@ -33,6 +34,13 @@ type readError = { cause: exn, } +/** +Describes a rejected native response body reader. + +Function results expose `[> bodyError]` so body errors can compose with fetch and application errors. +*/ +type bodyError = [#BodyReadRejected(readError)] + /** A successfully read body paired with its original response. @@ -47,19 +55,19 @@ type response<'body> = { /** Classifies an existing response using its native `ok` field. -Returns `Ok(response)` for successful responses and `Error({response})` otherwise. This function -does not read the body and is useful with responses obtained from the raw `Fetch` API. +Returns `Ok(response)` for successful responses and `Error(#ResponseNotOk({response}))` otherwise. +This function does not read the body and is useful with responses obtained from the raw `Fetch` API. */ -let checkOk: Response.t => result +let checkOk: Response.t => result #ResponseNotOk(httpError)]> /** Fetches a URL and returns expected request and HTTP status failures as values. -The returned promise fulfills with `Error(FetchRejected(cause))` when the native operation throws or -rejects, `Error(ResponseNotOk({response}))` when the response is not OK, or `Ok(response)` for an OK +The returned promise fulfills with `Error(#FetchRejected(cause))` when the native operation throws or +rejects, `Error(#ResponseNotOk({response}))` when the response is not OK, or `Ok(response)` for an OK response. */ -let fetch: (string, ~init: Request.requestInit=?) => promise> +let fetch: (string, ~init: Request.requestInit=?) => promise fetchError]>> /** Fetches an existing `Request.t` and returns expected request and HTTP status failures as values. @@ -70,47 +78,47 @@ passed to the native Fetch operation. let fetchWithRequest: ( Request.t, ~init: Request.requestInit=?, -) => promise> +) => promise fetchError]>> /** Reads the response body as an `ArrayBuffer.t`. -Returns the body with the original response, or a `readError` when the native reader rejects. +Returns the body with the original response, or a `bodyError` when the native reader rejects. */ -let arrayBuffer: Response.t => promise, readError>> +let arrayBuffer: Response.t => promise, [> bodyError]>> /** Reads the response body as a `Blob.t`. -Returns the body with the original response, or a `readError` when the native reader rejects. +Returns the body with the original response, or a `bodyError` when the native reader rejects. */ -let blob: Response.t => promise, readError>> +let blob: Response.t => promise, [> bodyError]>> /** Reads the response body as an array of bytes. -Returns the body with the original response, or a `readError` when the native reader rejects. +Returns the body with the original response, or a `bodyError` when the native reader rejects. */ -let bytes: Response.t => promise>, readError>> +let bytes: Response.t => promise>, [> bodyError]>> /** Reads the response body as `FormData.t`. -Returns the body with the original response, or a `readError` when the native reader rejects. +Returns the body with the original response, or a `bodyError` when the native reader rejects. */ -let formData: Response.t => promise, readError>> +let formData: Response.t => promise, [> bodyError]>> /** Parses the response body as `JSON.t`. -Malformed JSON and other native reader rejections become `readError` values. This function does not +Malformed JSON and other native reader rejections become `bodyError` values. This function does not validate the parsed JSON against an application domain type. */ -let json: Response.t => promise, readError>> +let json: Response.t => promise, [> bodyError]>> /** Reads the response body as a string. -Returns the body with the original response, or a `readError` when the native reader rejects. +Returns the body with the original response, or a `bodyError` when the native reader rejects. */ -let text: Response.t => promise, readError>> +let text: Response.t => promise, [> bodyError]>> diff --git a/tests/FetchAPI/SafeFetch__test.res b/tests/FetchAPI/SafeFetch__test.res index ff8e3289..bfc020fb 100644 --- a/tests/FetchAPI/SafeFetch__test.res +++ b/tests/FetchAPI/SafeFetch__test.res @@ -4,12 +4,12 @@ external isSame: ('value, 'value) => bool = "is" let okResponse = Response.fromString("ok", ~init={status: 200}) switch okResponse->SafeFetch.checkOk { | Ok(response) => assert(isSame(response, okResponse)) -| Error(_) => assert(false) +| Error(#ResponseNotOk(_)) => assert(false) } let notOkResponse = Response.fromString("missing", ~init={status: 404}) switch notOkResponse->SafeFetch.checkOk { -| Error({response}) => assert(isSame(response, notOkResponse)) +| Error(#ResponseNotOk({response})) => assert(isSame(response, notOkResponse)) | Ok(_) => assert(false) } @@ -19,12 +19,12 @@ switch await SafeFetch.fetch( "https://example.com/aborted", ~init={signal: Nullable.make(AbortSignal.abort())}, ) { -| Error(FetchRejected(cause)) => +| Error(#FetchRejected(cause)) => switch cause->JsExn.fromException { | Some(rawCause) => assert(JsExn.name(rawCause) == Some("AbortError")) | None => assert(false) } -| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) } let customAbortSignal = AbortSignal.abort(~reason=JSON.Encode.string("cancelled")) @@ -32,12 +32,12 @@ switch await SafeFetch.fetch( "https://example.com/aborted", ~init={signal: Nullable.make(customAbortSignal)}, ) { -| Error(FetchRejected(cause)) => +| Error(#FetchRejected(cause)) => switch cause->JsExn.fromException { | Some(rawCause) => assert(isSame(rawCause, %raw(`"cancelled"`))) | None => assert(false) } -| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) } %%raw(` @@ -79,23 +79,23 @@ globalThis.fetch = (_input, init) => { `) switch await SafeFetch.fetch("https://example.com/missing") { -| Error(ResponseNotOk({response})) => { +| Error(#ResponseNotOk({response})) => { let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) assert(isSame(response, expected)) let initOmitted: bool = %raw(`globalThis.__safeFetchInit === undefined`) assert(initOmitted) } -| Error(FetchRejected(_)) | Ok(_) => assert(false) +| Error(#FetchRejected(_)) | Ok(_) => assert(false) } switch await SafeFetch.fetchWithRequest(request) { -| Error(ResponseNotOk({response})) => { +| Error(#ResponseNotOk({response})) => { let expected: Response.t = %raw(`globalThis.__safeFetchResponse`) assert(isSame(response, expected)) let initOmitted: bool = %raw(`globalThis.__safeFetchInit === undefined`) assert(initOmitted) } -| Error(FetchRejected(_)) | Ok(_) => assert(false) +| Error(#FetchRejected(_)) | Ok(_) => assert(false) } %%raw(` @@ -104,21 +104,21 @@ globalThis.fetch = () => Promise.reject(globalThis.__safeFetchCause) `) switch await SafeFetch.fetch("https://example.com/rejected") { -| Error(FetchRejected(cause)) => +| Error(#FetchRejected(cause)) => switch cause->JsExn.fromException { | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) | None => assert(false) } -| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) } switch await SafeFetch.fetchWithRequest(request) { -| Error(FetchRejected(cause)) => +| Error(#FetchRejected(cause)) => switch cause->JsExn.fromException { | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) | None => assert(false) } -| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) } %%raw(` @@ -127,21 +127,21 @@ globalThis.fetch = () => { throw globalThis.__safeFetchCause } `) switch await SafeFetch.fetchWithRequest(request) { -| Error(FetchRejected(cause)) => +| Error(#FetchRejected(cause)) => switch cause->JsExn.fromException { | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) | None => assert(false) } -| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) } switch await SafeFetch.fetch("https://example.com/synchronous") { -| Error(FetchRejected(cause)) => +| Error(#FetchRejected(cause)) => switch cause->JsExn.fromException { | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) | None => assert(false) } -| Error(ResponseNotOk(_)) | Ok(_) => assert(false) +| Error(#ResponseNotOk(_)) | Ok(_) => assert(false) } let textResponse = Response.fromString("hello") @@ -164,14 +164,14 @@ switch await jsonResponse->SafeFetch.json { let malformedResponse = Response.fromString("not json") switch await malformedResponse->SafeFetch.json { -| Error({response, cause: _}) => assert(isSame(response, malformedResponse)) +| Error(#BodyReadRejected({response, cause: _})) => assert(isSame(response, malformedResponse)) | Ok(_) => assert(false) } let consumedResponse = Response.fromString("once") let _ = await consumedResponse->Response.text switch await consumedResponse->SafeFetch.text { -| Error({response, cause: _}) => assert(isSame(response, consumedResponse)) +| Error(#BodyReadRejected({response, cause: _})) => assert(isSame(response, consumedResponse)) | Ok(_) => assert(false) } @@ -186,18 +186,19 @@ switch await errorBodyResponse->SafeFetch.text { let arrayBufferResult: result< SafeFetch.response, - SafeFetch.readError, + [> SafeFetch.bodyError], > = await Response.fromString("buffer")->SafeFetch.arrayBuffer assert(arrayBufferResult->Result.isOk) -let blobResult: result, SafeFetch.readError> = await Response.fromString( - "blob", -)->SafeFetch.blob +let blobResult: result< + SafeFetch.response, + [> SafeFetch.bodyError], +> = await Response.fromString("blob")->SafeFetch.blob assert(blobResult->Result.isOk) let bytesResult: result< SafeFetch.response>, - SafeFetch.readError, + [> SafeFetch.bodyError], > = await Response.fromString("bytes")->SafeFetch.bytes assert(bytesResult->Result.isOk) @@ -209,6 +210,107 @@ let formDataResponse = Response.fromString( ) let formDataResult: result< SafeFetch.response, - SafeFetch.readError, + [> SafeFetch.bodyError], > = await formDataResponse->SafeFetch.formData assert(formDataResult->Result.isOk) + +type user = {name: string} + +let decodeUser = json => + switch json { + | JSON.Object(dict{"name": JSON.String(name)}) => Ok({name: name}) + | _ => Error(#InvalidUser) + } + +let loadUser = async (url, decode) => { + let? Ok(response) = await SafeFetch.fetch(url) + let? Ok({body}) = await response->SafeFetch.json + let? Ok(user) = body->decode + Ok(user) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response('{"name":"Ada"}') +globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +`) + +switch await loadUser("https://example.com/user", decodeUser) { +| Ok({name}) => assert(name == "Ada") +| Error(#FetchRejected(_) | #ResponseNotOk(_) | #BodyReadRejected(_) | #InvalidUser) => + assert(false) +} + +let mustNotDecode = _ => assert(false) + +%%raw(` +globalThis.__safeFetchCause = new TypeError("composed request rejected") +globalThis.fetch = () => Promise.reject(globalThis.__safeFetchCause) +`) + +switch await loadUser("https://example.com/user", mustNotDecode) { +| Error(#FetchRejected(cause)) => + switch cause->JsExn.fromException { + | Some(rawCause) => assert(isSame(rawCause, %raw(`globalThis.__safeFetchCause`))) + | None => assert(false) + } +| Error(#ResponseNotOk(_) | #BodyReadRejected(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response('{"name":"Ada"}', {status: 404}) +globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +`) + +switch await loadUser("https://example.com/user", mustNotDecode) { +| Error(#ResponseNotOk({response})) => { + assert(isSame(response, %raw(`globalThis.__safeFetchResponse`))) + assert(!response.bodyUsed) + } +| Error(#FetchRejected(_) | #BodyReadRejected(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response("not json") +globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +`) + +switch await loadUser("https://example.com/user", mustNotDecode) { +| Error(#BodyReadRejected({response, cause})) => { + assert(isSame(response, %raw(`globalThis.__safeFetchResponse`))) + switch cause->JsExn.fromException { + | Some(rawCause) => assert(JsExn.name(rawCause) == Some("SyntaxError")) + | None => assert(false) + } + } +| Error(#FetchRejected(_) | #ResponseNotOk(_)) | Ok(_) => assert(false) +} + +%%raw(` +globalThis.__safeFetchResponse = new Response('{"name":42}') +globalThis.fetch = () => Promise.resolve(globalThis.__safeFetchResponse) +`) + +switch await loadUser("https://example.com/user", decodeUser) { +| Error(#InvalidUser) => () +| Error(#FetchRejected(_) | #ResponseNotOk(_) | #BodyReadRejected(_)) | Ok(_) => assert(false) +} + +let readCheckedText = async response => { + let? Ok(response) = response->SafeFetch.checkOk + let? Ok({body}) = await response->SafeFetch.text + Ok(body) +} + +switch await Response.fromString("checked")->readCheckedText { +| Ok(body) => assert(body == "checked") +| Error(#ResponseNotOk(_) | #BodyReadRejected(_)) => assert(false) +} + +let uncheckedResponse = Response.fromString("not read", ~init={status: 500}) +switch await uncheckedResponse->readCheckedText { +| Error(#ResponseNotOk({response})) => { + assert(isSame(response, uncheckedResponse)) + assert(!response.bodyUsed) + } +| Error(#BodyReadRejected(_)) | Ok(_) => assert(false) +} From cf80f721a0997625ad9bde4b6f7aa6de5151b270 Mon Sep 17 00:00:00 2001 From: Josh Vlk Date: Thu, 5 Nov 2026 10:11:44 -0500 Subject: [PATCH 8/8] docs(fetch): demonstrate composed SafeFetch workflows --- docs/content/docs/api-surface.mdx | 109 +++++++++++++++++++++++------- 1 file changed, 84 insertions(+), 25 deletions(-) diff --git a/docs/content/docs/api-surface.mdx b/docs/content/docs/api-surface.mdx index d2eed2ff..7297f6aa 100644 --- a/docs/content/docs/api-surface.mdx +++ b/docs/content/docs/api-surface.mdx @@ -70,10 +70,30 @@ WebAPI.URL WebAPI.Fetch WebAPI.UIEvents WebAPI.Observers WebAPI.Media WebAPI.WebAudio WebAPI.Storage WebAPI.Messaging WebAPI.Workers WebAPI.Crypto WebAPI.Performance WebAPI.ViewTransitions +WebAPI.SafeFetch ``` The package owns each bundle as one internal source folder. Those internal folder feature names are an implementation detail; consumers should use only the qualified names above. +`WebAPI.SafeFetch` transitively enables `WebAPI.Fetch`, so consumers that want the result-based +helpers can select only the safe feature: + +```json +{ + "experimental-features": { + "LetUnwrap": true + }, + "dependencies": [ + { + "name": "@rescript/webapi", + "features": ["WebAPI.SafeFetch"] + } + ] +} +``` + +The `let?` examples below require the experimental `LetUnwrap` setting with the current ReScript 13 +alpha compiler. The SafeFetch API itself can also be used with ordinary `switch` expressions. ## Fetch @@ -115,55 +135,93 @@ let text = await response->WebAPI.Response.text ### Result-based workflows -`WebAPI.Fetch` and `WebAPI.Response` are the raw browser bindings. Use the opt-in -`WebAPI.SafeFetch` helpers when request, HTTP status, and body-reading failures should be handled as -`result` values. The wrapper keeps those failure stages separate and preserves the native values -needed to inspect each outcome. +`WebAPI.Fetch` and `WebAPI.Response` are the raw browser bindings and remain unchanged. Use the +separate, opt-in `WebAPI.SafeFetch` module when request, HTTP status, and body-reading failures should +be handled as `result` values. The separate feature is an intentional breaking change for consumers +that restrict enabled features: add `WebAPI.SafeFetch` to use these helpers. Existing raw Fetch +clients are unaffected. -| Stage | Result | Preserved context | -| ----------------------- | ---------------------------------- | ------------------------------- | -| Fetch throws or rejects | `Error(FetchRejected(cause))` | Original rejection as `exn` | -| Response is not OK | `Error(ResponseNotOk({response}))` | Complete response | -| Body reader rejects | `Error({response, cause})` | Response and original rejection | +| Stage | Result | Preserved context | +| ----------------------- | --------------------------------------------- | ------------------------------- | +| Fetch throws or rejects | `Error(#FetchRejected(cause))` | Original rejection as `exn` | +| Response is not OK | `Error(#ResponseNotOk({response}))` | Complete response | +| Body reader rejects | `Error(#BodyReadRejected({response, cause}))` | Response and original rejection | `SafeFetch.fetch` accepts a URL, while `SafeFetch.fetchWithRequest` accepts an existing `WebAPI.Request.t`. Both promises fulfill with a `result` for these expected failures. A fulfilled response is classified with the platform's `response.ok` value, so a 404 or 500 is distinct from a -request that did not produce a response. +request that did not produce a response. The public `fetchError` type contains `#FetchRejected(exn)` +and `#ResponseNotOk({response})`; `bodyError` contains `#BodyReadRejected(readError)`. `readError` has +`response` and `cause` fields, and `response<'body>` retains the original response alongside the +successful body. The function signatures expose extensible error rows (`[> fetchError]` and +`[> bodyError]`), so application errors can be added directly with `let?`, without `mapError`. ```ReScript -switch await WebAPI.SafeFetch.fetch("https://example.com/api/users") { -| Error(WebAPI.SafeFetch.FetchRejected(cause)) => handleUnavailable(cause) -| Error(WebAPI.SafeFetch.ResponseNotOk({response})) => handleHttpError(response.status) -| Ok(response) => - switch await response->WebAPI.SafeFetch.json { - | Error({response, cause}) => handleInvalidBody(response.status, cause) - | Ok({response, body}) => handleJson(response.headers, body) +let fetchJson = async url => { + let? Ok(response) = await WebAPI.SafeFetch.fetch(url) + let? Ok({body}) = await response->WebAPI.SafeFetch.json + Ok(body) +} +``` + +The body reader reports malformed JSON as `#BodyReadRejected`; valid JSON with the wrong shape is an +application decoding failure instead. Here is one way to compose the stages and retain an application +error variant in the result: + +```ReScript +type user = {name: string} + +let decodeUser = json => + switch json { + | JSON.Object(dict{"name": JSON.String(name)}) => Ok({name: name}) + | _ => Error(#InvalidUser) } + +let loadUser = async url => { + let? Ok(response) = await WebAPI.SafeFetch.fetch(url) + let? Ok({body}) = await response->WebAPI.SafeFetch.json + let? Ok(user) = body->decodeUser + Ok(user) +} +``` + +The composed result has open error rows, so its caller can handle each stage exhaustively, including +the application-specific `#InvalidUser`: + +```ReScript +switch await loadUser("https://example.com/api/users/42") { +| Error(#FetchRejected(cause)) => reportUnavailable(cause) +| Error(#ResponseNotOk({response})) => handleHttpError(response.status) +| Error(#BodyReadRejected({response, cause})) => handleInvalidBody(response.status, cause) +| Error(#InvalidUser) => handleInvalidUser() +| Ok(user) => showUser(user) } ``` -Use `checkOk` to classify a response obtained from the raw API without reading its body. +Use `checkOk` to classify a response obtained from the raw API without reading its body. Its +non-success result is tagged as `#ResponseNotOk({response})`. ```ReScript let response = await WebAPI.Fetch.fetch("https://example.com/api/users") switch response->WebAPI.SafeFetch.checkOk { | Ok(response) => useResponse(response) -| Error({response}) => handleHttpError(response.status) +| Error(#ResponseNotOk({response})) => handleHttpError(response.status) } ``` -A non-OK response remains readable. This is useful when a server returns structured error details. +A non-OK response remains readable. This is useful when a server returns structured error details; +reading that body can itself fail with `#BodyReadRejected`. ```ReScript switch await WebAPI.SafeFetch.fetch("https://example.com/api/users/42") { -| Error(WebAPI.SafeFetch.ResponseNotOk({response})) => +| Error(#ResponseNotOk({response})) => switch await response->WebAPI.SafeFetch.text { | Ok({body}) => Console.error(body) - | Error(_) => Console.error(`Request failed with ${Int.toString(response.status)}`) + | Error(#BodyReadRejected(_)) => + Console.error(`Request failed with ${Int.toString(response.status)}`) } -| Error(WebAPI.SafeFetch.FetchRejected(cause)) => reportUnavailable(cause) +| Error(#FetchRejected(cause)) => reportUnavailable(cause) | Ok(response) => useResponse(response) } ``` @@ -174,8 +232,9 @@ other metadata. Reading consumes the native body; clone the response before read consumer also needs it. `SafeFetch.json` returns `JSON.t`; validating that JSON against an application's domain types remains -the application's responsibility. This keeps malformed JSON text, which becomes a `readError`, -separate from valid JSON that does not match an application schema. +the application's responsibility. A body read preserves its `response` and original rejection in +the `readError` payload. This keeps malformed JSON text separate from valid JSON that does not match +an application schema. `BodyInit` has constructors for the common JavaScript body shapes.