diff --git a/docs/guides/migration-playbook.adoc b/docs/guides/migration-playbook.adoc index 12a07af..b5baca7 100644 --- a/docs/guides/migration-playbook.adoc +++ b/docs/guides/migration-playbook.adoc @@ -164,6 +164,40 @@ The `affinescript compile graph.affine -o graph.onnx` invocation produces a real Why this matters for migration: when porting code that calls platform-specific functions, you do not need to extend AffineScript itself. Declare the platform's API as stubs in your translation unit, and let the backend recognise them. This keeps the frontend small and the backend self-contained. +[#portable-http] +=== Portable HTTP (ReScript `fetch` / `Js.Promise` → `Http` stdlib) + +ReScript networking is `fetch(...)` returning a `Js.Promise.t`, usually +wrapped in a `.then`/`.catch` umbrella. Do not port the promise. Port to +the `Http` stdlib module (`use Http::{fetch, get, post, request};`), +whose surface is a typed request/response with an honest effect row: + +[source] +---- +pub fn fetch(req: Request) -> Response / { Net, Async } +// Request #{ url, method, headers: [(String, String)], body: Option } +// Response #{ status: Int, headers: [(String, String)], body: String } +---- + +Re-decomposition rules: + +* The `.catch` umbrella becomes a `match` on `is_ok(resp)` / `resp.status` + — failure is data, not a detached rejected promise. +* Headers are an *association list* `[(String, String)]`, not an opaque + object. Build/read them explicitly. (This will become `Dict` once #162 + lands; the change is source-compatible for callers using the helpers.) +* The response `body` is raw text. JSON decoding is a *separate* step + (the #161 `Json` primitive) — do not conflate transport with parsing. +* Every function that transitively calls `Http` carries `/ { Net, Async }` + in its signature. That is the point: the network and its asynchrony are + visible in the type, where ReScript hid them in a `Promise`. + +Backend status: the Deno-ESM target lowers this to a `globalThis.fetch` +round-trip (Deno / Node 18+ / browser ESM) and is fully exercised +end-to-end. The typed-wasm target is a tracked next slice — the +convergence ABI shared with Ephapax (see <>, +typed-wasm ADR-004); AffineScript is not coupled to it. + [#decision-criteria] == Decision Criteria @@ -341,4 +375,8 @@ If you complete a non-trivial `.res → .affine` translation and the re-decompos | 1.2 | 2026-05-02 | Backend-buildout findings folded into TS→AS index and a new recipe: (1) Float-arithmetic typechecker gap is now closed (no more Int-scaling workaround); (2) `mut`-parameter indexed-write borrow gap is now closed (note the binder-modifier keyword position); (3) new `<>` recipe documents the stub-as-intrinsic pattern that lets user code call CUDA / ONNX / Faust / Verilog runtime ops without extending the language. + +| 1.3 +| 2026-05-18 +| New `<>` recipe for the C-spine `Http` stdlib primitive (issue #160): ReScript `fetch`/`Js.Promise` → typed `Request`/`Response` with a `/ { Net, Async }` effect row. Deno-ESM target landed and exercised end-to-end; typed-wasm target tracked as the next slice (convergence ABI shared with Ephapax). No existing guidance changed. |=== diff --git a/lib/codegen_deno.ml b/lib/codegen_deno.ml index dc81369..6e6e738 100644 --- a/lib/codegen_deno.ml +++ b/lib/codegen_deno.ml @@ -63,6 +63,11 @@ type codegen_ctx = { definition shadows a same-named host intrinsic ({!deno_builtins}), so e.g. a user `fn len(xs: IntList)` is NOT lowered to `.length`. *) local_fns : (string, unit) Hashtbl.t; + (* Top-level functions declared with an `Async` effect row. A call to + one of these is a Promise; from an async context the call site must + `await` it (e.g. `get(u).status` would otherwise read `.status` off + a pending Promise). Populated in {!generate}. *) + async_fns : (string, unit) Hashtbl.t; (* True while emitting a synthesised `async` method body. The expression-position IIFE wrappers (block/try/match/let/return) must then be `async` and awaited, because they may contain an awaited @@ -79,6 +84,7 @@ let create_ctx symbols = { self_name = None; assoc = Hashtbl.create 32; local_fns = Hashtbl.create 64; + async_fns = Hashtbl.create 32; in_async = false; } @@ -98,6 +104,19 @@ let emit_line ctx str = let increase_indent ctx = { ctx with indent = ctx.indent + 1 } +(* True if an effect row mentions `Async`. A function whose declared + effect row includes Async compiles to an `async function`, and its + body is emitted in async context (`in_async`) so an awaited host + call (e.g. the `http_request` -> `await fetch(...)` lowering for + issue #160) is legal JS. This is the free-function analogue of the + unconditionally-async synthesised methods (`gen_class`). *) +let rec eff_has_async : effect_expr -> bool = function + | EffVar id | EffCon (id, _) -> id.name = "Async" + | EffUnion (a, b) -> eff_has_async a || eff_has_async b + +let fd_is_async (fd : fn_decl) : bool = + match fd.fd_eff with Some e -> eff_has_async e | None -> false + (* ============================================================================ Runtime prelude (ESM, Deno-flavoured) @@ -159,6 +178,34 @@ const __as_parseFloat = (s) => { return Number.isNaN(n) ? None : Some(n); }; const __as_show = (v) => (typeof v === "string" ? v : JSON.stringify(v)); +// ---- Http (issue #160): portable fetch round-trip ---- +// `headers` crosses the boundary as an AffineScript [(String, String)] +// assoc list == JS array of [name, value] pairs. `body` is an +// AffineScript Option == { tag: "Some", value } | { tag: "None" }. +// The result is the `Response` record shape { status, headers, body }. +const __as_httpHeadersToObject = (pairs) => { + const o = {}; + for (const kv of (pairs || [])) o[kv[0]] = kv[1]; + return o; +}; +const __as_httpHeadersFromResponse = (res) => { + const out = []; + res.headers.forEach((value, key) => out.push([key, value])); + return out; +}; +const __as_httpFetch = async (url, method, headers, bodyOpt) => { + const init = { method, headers: __as_httpHeadersToObject(headers) }; + if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value; + // `globalThis.fetch` explicitly: the stdlib `Http.fetch` compiles to a + // module-level `function fetch`, which would otherwise shadow the host. + const res = await globalThis.fetch(url, init); + const text = await res.text(); + return { + status: res.status, + headers: __as_httpHeadersFromResponse(res), + body: text, + }; +}; // ---- end runtime ---- |} @@ -227,7 +274,14 @@ let () = b "char_to_int" (fun a -> Printf.sprintf "__as_charToInt(%s)" (arg 0 a)); b "int_to_char" (fun a -> Printf.sprintf "__as_intToChar(%s)" (arg 0 a)); b "show" (fun a -> Printf.sprintf "__as_show(%s)" (arg 0 a)); - b "panic" (fun a -> Printf.sprintf "(() => { throw new Error(%s); })()" (arg 0 a)) + b "panic" (fun a -> Printf.sprintf "(() => { throw new Error(%s); })()" (arg 0 a)); + (* ---- Http (issue #160) ---- *) + (* `await` is legal: every caller of `http_request` is declared + `/ Net, Async` and so is emitted as an `async function` + (see {!fd_is_async}). *) + b "http_request" (fun a -> + Printf.sprintf "(await __as_httpFetch(%s, %s, %s, %s))" + (arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a)) (* ============================================================================ Identifier sanitisation (JS reserved words -> trailing underscore) @@ -304,7 +358,15 @@ let rec gen_expr ctx (expr : expr) : string = mangle id.name ^ "(" ^ String.concat ", " arg_strs ^ ")" | _ -> let arg_strs = List.map (gen_expr ctx) args in - gen_expr ctx func ^ "(" ^ String.concat ", " arg_strs ^ ")") + let call = + gen_expr ctx func ^ "(" ^ String.concat ", " arg_strs ^ ")" in + (match func with + | ExprVar id + when Hashtbl.mem ctx.async_fns id.name && ctx.in_async -> + (* Async free fn returns a Promise; await at the call + site so `get(u).status` reads the resolved value. *) + "(await " ^ call ^ ")" + | _ -> call)) | ExprBinary (e1, OpConcat, e2) -> (* `++` is string- OR array-concat; dispatch on shape at runtime so `a ++ b` (string) and `acc ++ [x]` (array) are both correct. *) @@ -648,11 +710,18 @@ let gen_function ctx (fd : fn_decl) : unit = let name = mangle fd.fd_name.name in let params = List.map (fun (p : param) -> mangle p.p_name.name) fd.fd_params in + let is_async = fd_is_async fd in + let async_kw = if is_async then "async " else "" in let kw = - if visibility_is_public fd.fd_vis then "export function" else "function" in + if visibility_is_public fd.fd_vis + then "export " ^ async_kw ^ "function" + else async_kw ^ "function" in emit_line ctx (Printf.sprintf "%s %s(%s) {" kw name (String.concat ", " params)); - gen_body (increase_indent ctx) fd.fd_body; + let body_ctx = increase_indent ctx in + let body_ctx = + if is_async then { body_ctx with in_async = true } else body_ctx in + gen_body body_ctx fd.fd_body; emit_line ctx "}"; emit ctx "\n" @@ -824,7 +893,9 @@ let generate (program : program) (symbols : Symbol.t) : string = | TopFn fd when fd.fd_body = FnExtern -> Hashtbl.replace ctx.externs fd.fd_name.name () | TopFn fd -> - Hashtbl.replace ctx.local_fns fd.fd_name.name () + Hashtbl.replace ctx.local_fns fd.fd_name.name (); + if fd_is_async fd then + Hashtbl.replace ctx.async_fns fd.fd_name.name () | TopConst { tc_name; _ } -> Hashtbl.replace ctx.local_fns tc_name.name () | TopImpl ib -> diff --git a/stdlib/Http.affine b/stdlib/Http.affine new file mode 100644 index 0000000..7060ee9 --- /dev/null +++ b/stdlib/Http.affine @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// SPDX-FileCopyrightText: 2026 hyperpolymath +// +// Http.affine — portable HTTP requests (issue #160). +// +// The C-spine migration primitive that replaces ReScript's +// `fetch`/`Promise`-based networking with a typed, effect-rowed surface. +// +// Portability: the single `http_request` extern is lowered by the +// Deno-ESM backend (lib/codegen_deno.ml) to a `globalThis.fetch` +// round-trip, which runs unmodified on Deno, Node 18+, and browser ESM. +// The WasmGC / Node-CJS host-import path is a tracked follow-up +// (the runtime there has no `fetch` import yet) — see the issue thread. +// +// Headers are an association list `[(String, String)]` rather than a +// `Dict`: this lets #160 land independently of #162 +// (Dict) while keeping the stated spine order #160 -> #161 -> #162. +// Upgrading `headers` to `Dict` later is an additive, source-compatible +// change for callers that build/read headers through the helpers here. + +module Http; + +// `Option`/`Some`/`None` are owned by `prelude` (ADR-011). +use prelude::{Option, Some, None}; + +// Networking uses the reserved built-in effect `Net` (no declaration +// needed — it is one of the language's reserved effect names alongside +// `Random`/`Time`). Every signature that can touch the network carries +// `/ Net`; the host round-trip is also `Async`, so the effect row +// stays honest end to end. A bespoke `effect Http;` would not be +// visible to importing modules' effect checker, so `Net` is both more +// correct and the only cross-module-sound choice. + +/// An outgoing HTTP request. +/// +/// `headers` is an ordered association list of `(name, value)` pairs. +/// `body` is absent for verbs that take none (GET/HEAD). +pub type Request = { + url: String, + method: String, + headers: [(String, String)], + body: Option +} + +/// A host HTTP response. `body` is the raw response text; structured +/// decoding (JSON) is the caller's concern and is the subject of the +/// next spine primitive, #161. +pub type Response = { + status: Int, + headers: [(String, String)], + body: String +} + +// Core boundary primitive. No AffineScript body: the Deno-ESM backend +// lowers a call here to `(await __as_httpFetch(url, method, headers, +// body))`. The `Async` effect is real — callers compile to +// `async function` (codegen_deno async-fn detection). +extern fn http_request(url: String, + method: String, + headers: [(String, String)], + body: Option) -> Response / { Net, Async }; + +/// Build a `Request` value (ergonomic constructor for the record). +pub fn request(method: String, + url: String, + headers: [(String, String)], + body: Option) -> Request { + #{ url: url, method: method, headers: headers, body: body } +} + +/// Perform the HTTP request described by `req`. +pub fn fetch(req: Request) -> Response / { Net, Async } { + http_request(req.url, req.method, req.headers, req.body) +} + +/// `GET url` with no extra request headers. +pub fn get(url: String) -> Response / { Net, Async } { + http_request(url, "GET", [], None) +} + +/// `POST body` to `url` with no extra request headers. +pub fn post(url: String, body: String) -> Response / { Net, Async } { + http_request(url, "POST", [], Some(body)) +} + +/// True for a 2xx status. +pub fn is_ok(resp: Response) -> Bool { + resp.status >= 200 && resp.status < 300 +} diff --git a/tests/codegen-deno/class_basic.affine b/tests/codegen-deno/class_basic.affine index e60a24a..a56e165 100644 --- a/tests/codegen-deno/class_basic.affine +++ b/tests/codegen-deno/class_basic.affine @@ -15,7 +15,7 @@ pub extern fn jsonStringify(v: Int) -> String; struct Counter { start: Int } -pub fn Counter_new(start: Int) -> Counter = Counter { start: start }; +pub fn Counter_new(start: Int) -> Counter = Counter #{ start: start }; pub fn Counter_bumped(c: Counter, by: Int) -> Int = c.start + by; diff --git a/tests/codegen-deno/http_fetch.affine b/tests/codegen-deno/http_fetch.affine new file mode 100644 index 0000000..7a12aee --- /dev/null +++ b/tests/codegen-deno/http_fetch.affine @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// issue #160 — portable Http.fetch end-to-end on the Deno-ESM backend. +// +// Exercises the real stdlib/Http.affine (flattened in via `use`): the +// `http_request` extern -> `(await __as_httpFetch(...))` lowering, the +// `/ { Net, Async }` effect row -> `async function` detection, the +// [(String,String)] header assoc list, and the Option body. +// The harness stubs `globalThis.fetch`, so no network is touched. +// +// Signatures stay in primitives (Int/String/Bool) so the fixture never +// re-annotates the imported nominal `Request`/`Response` across the +// module boundary; field access on the inferred response is enough. + +use prelude::{Some, None}; +use Http::{fetch, get, post, request, is_ok}; + +pub fn get_status(url: String) -> Int / { Net, Async } { + get(url).status +} + +pub fn get_body(url: String) -> String / { Net, Async } { + get(url).body +} + +pub fn post_status(url: String, body: String) -> Int / { Net, Async } { + post(url, body).status +} + +pub fn req_get_status(url: String) -> Int / { Net, Async } { + fetch(request("GET", url, [("x-test", "1")], None)).status +} + +pub fn req_post_body(url: String, body: String) -> String / { Net, Async } { + fetch(request("POST", url, [], Some(body))).body +} + +pub fn ok_get(url: String) -> Bool / { Net, Async } { + is_ok(get(url)) +} diff --git a/tests/codegen-deno/http_fetch.harness.mjs b/tests/codegen-deno/http_fetch.harness.mjs new file mode 100644 index 0000000..955e426 --- /dev/null +++ b/tests/codegen-deno/http_fetch.harness.mjs @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// issue #160 — Node-ESM harness for portable Http.fetch (Deno-ESM backend). +// +// Stubs `globalThis.fetch` so nothing touches the network: the stub +// echoes the request so we can assert the lowering passed url/method/ +// headers/body through correctly, and round-trips status/headers/body. +import assert from "node:assert/strict"; + +let lastCall = null; +globalThis.fetch = async (url, init) => { + lastCall = { url, init }; + const h = new Map([ + ["content-type", "text/plain"], + ["x-echo-method", init.method], + ]); + return { + status: url.includes("/missing") ? 404 : 200, + headers: { forEach: (cb) => h.forEach((v, k) => cb(v, k)) }, + text: async () => `body-for:${init.method}:${init.body ?? ""}`, + }; +}; + +const { + get_status, + get_body, + post_status, + req_get_status, + req_post_body, + ok_get, +} = await import("./http_fetch.deno.js"); + +// GET — status round-trips, no body sent +assert.equal(await get_status("https://example.test/ok"), 200, "GET 200"); +assert.equal(lastCall.init.method, "GET", "method lowered = GET"); +assert.equal(lastCall.init.body, undefined, "GET has no body"); + +// GET — body text round-trips +assert.equal( + await get_body("https://example.test/ok"), + "body-for:GET:", + "GET body round-trip", +); + +// GET — 404 path +assert.equal( + await get_status("https://example.test/missing"), + 404, + "GET 404 status", +); + +// POST — method + body passed through +assert.equal( + await post_status("https://example.test/p", "hello"), + 200, + "POST 200", +); +assert.equal(lastCall.init.method, "POST", "method lowered = POST"); +assert.equal(lastCall.init.body, "hello", "POST body passed through"); + +// request() builder + fetch() — header assoc list -> request headers +assert.equal( + await req_get_status("https://example.test/h"), + 200, + "fetch(request(...)) status", +); +assert.equal( + lastCall.init.headers["x-test"], + "1", + "assoc-list header [(\"x-test\",\"1\")] lowered to header object", +); + +// request() POST with Some(body) +assert.equal( + await req_post_body("https://example.test/p", "payload"), + "body-for:POST:payload", + "Some(body) -> request body", +); + +// is_ok — 2xx classification +assert.equal(await ok_get("https://example.test/ok"), true, "is_ok 200"); +assert.equal(await ok_get("https://example.test/missing"), false, "is_ok 404"); + +console.log("http_fetch.harness.mjs OK"); diff --git a/tests/codegen-deno/ref_fields.affine b/tests/codegen-deno/ref_fields.affine index 998ffcd..21c57e4 100644 --- a/tests/codegen-deno/ref_fields.affine +++ b/tests/codegen-deno/ref_fields.affine @@ -6,7 +6,7 @@ struct Point { x: Int, y: Int } -pub fn mk_point(x: Int, y: Int) -> Point = Point { x: x, y: y }; +pub fn mk_point(x: Int, y: Int) -> Point = Point #{ x: x, y: y }; pub fn sum_ref(p: ref Point) -> Int = p.x + p.y;