From 7fb7e52611b171eeb7c487fb5af311f5ef8290b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bal=C3=A1zs=20Re=C3=A9?= Date: Wed, 24 Jun 2026 13:58:53 +0200 Subject: [PATCH] Unify the web Response binding into Fetch.Response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit App Router route handlers return a web `Response`, but the bindings had no way to construct one — only the read side (`FetchResponse`, what `fetch` resolves to). It's one Web API type, so rather than add a second type for it, fold the construct side into the existing record module: - Rename `FetchResponse` -> `Response`, kept as a deprecated `module FetchResponse = Response` alias so existing consumers keep compiling. - Add `make(body, init)` (`new Response`, nullable body, init status/statusText/headers) and the static `redirect(url, status)`. - Add a `headers: Headers.t` read field (response headers weren't readable). - `fetch` now returns `Response.t`. Headers are typed `Headers.t` (consistent with NextServer.NextResponse.options), not a dict. Read access (`res.status`, `res.json()`) is unchanged, so this is non-breaking for read consumers; the only theoretical break — constructing a `FetchResponse.t` record literal — occurs nowhere in rescript-next, njc, or vialab. Tested against the runtime. --- __tests__/Fetch_vitest.res | 26 ++++++++++++++++++++++++++ src/Fetch.res | 24 ++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 __tests__/Fetch_vitest.res diff --git a/__tests__/Fetch_vitest.res b/__tests__/Fetch_vitest.res new file mode 100644 index 0000000..004509f --- /dev/null +++ b/__tests__/Fetch_vitest.res @@ -0,0 +1,26 @@ +open Vitest + +describe("Fetch.Response", () => { + open Expect + + test("make sets status + headers; body may be null", () => { + let headers = Fetch.Headers.make() + headers->Fetch.Headers.set("Location", "/somewhere") + headers->Fetch.Headers.set("Cache-Control", "public, max-age=3600") + let res = Fetch.Response.make(Nullable.null, {status: 307, headers}) + ( + res.status, + res.headers->Fetch.Headers.get("Location"), + res.headers->Fetch.Headers.get("Cache-Control"), + ) + ->expect + ->toEqual((307, Some("/somewhere"), Some("public, max-age=3600"))) + }) + + test("redirect sets Location + status", () => { + let res = Fetch.Response.redirect("https://example.com/y", 307) + (res.status, res.headers->Fetch.Headers.get("Location")) + ->expect + ->toEqual((307, Some("https://example.com/y"))) + }) +}) diff --git a/src/Fetch.res b/src/Fetch.res index f67acaf..9df3548 100644 --- a/src/Fetch.res +++ b/src/Fetch.res @@ -20,14 +20,31 @@ module Headers = { // missing forEach, getSetCookie } -module FetchResponse = { +// The web `Response` — one Web API type, so one binding for both its *read* +// side (what `fetch` resolves to) and its *construct* side (what an App Router +// route handler returns). The latter is why `make` / `redirect` live here: +// `next/navigation`'s `redirect` / `notFound` are control-flow throwers for +// pages (no custom headers; `notFound` renders the HTML page), and +// `NextServer.NextResponse`'s status is a closed 200/400/403/404/500 variant. +module Response = { type t = { ok: bool, status: int, + headers: Headers.t, arrayBuffer: unit => promise, text: unit => promise, json: unit => promise, } + + // Construct: `new Response(body, init)`. Body may be null (redirects, empty + // bodies); every `init` field is optional. + type init = {status?: int, statusText?: string, headers?: Headers.t} + @new external make: (Nullable.t, init) => t = "Response" + + // Static `Response.redirect(url, status)`. Sets Location + status only; for + // extra headers (e.g. `Cache-Control`) use `make` with `init.headers`. + @val @scope("Response") external redirect: (string, int) => t = "redirect" + let jsonResult = async response => { let text = await response.text() try JSON.parseOrThrow(text)->Result.Ok catch { @@ -43,7 +60,10 @@ module FetchResponse = { let json = async response => await response.json() } -@val external fetch: (string, {..}) => promise = "fetch" +// Former name; kept so existing consumers keep compiling. Prefer `Response`. +module FetchResponse = Response + +@val external fetch: (string, {..}) => promise = "fetch" let fetchJson = async (url: string, body: JSON.t) => { let options = {