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
41 changes: 41 additions & 0 deletions stdlib/Http.affine
Original file line number Diff line number Diff line change
Expand Up @@ -128,3 +128,44 @@ pub extern fn http_request_thenable(url: String,
/// single-fire + a guest-side defensive trap on re-entry).
pub extern fn thenableThen(t: Thenable,
on_settle: fn(Unit) -> Int) -> Int / { Async };

// ── Typed Response reader (issue #225 PR3d, ADR-013 §Value-reconstruction) ──
//
// After a `Thenable` settles, the continuation needs the full
// `Response`. `jsonField` (one top-level *scalar* String) cannot decode
// `headers: [(String, String)]` — a JSON array of pairs. ADR-013
// §Value-reconstruction calls for a *minimal typed reader for the fixed
// `Response` shape*, deferring general JSON decode to #161.
//
// These host-mediated primitives expose the settled payload as
// scalars/strings — the same proven extern convention as
// `thenableResultJson`/`jsonField` (the host reads its own settled
// record, keyed by the `Thenable` handle; no structured value crosses
// the i32 boundary). The guest reconstructs the fixed record in
// `readResponse`; its header loop exercises the #255-fixed `while`
// codegen.

pub extern fn responseStatus(t: Thenable) -> Int / { Async };
pub extern fn responseBody(t: Thenable) -> String / { Async };
pub extern fn responseHeaderCount(t: Thenable) -> Int / { Async };
pub extern fn responseHeaderName(t: Thenable, index: Int) -> String / { Async };
pub extern fn responseHeaderValue(t: Thenable, index: Int) -> String / { Async };

/// Reconstruct the fixed `Response` shape from a settled `Thenable`.
///
/// The ADR-013 *minimal typed reader*: it performs the structured
/// `headers` decode `jsonField` cannot, for exactly the fixed
/// `{ status, headers, body }` shape (no silent lossiness — those are
/// the only `Response` fields). General JSON decode stays #161's
/// concern. Call only after `t` has settled (from a `thenableThen`
/// continuation / the CPS-lowered surface).
pub fn readResponse(t: Thenable) -> Response {
let mut headers = [];
let mut i = 0;
let n = responseHeaderCount(t);
while i < n {
headers = headers ++ [(responseHeaderName(t, i), responseHeaderValue(t, i))];
i = i + 1;
}
#{ status: responseStatus(t), headers: headers, body: responseBody(t) }
}
59 changes: 59 additions & 0 deletions tests/codegen/http_response_reader.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
// issue #225 PR3d — wasm e2e: the typed `Response` reader primitives
// (ADR-013 §Value-reconstruction) + http_fetch-parity on the WasmGC
// path.
//
// Mirrors tests/codegen-deno/http_fetch.* (same Response field access
// on Deno-ESM) but on the wasm Thenable + CPS path. The author writes
// straight-line code: an async boundary (`http_request_thenable`) then
// a continuation that reconstructs the FULL typed `Response`
// (`status` + `headers: [(String,String)]` + `body`) from the settled
// payload via the scalar/string host primitives — the structured
// `headers` decode `jsonField` cannot do.
//
// On WasmGC the backend does NOT flatten imports (ADR-013 §refinement):
// a cross-module `pub fn` like `Http.readResponse` would become a host
// import *returning a `Response` record* — structured value across the
// i32 boundary, exactly what the scalar-extern convention exists to
// avoid. So the reconstruction is done HERE, in the compiled-in
// consuming unit, over the scalar/string primitives (each a simple
// host import like `jsonField`). `Http.readResponse` (stdlib) is the
// same logic for the Deno-ESM backend, which DOES flatten. The header
// loop runs the #255-fixed `while`/`for` codegen.

// `thenableThen` is in the `use` list so it resolves as an import for
// the CPS transform to emit, even though the author never calls it
// (auto-injecting that import is broader transparent-surface work,
// PR3+; same convention as http_cps_base.affine).
use Http::{
Thenable, http_request_thenable, thenableThen,
responseStatus, responseHeaderCount,
responseHeaderName, responseHeaderValue
};

// Reconstruct + fold the typed Response into one i32 the host asserts:
// status(200) + 10*header_count(1) + 1000 (2xx => is_ok) = 1210.
// A wrong/partial decode cannot produce 1210; the header count proves
// the [(String,String)] structured decode jsonField cannot do.
fn decode(t: Thenable) -> Int {
let status = responseStatus(t);
let mut headers = [];
let mut i = 0;
let n = responseHeaderCount(t);
while i < n {
headers = headers ++ [(responseHeaderName(t, i), responseHeaderValue(t, i))];
i = i + 1;
}
let mut hc = 0;
for h in headers {
hc = hc + 1;
}
let ok = if status >= 200 && status < 300 { 1000 } else { 0 };
status + 10 * hc + ok
}

// Single async boundary, then the continuation reconstructs + folds.
pub fn launch() -> Int / { Net, Async } {
let t = http_request_thenable("https://example.test/ok", "GET", "");
decode(t)
}
143 changes: 143 additions & 0 deletions tests/codegen/test_http_response_reader.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
// issue #225 PR3d — wasm e2e host for http_response_reader.affine.
//
// Same #205 Thenable + #199 closure scaffold as test_http_cps_base.mjs
// (mirrors packages/affine-vscode/mod.js). The continuation here is the
// typed-Response reconstruction over the scalar/string primitives; the
// harness implements those host imports off the settled record and
// asserts the folded result proves status + structured `headers`
// decode + is_ok — i.e. http_fetch parity (cf. the Deno-ESM
// tests/codegen-deno/http_fetch.* field access) on the wasm path.
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';

// Stubbed host fetch: 1 response header (proves the [(String,String)]
// decode jsonField cannot do), status 200.
globalThis.fetch = async (url, init) => ({
status: url.includes('/missing') ? 404 : 200,
headers: { forEach: (cb) => cb('text/plain', 'content-type') },
text: async () => `ok:${init && init.method}`,
});

let inst = null;
const _handles = new Map();
const _results = new Map();
let _next = 1;
let contFired = 0;
let contReturn = null;
let savedCb = null;
const reads = []; // names of response* primitives the reader invoked

function readString(ptr) {
const dv = new DataView(inst.exports.memory.buffer);
const len = dv.getUint32(ptr, true);
const bytes = new Uint8Array(inst.exports.memory.buffer, ptr + 4, len);
return new TextDecoder('utf-8').decode(bytes);
}

function wrapHandler(closurePtr) {
return () => {
const tbl = inst.exports.__indirect_function_table;
const dv = new DataView(inst.exports.memory.buffer);
const fnId = dv.getInt32(closurePtr, true);
const envPtr = dv.getInt32(closurePtr + 4, true);
const fn = tbl.get(fnId);
const args = [envPtr];
while (args.length < fn.length) args.push(0);
return fn(...args);
};
}

const settled = (tHandle) => _results.get(tHandle);

const imports = {
wasi_snapshot_preview1: { fd_write: () => 0 },
Http: {
http_request_thenable: (urlPtr, methodPtr, bodyPtr) => {
const url = readString(urlPtr);
const method = readString(methodPtr);
const body = readString(bodyPtr);
const h = _next++;
const p = globalThis
.fetch(url, { method, body: body || undefined })
.then(async (r) => {
const headers = [];
r.headers.forEach((v, k) => headers.push([k, v]));
return { status: r.status, headers, body: await r.text() };
})
.catch((e) => ({ __error: String(e) }));
_handles.set(h, p);
return h;
},
thenableThen: (tHandle, onSettlePtr) => {
const cb = wrapHandler(onSettlePtr);
savedCb = cb;
Promise.resolve(_handles.get(tHandle)).then((v) => {
_results.set(tHandle, v);
contFired += 1;
contReturn = cb();
});
return 1;
},
// Typed-reader scalar/string primitives (ADR-013 minimal reader;
// the proven jsonField-style convention — no record crosses the
// i32 boundary). String returns are opaque host handles the guest
// only stores; the fold asserts status + header count + is_ok.
responseStatus: (t) => {
reads.push('status');
const v = settled(t);
return v && typeof v.status === 'number' ? v.status : -1;
},
responseHeaderCount: (t) => {
reads.push('count');
const v = settled(t);
return v && Array.isArray(v.headers) ? v.headers.length : -1;
},
responseHeaderName: (t, i) => {
reads.push(`name${i}`);
return 0x4000 + i; // opaque String handle (guest never decodes)
},
responseHeaderValue: (t, i) => {
reads.push(`value${i}`);
return 0x8000 + i;
},
},
};

const buf = await readFile('./tests/codegen/http_response_reader.wasm');
inst = (await WebAssembly.instantiate(buf, imports)).instance;

// 1. launch() returns synchronously (async deferred via the transform).
const disposable = inst.exports.launch();
assert.ok(Number.isInteger(disposable), 'launch() returns synchronously');
assert.equal(contFired, 0, 'continuation deferred until settlement');

// 2. settle.
await new Promise((r) => setTimeout(r, 0));
await Promise.resolve();

assert.equal(contFired, 1, 'continuation fired exactly once');
// status 200 + 10*headerCount(1) + 1000 (200 is 2xx) = 1210. Only a
// correct status + structured-header decode + is_ok yields this.
assert.equal(
contReturn,
1210,
'typed Response reconstructed: status=200, headers decoded (count=1), is_ok=true',
);
assert.ok(
reads.includes('status') &&
reads.includes('count') &&
reads.includes('name0') &&
reads.includes('value0'),
'continuation invoked the typed-reader primitives (incl. per-header decode)',
);

// 3. ADR-013 obligation 1: a forced second resumption traps.
assert.throws(
() => savedCb(),
(e) => e instanceof WebAssembly.RuntimeError,
'second continuation entry traps (once-resumption guard)',
);
assert.equal(contFired, 1, 'trapped re-entry did not re-run the continuation');

console.log('test_http_response_reader.mjs OK');
Loading