Skip to content

Commit 86b4e7e

Browse files
hyperpolymathclaude
andcommitted
feat(stdlib): portable Http primitive — Deno-ESM target (Refs #160)
stdlib/Http.affine: typed `Request`/`Response` with assoc-list headers `[(String, String)]` and `Option<String>` body; `fetch`/`get`/`post`/ `request`/`is_ok`. Effect row `/ { Net, Async }` (reserved built-in `Net` — cross-module-sound, no per-module effect decl). Headers are an assoc list, not Dict: lets #160 land independently of #162 while preserving spine order #160 -> #161 -> #162; upgrade to Dict later is source-compatible for callers using the helpers. codegen_deno.ml — general Async-effect lowering (serves the whole migration spine, not a one-off): * fd_is_async: a fn whose effect row mentions `Async` is emitted as `async function` with its body in async context. * async free-fn calls are awaited at the call site (so `get(u).status` reads the resolved value, not a Promise). * __as_httpFetch runtime: assoc-list <-> header object, `Option` body, `globalThis.fetch` round-trip (explicit `globalThis.` — the stdlib `fetch` compiles to a module-level `function fetch` that would otherwise shadow the host). * http_request extern lowers to `(await __as_httpFetch(...))`. tests/codegen-deno/http_fetch.{affine,harness.mjs}: e2e via the real flattened stdlib/Http.affine, `globalThis.fetch` stubbed (no network) — GET/POST/headers/body/404/is_ok asserted. Full Deno suite + 258-test gate green (+1 = auto-picked-up AOT Http smoke case). Also migrates two pre-existing baseline-rot fixtures (class_basic.affine, ref_fields.affine) from retired `Type { f: x }` to `Type #{ f: x }` (#218 record syntax; the estate sweep skipped in-tree test fixtures). Not a #160 change; required to make the Deno suite green. Deno-ESM target only here by design; typed-wasm target (the convergence ABI shared with Ephapax) is tracked as the next slice in #225. Refs #160 (stays open until both targets land); companion #225. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 865ff0e commit 86b4e7e

7 files changed

Lines changed: 327 additions & 7 deletions

File tree

docs/guides/migration-playbook.adoc

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,40 @@ The `affinescript compile graph.affine -o graph.onnx` invocation produces a real
164164

165165
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.
166166

167+
[#portable-http]
168+
=== Portable HTTP (ReScript `fetch` / `Js.Promise` → `Http` stdlib)
169+
170+
ReScript networking is `fetch(...)` returning a `Js.Promise.t`, usually
171+
wrapped in a `.then`/`.catch` umbrella. Do not port the promise. Port to
172+
the `Http` stdlib module (`use Http::{fetch, get, post, request};`),
173+
whose surface is a typed request/response with an honest effect row:
174+
175+
[source]
176+
----
177+
pub fn fetch(req: Request) -> Response / { Net, Async }
178+
// Request #{ url, method, headers: [(String, String)], body: Option<String> }
179+
// Response #{ status: Int, headers: [(String, String)], body: String }
180+
----
181+
182+
Re-decomposition rules:
183+
184+
* The `.catch` umbrella becomes a `match` on `is_ok(resp)` / `resp.status`
185+
— failure is data, not a detached rejected promise.
186+
* Headers are an *association list* `[(String, String)]`, not an opaque
187+
object. Build/read them explicitly. (This will become `Dict` once #162
188+
lands; the change is source-compatible for callers using the helpers.)
189+
* The response `body` is raw text. JSON decoding is a *separate* step
190+
(the #161 `Json` primitive) — do not conflate transport with parsing.
191+
* Every function that transitively calls `Http` carries `/ { Net, Async }`
192+
in its signature. That is the point: the network and its asynchrony are
193+
visible in the type, where ReScript hid them in a `Promise`.
194+
195+
Backend status: the Deno-ESM target lowers this to a `globalThis.fetch`
196+
round-trip (Deno / Node 18+ / browser ESM) and is fully exercised
197+
end-to-end. The typed-wasm target is a tracked next slice — the
198+
convergence ABI shared with Ephapax (see <<see-also,Settled Decisions>>,
199+
typed-wasm ADR-004); AffineScript is not coupled to it.
200+
167201
[#decision-criteria]
168202
== Decision Criteria
169203

@@ -341,4 +375,8 @@ If you complete a non-trivial `.res → .affine` translation and the re-decompos
341375
| 1.2
342376
| 2026-05-02
343377
| 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 `<<calling-target-intrinsics>>` recipe documents the stub-as-intrinsic pattern that lets user code call CUDA / ONNX / Faust / Verilog runtime ops without extending the language.
378+
379+
| 1.3
380+
| 2026-05-18
381+
| New `<<portable-http>>` 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.
344382
|===

lib/codegen_deno.ml

Lines changed: 76 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,11 @@ type codegen_ctx = {
6363
definition shadows a same-named host intrinsic ({!deno_builtins}),
6464
so e.g. a user `fn len(xs: IntList)` is NOT lowered to `.length`. *)
6565
local_fns : (string, unit) Hashtbl.t;
66+
(* Top-level functions declared with an `Async` effect row. A call to
67+
one of these is a Promise; from an async context the call site must
68+
`await` it (e.g. `get(u).status` would otherwise read `.status` off
69+
a pending Promise). Populated in {!generate}. *)
70+
async_fns : (string, unit) Hashtbl.t;
6671
(* True while emitting a synthesised `async` method body. The
6772
expression-position IIFE wrappers (block/try/match/let/return) must
6873
then be `async` and awaited, because they may contain an awaited
@@ -79,6 +84,7 @@ let create_ctx symbols = {
7984
self_name = None;
8085
assoc = Hashtbl.create 32;
8186
local_fns = Hashtbl.create 64;
87+
async_fns = Hashtbl.create 32;
8288
in_async = false;
8389
}
8490

@@ -98,6 +104,19 @@ let emit_line ctx str =
98104

99105
let increase_indent ctx = { ctx with indent = ctx.indent + 1 }
100106

107+
(* True if an effect row mentions `Async`. A function whose declared
108+
effect row includes Async compiles to an `async function`, and its
109+
body is emitted in async context (`in_async`) so an awaited host
110+
call (e.g. the `http_request` -> `await fetch(...)` lowering for
111+
issue #160) is legal JS. This is the free-function analogue of the
112+
unconditionally-async synthesised methods (`gen_class`). *)
113+
let rec eff_has_async : effect_expr -> bool = function
114+
| EffVar id | EffCon (id, _) -> id.name = "Async"
115+
| EffUnion (a, b) -> eff_has_async a || eff_has_async b
116+
117+
let fd_is_async (fd : fn_decl) : bool =
118+
match fd.fd_eff with Some e -> eff_has_async e | None -> false
119+
101120
(* ============================================================================
102121
Runtime prelude (ESM, Deno-flavoured)
103122
@@ -159,6 +178,34 @@ const __as_parseFloat = (s) => {
159178
return Number.isNaN(n) ? None : Some(n);
160179
};
161180
const __as_show = (v) => (typeof v === "string" ? v : JSON.stringify(v));
181+
// ---- Http (issue #160): portable fetch round-trip ----
182+
// `headers` crosses the boundary as an AffineScript [(String, String)]
183+
// assoc list == JS array of [name, value] pairs. `body` is an
184+
// AffineScript Option<String> == { tag: "Some", value } | { tag: "None" }.
185+
// The result is the `Response` record shape { status, headers, body }.
186+
const __as_httpHeadersToObject = (pairs) => {
187+
const o = {};
188+
for (const kv of (pairs || [])) o[kv[0]] = kv[1];
189+
return o;
190+
};
191+
const __as_httpHeadersFromResponse = (res) => {
192+
const out = [];
193+
res.headers.forEach((value, key) => out.push([key, value]));
194+
return out;
195+
};
196+
const __as_httpFetch = async (url, method, headers, bodyOpt) => {
197+
const init = { method, headers: __as_httpHeadersToObject(headers) };
198+
if (bodyOpt && bodyOpt.tag === "Some") init.body = bodyOpt.value;
199+
// `globalThis.fetch` explicitly: the stdlib `Http.fetch` compiles to a
200+
// module-level `function fetch`, which would otherwise shadow the host.
201+
const res = await globalThis.fetch(url, init);
202+
const text = await res.text();
203+
return {
204+
status: res.status,
205+
headers: __as_httpHeadersFromResponse(res),
206+
body: text,
207+
};
208+
};
162209
// ---- end runtime ----
163210

164211
|}
@@ -227,7 +274,14 @@ let () =
227274
b "char_to_int" (fun a -> Printf.sprintf "__as_charToInt(%s)" (arg 0 a));
228275
b "int_to_char" (fun a -> Printf.sprintf "__as_intToChar(%s)" (arg 0 a));
229276
b "show" (fun a -> Printf.sprintf "__as_show(%s)" (arg 0 a));
230-
b "panic" (fun a -> Printf.sprintf "(() => { throw new Error(%s); })()" (arg 0 a))
277+
b "panic" (fun a -> Printf.sprintf "(() => { throw new Error(%s); })()" (arg 0 a));
278+
(* ---- Http (issue #160) ---- *)
279+
(* `await` is legal: every caller of `http_request` is declared
280+
`/ Net, Async` and so is emitted as an `async function`
281+
(see {!fd_is_async}). *)
282+
b "http_request" (fun a ->
283+
Printf.sprintf "(await __as_httpFetch(%s, %s, %s, %s))"
284+
(arg 0 a) (arg 1 a) (arg 2 a) (arg 3 a))
231285

232286
(* ============================================================================
233287
Identifier sanitisation (JS reserved words -> trailing underscore)
@@ -304,7 +358,15 @@ let rec gen_expr ctx (expr : expr) : string =
304358
mangle id.name ^ "(" ^ String.concat ", " arg_strs ^ ")"
305359
| _ ->
306360
let arg_strs = List.map (gen_expr ctx) args in
307-
gen_expr ctx func ^ "(" ^ String.concat ", " arg_strs ^ ")")
361+
let call =
362+
gen_expr ctx func ^ "(" ^ String.concat ", " arg_strs ^ ")" in
363+
(match func with
364+
| ExprVar id
365+
when Hashtbl.mem ctx.async_fns id.name && ctx.in_async ->
366+
(* Async free fn returns a Promise; await at the call
367+
site so `get(u).status` reads the resolved value. *)
368+
"(await " ^ call ^ ")"
369+
| _ -> call))
308370
| ExprBinary (e1, OpConcat, e2) ->
309371
(* `++` is string- OR array-concat; dispatch on shape at runtime so
310372
`a ++ b` (string) and `acc ++ [x]` (array) are both correct. *)
@@ -648,11 +710,18 @@ let gen_function ctx (fd : fn_decl) : unit =
648710
let name = mangle fd.fd_name.name in
649711
let params =
650712
List.map (fun (p : param) -> mangle p.p_name.name) fd.fd_params in
713+
let is_async = fd_is_async fd in
714+
let async_kw = if is_async then "async " else "" in
651715
let kw =
652-
if visibility_is_public fd.fd_vis then "export function" else "function" in
716+
if visibility_is_public fd.fd_vis
717+
then "export " ^ async_kw ^ "function"
718+
else async_kw ^ "function" in
653719
emit_line ctx
654720
(Printf.sprintf "%s %s(%s) {" kw name (String.concat ", " params));
655-
gen_body (increase_indent ctx) fd.fd_body;
721+
let body_ctx = increase_indent ctx in
722+
let body_ctx =
723+
if is_async then { body_ctx with in_async = true } else body_ctx in
724+
gen_body body_ctx fd.fd_body;
656725
emit_line ctx "}";
657726
emit ctx "\n"
658727

@@ -824,7 +893,9 @@ let generate (program : program) (symbols : Symbol.t) : string =
824893
| TopFn fd when fd.fd_body = FnExtern ->
825894
Hashtbl.replace ctx.externs fd.fd_name.name ()
826895
| TopFn fd ->
827-
Hashtbl.replace ctx.local_fns fd.fd_name.name ()
896+
Hashtbl.replace ctx.local_fns fd.fd_name.name ();
897+
if fd_is_async fd then
898+
Hashtbl.replace ctx.async_fns fd.fd_name.name ()
828899
| TopConst { tc_name; _ } ->
829900
Hashtbl.replace ctx.local_fns tc_name.name ()
830901
| TopImpl ib ->

stdlib/Http.affine

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// SPDX-FileCopyrightText: 2026 hyperpolymath
3+
//
4+
// Http.affine — portable HTTP requests (issue #160).
5+
//
6+
// The C-spine migration primitive that replaces ReScript's
7+
// `fetch`/`Promise`-based networking with a typed, effect-rowed surface.
8+
//
9+
// Portability: the single `http_request` extern is lowered by the
10+
// Deno-ESM backend (lib/codegen_deno.ml) to a `globalThis.fetch`
11+
// round-trip, which runs unmodified on Deno, Node 18+, and browser ESM.
12+
// The WasmGC / Node-CJS host-import path is a tracked follow-up
13+
// (the runtime there has no `fetch` import yet) — see the issue thread.
14+
//
15+
// Headers are an association list `[(String, String)]` rather than a
16+
// `Dict<String, String>`: this lets #160 land independently of #162
17+
// (Dict) while keeping the stated spine order #160 -> #161 -> #162.
18+
// Upgrading `headers` to `Dict` later is an additive, source-compatible
19+
// change for callers that build/read headers through the helpers here.
20+
21+
module Http;
22+
23+
// `Option`/`Some`/`None` are owned by `prelude` (ADR-011).
24+
use prelude::{Option, Some, None};
25+
26+
// Networking uses the reserved built-in effect `Net` (no declaration
27+
// needed — it is one of the language's reserved effect names alongside
28+
// `Random`/`Time`). Every signature that can touch the network carries
29+
// `/ Net`; the host round-trip is also `Async`, so the effect row
30+
// stays honest end to end. A bespoke `effect Http;` would not be
31+
// visible to importing modules' effect checker, so `Net` is both more
32+
// correct and the only cross-module-sound choice.
33+
34+
/// An outgoing HTTP request.
35+
///
36+
/// `headers` is an ordered association list of `(name, value)` pairs.
37+
/// `body` is absent for verbs that take none (GET/HEAD).
38+
pub type Request = {
39+
url: String,
40+
method: String,
41+
headers: [(String, String)],
42+
body: Option<String>
43+
}
44+
45+
/// A host HTTP response. `body` is the raw response text; structured
46+
/// decoding (JSON) is the caller's concern and is the subject of the
47+
/// next spine primitive, #161.
48+
pub type Response = {
49+
status: Int,
50+
headers: [(String, String)],
51+
body: String
52+
}
53+
54+
// Core boundary primitive. No AffineScript body: the Deno-ESM backend
55+
// lowers a call here to `(await __as_httpFetch(url, method, headers,
56+
// body))`. The `Async` effect is real — callers compile to
57+
// `async function` (codegen_deno async-fn detection).
58+
extern fn http_request(url: String,
59+
method: String,
60+
headers: [(String, String)],
61+
body: Option<String>) -> Response / { Net, Async };
62+
63+
/// Build a `Request` value (ergonomic constructor for the record).
64+
pub fn request(method: String,
65+
url: String,
66+
headers: [(String, String)],
67+
body: Option<String>) -> Request {
68+
#{ url: url, method: method, headers: headers, body: body }
69+
}
70+
71+
/// Perform the HTTP request described by `req`.
72+
pub fn fetch(req: Request) -> Response / { Net, Async } {
73+
http_request(req.url, req.method, req.headers, req.body)
74+
}
75+
76+
/// `GET url` with no extra request headers.
77+
pub fn get(url: String) -> Response / { Net, Async } {
78+
http_request(url, "GET", [], None)
79+
}
80+
81+
/// `POST body` to `url` with no extra request headers.
82+
pub fn post(url: String, body: String) -> Response / { Net, Async } {
83+
http_request(url, "POST", [], Some(body))
84+
}
85+
86+
/// True for a 2xx status.
87+
pub fn is_ok(resp: Response) -> Bool {
88+
resp.status >= 200 && resp.status < 300
89+
}

tests/codegen-deno/class_basic.affine

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ pub extern fn jsonStringify(v: Int) -> String;
1515

1616
struct Counter { start: Int }
1717

18-
pub fn Counter_new(start: Int) -> Counter = Counter { start: start };
18+
pub fn Counter_new(start: Int) -> Counter = Counter #{ start: start };
1919

2020
pub fn Counter_bumped(c: Counter, by: Int) -> Int = c.start + by;
2121

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// issue #160 — portable Http.fetch end-to-end on the Deno-ESM backend.
3+
//
4+
// Exercises the real stdlib/Http.affine (flattened in via `use`): the
5+
// `http_request` extern -> `(await __as_httpFetch(...))` lowering, the
6+
// `/ { Net, Async }` effect row -> `async function` detection, the
7+
// [(String,String)] header assoc list, and the Option<String> body.
8+
// The harness stubs `globalThis.fetch`, so no network is touched.
9+
//
10+
// Signatures stay in primitives (Int/String/Bool) so the fixture never
11+
// re-annotates the imported nominal `Request`/`Response` across the
12+
// module boundary; field access on the inferred response is enough.
13+
14+
use prelude::{Some, None};
15+
use Http::{fetch, get, post, request, is_ok};
16+
17+
pub fn get_status(url: String) -> Int / { Net, Async } {
18+
get(url).status
19+
}
20+
21+
pub fn get_body(url: String) -> String / { Net, Async } {
22+
get(url).body
23+
}
24+
25+
pub fn post_status(url: String, body: String) -> Int / { Net, Async } {
26+
post(url, body).status
27+
}
28+
29+
pub fn req_get_status(url: String) -> Int / { Net, Async } {
30+
fetch(request("GET", url, [("x-test", "1")], None)).status
31+
}
32+
33+
pub fn req_post_body(url: String, body: String) -> String / { Net, Async } {
34+
fetch(request("POST", url, [], Some(body))).body
35+
}
36+
37+
pub fn ok_get(url: String) -> Bool / { Net, Async } {
38+
is_ok(get(url))
39+
}

0 commit comments

Comments
 (0)