Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions docs/guides/migration-playbook.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> }
// 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 <<see-also,Settled Decisions>>,
typed-wasm ADR-004); AffineScript is not coupled to it.

[#decision-criteria]
== Decision Criteria

Expand Down Expand Up @@ -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 `<<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.

| 1.3
| 2026-05-18
| 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.
|===
81 changes: 76 additions & 5 deletions lib/codegen_deno.ml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}

Expand All @@ -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)

Expand Down Expand Up @@ -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<String> == { 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 ----

|}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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. *)
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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 ->
Expand Down
89 changes: 89 additions & 0 deletions stdlib/Http.affine
Original file line number Diff line number Diff line change
@@ -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<String, String>`: 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<String>
}

/// 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<String>) -> Response / { Net, Async };

/// Build a `Request` value (ergonomic constructor for the record).
pub fn request(method: String,
url: String,
headers: [(String, String)],
body: Option<String>) -> 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
}
2 changes: 1 addition & 1 deletion tests/codegen-deno/class_basic.affine
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
39 changes: 39 additions & 0 deletions tests/codegen-deno/http_fetch.affine
Original file line number Diff line number Diff line change
@@ -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<String> 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))
}
Loading
Loading