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
6 changes: 3 additions & 3 deletions docs/bindings-roadmap.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,9 @@ no further significant ReScript → AffineScript work is tractable.

|9
|*IPC / structuredClone* for host↔guest message passing (postMessage, MessageChannel, transfer ownership)
|`○`
|`affinescript-ipc`, or under `Deno`
|idaptik-ums uses Gossamer IPC for level I/O; pattern is broad — any embedded-engine binding needs it.
|`◑` partial (MessageChannel + MessagePort + structuredClone + generic `targetPostMessage` landed; `transfer` array argument, `BroadcastChannel`, dedicated Worker constructors, and typed `MessageEvent` accessors deferred)
|`stdlib/Ipc.affine`
|idaptik-ums uses Gossamer IPC for level I/O; pattern is broad — any embedded-engine binding needs it. Landed 2026-05-31. Surface: 2 extern types (`MessageChannel`, `MessagePort`) + 9 extern fns covering channel construction, port handoff, post + onmessage, start/close lifecycle, generic `targetPostMessage` (Worker / iframe.contentWindow / self-from-worker), and `structuredCloneValue` deep-copy. No consumer init required — `MessageChannel` / `structuredClone` are standard web-platform globals (Deno, Node 16+, browsers, Web Workers). Test fixture: `tests/codegen-deno/ipc_smoke.{affine,harness.mjs}` exercises a port-pair round-trip with handler identity preservation + a target-post stub + a deep-clone reference-equality assertion. Follow-ups: `transfer` list (Transferables), `BroadcastChannel`, typed `MessageEvent` accessors (bindings #41 axis), Worker constructors (Tier 3 #25).

|10
|*JSON Schema validation (Ajv)* — already exists as `stdlib/Ajv.affine`
Expand Down
23 changes: 23 additions & 0 deletions lib/codegen_deno.ml
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,19 @@ const __as_pixiSoundPause = (s) => { s.pause(); return 0; };
const __as_pixiSoundResume = (s) => { s.resume(); return 0; };
const __as_pixiSoundSetVolume = (s, vol) => { s.volume = vol; return 0; };
const __as_pixiSoundSetLoop = (s, loop) => { s.loop = loop; return 0; };
// ---- Ipc (bindings #9): web-platform MessageChannel/MessagePort ----
// Uses standard web globals (MessageChannel, structuredClone) — no
// consumer-side init required. Available unmodified in Deno, Node 16+,
// browsers, and Web Workers.
const __as_messageChannelNew = () => new MessageChannel();
const __as_messageChannelPort1 = (ch) => ch.port1;
const __as_messageChannelPort2 = (ch) => ch.port2;
const __as_messagePortPostMessage = (p, data) => { p.postMessage(data); return 0; };
const __as_messagePortOnMessage = (p, handler) => { p.onmessage = handler; return 0; };
const __as_messagePortStart = (p) => { p.start(); return 0; };
const __as_messagePortClose = (p) => { p.close(); return 0; };
const __as_targetPostMessage = (t, msg) => { t.postMessage(msg); return 0; };
const __as_structuredCloneValue = (v) => structuredClone(v);
// `++` is overloaded (string concat / array concat); `a + b` would
// stringify arrays. Dispatch on shape so stdlib/string.affine's
// `result ++ [x]` and `a ++ b` are both correct.
Expand Down Expand Up @@ -557,6 +570,16 @@ let () =
b "pixiSoundResume" (fun a -> Printf.sprintf "__as_pixiSoundResume(%s)" (arg 0 a));
b "pixiSoundSetVolume" (fun a -> Printf.sprintf "__as_pixiSoundSetVolume(%s, %s)" (arg 0 a) (arg 1 a));
b "pixiSoundSetLoop" (fun a -> Printf.sprintf "__as_pixiSoundSetLoop(%s, %s)" (arg 0 a) (arg 1 a));
(* ---- Ipc (bindings #9): MessageChannel/MessagePort + structuredClone ---- *)
b "messageChannelNew" (fun _ -> "__as_messageChannelNew()");
b "messageChannelPort1" (fun a -> Printf.sprintf "__as_messageChannelPort1(%s)" (arg 0 a));
b "messageChannelPort2" (fun a -> Printf.sprintf "__as_messageChannelPort2(%s)" (arg 0 a));
b "messagePortPostMessage" (fun a -> Printf.sprintf "__as_messagePortPostMessage(%s, %s)" (arg 0 a) (arg 1 a));
b "messagePortOnMessage" (fun a -> Printf.sprintf "__as_messagePortOnMessage(%s, %s)" (arg 0 a) (arg 1 a));
b "messagePortStart" (fun a -> Printf.sprintf "__as_messagePortStart(%s)" (arg 0 a));
b "messagePortClose" (fun a -> Printf.sprintf "__as_messagePortClose(%s)" (arg 0 a));
b "targetPostMessage" (fun a -> Printf.sprintf "__as_targetPostMessage(%s, %s)" (arg 0 a) (arg 1 a));
b "structuredCloneValue" (fun a -> Printf.sprintf "__as_structuredCloneValue(%s)" (arg 0 a));
(* Generic JS array push helper (returns the array, fluent). *)
b "arrayPush" (fun a -> Printf.sprintf "(%s.push(%s), %s)" (arg 0 a) (arg 1 a) (arg 0 a));
(* ---- honest string/number primitives underpinning the
Expand Down
108 changes: 108 additions & 0 deletions stdlib/Ipc.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 hyperpolymath
//
// Ipc.affine — host↔guest message-passing primitives (bindings #9 in
// docs/bindings-roadmap.adoc). Covers the MessageChannel /
// MessagePort surface plus `structuredClone` for deep-copying values
// across the IPC boundary.
//
// Pattern: the host creates a MessageChannel, transfers one port to
// the embedded engine (worker / iframe / Gossamer-style guest) at
// init time, and keeps the sibling port. Each side calls
// `messagePortPostMessage` to send and registers a handler via
// `messagePortOnMessage` to receive. Handlers see the raw
// `MessageEvent` as opaque `Json` — read the payload as `event.data`
// through existing Json accessors.
//
// Targets the Deno-ESM backend. No consumer-side init is required —
// `MessageChannel`, `MessagePort`, and `structuredClone` are
// standard web-platform globals available in Deno, Node 16+,
// browsers, and Web Workers.
//
// Test fixture: `tests/codegen-deno/ipc_smoke.{affine,harness.mjs}`.
//
// Out of scope for this initial surface (deferred to follow-ups):
// * `transfer` array on `postMessage` (Transferable objects —
// ArrayBuffer transfer, sub-port handoff)
// * `BroadcastChannel` (separate same-origin pub/sub primitive)
// * `Worker` constructors / `Worker.terminate` (Tier 3 #25 — a
// dedicated Web Workers binding subsumes this)
// * Typed `MessageEvent` accessors (the handler currently sees the
// event as opaque Json; a typed extern_type + accessor surface
// is the natural follow-up axis once usage patterns crystallise)

module Ipc;

use Deno::{Json};

// ── Opaque host types ──────────────────────────────────────────────

/// Web-platform `MessageChannel` — a one-shot factory for a pair of
/// linked `MessagePort`s. Either end can post to the other.
pub extern type MessageChannel;

/// One end of a `MessageChannel`. Posts on `port1` arrive on `port2`
/// and vice versa.
pub extern type MessagePort;

// ── MessageChannel ─────────────────────────────────────────────────

/// `new MessageChannel()` — returns a fresh channel whose two ports
/// are not yet attached to anything. The convention is for the
/// creator to keep `port1` and transfer `port2` to the guest.
pub extern fn messageChannelNew() -> MessageChannel;

/// `channel.port1` — the local (creator-side) port.
pub extern fn messageChannelPort1(channel: MessageChannel) -> MessagePort;

/// `channel.port2` — the remote (guest-side) port. Typically handed
/// off via a `targetPostMessage` with a transfer list during the
/// guest's init handshake.
pub extern fn messageChannelPort2(channel: MessageChannel) -> MessagePort;

// ── MessagePort ────────────────────────────────────────────────────

/// `port.postMessage(data)` — enqueue a message for the peer port.
/// `data` is any JSON-cloneable AffineScript `Json` value; cyclic
/// graphs are preserved (the host applies `structuredClone`). The
/// peer receives it as `event.data` on its registered handler.
/// Returns 0.
pub extern fn messagePortPostMessage(port: MessagePort, data: Json) -> Int;

/// `port.onmessage = handler` — register the receive callback.
/// `handler` is an opaque JS function `(event: Json) => void`; the
/// `MessageEvent` arg is exposed as `Json` and read with existing
/// `Json` field accessors (`event.data`, `event.ports`, …).
/// Replaces any prior handler. Returns 0.
pub extern fn messagePortOnMessage(port: MessagePort, handler: Json) -> Int;

/// `port.start()` — explicitly open the port's queue. Required when
/// the port was received via a `MessageEvent` rather than created
/// locally; ports created via `MessageChannel` are auto-started on
/// the first `onmessage` registration but calling `start` is always
/// safe. Returns 0.
pub extern fn messagePortStart(port: MessagePort) -> Int;

/// `port.close()` — terminate the port. The peer's next post resolves
/// silently; further receives are no-ops. Returns 0.
pub extern fn messagePortClose(port: MessagePort) -> Int;

// ── Cross-context postMessage ──────────────────────────────────────

/// Generic `target.postMessage(message)` — works for any global with
/// a `postMessage` method: `Worker`, `DedicatedWorkerGlobalScope`
/// (`self` inside a worker), `Window`, `iframe.contentWindow`. The
/// `target` is opaque `Json` because its shape depends on context;
/// the AS caller obtains it from whatever host bridge supplies it
/// (e.g. a `getCurrentSelf()` host helper inside a worker). Returns 0.
pub extern fn targetPostMessage(target: Json, message: Json) -> Int;

// ── structuredClone ────────────────────────────────────────────────

/// `structuredClone(value)` — the web-platform deep-clone primitive.
/// Preserves cyclic graphs, typed arrays, Maps, Sets, Dates, and
/// `Transferable` views; rejects Functions, DOM nodes, Errors with
/// non-cloneable causes. Useful for snapshotting a payload before
/// posting it across an IPC boundary so downstream mutation can't
/// leak back through a shared reference.
pub extern fn structuredCloneValue(value: Json) -> Json;
57 changes: 57 additions & 0 deletions tests/codegen-deno/ipc_smoke.affine
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// SPDX-License-Identifier: MPL-2.0
// bindings #9 — IPC / MessageChannel / structuredClone smoke test.
//
// Exercises the host-side surface (creator-side): MessageChannel
// construction, two-port handoff shape, postMessage round-trip,
// onmessage handler wiring, port lifecycle (start), and the
// structuredClone deep-copy helper. The peer port is also exercised
// via JS-side stubs in the harness. close() is exercised
// independently — calling it inline with post() would close the
// queue before MessagePort's microtask-async delivery runs.

use Deno::{Json};
use Ipc::{MessageChannel, MessagePort, messageChannelNew, messageChannelPort1, messageChannelPort2, messagePortPostMessage, messagePortOnMessage, messagePortStart, messagePortClose, targetPostMessage, structuredCloneValue};

/// Build a channel, register handlers on both ports, post a payload
/// through each direction, and return the channel so the host can
/// keep a reference until the message-event microtasks have drained.
/// MessagePort delivery is asynchronous (host event-loop tick); on
/// Node the ports are transferable objects backed by worker_threads
/// and GC-eligible the moment the last reference drops — returning
/// the channel keeps both ports alive across the drain.
pub fn smokeChannelFlow(payload: Json, handler1: Json, handler2: Json) -> MessageChannel {
let ch = messageChannelNew();
let p1 = messageChannelPort1(ch);
let p2 = messageChannelPort2(ch);
messagePortOnMessage(p1, handler1);
messagePortOnMessage(p2, handler2);
messagePortStart(p1);
messagePortStart(p2);
messagePortPostMessage(p1, payload);
messagePortPostMessage(p2, payload);
ch
}

/// Standalone close exercise — host-driven so the test can assert
/// it doesn't throw and that a post()-after-close lands quietly.
pub fn smokeCloseFlow() -> Int {
let ch = messageChannelNew();
let p1 = messageChannelPort1(ch);
messagePortStart(p1);
messagePortClose(p1);
0
}

/// Demonstrate the generic `targetPostMessage` shape — host posts to
/// an opaque target (could be a Worker, an iframe.contentWindow,
/// `self` inside a worker). The harness supplies a target stub.
pub fn smokeTargetFlow(target: Json, message: Json) -> Int {
targetPostMessage(target, message);
0
}

/// Round-trip a payload through `structuredCloneValue` — the deep
/// clone must equal the original by value but not by reference.
pub fn smokeStructuredClone(value: Json) -> Json {
structuredCloneValue(value)
}
62 changes: 62 additions & 0 deletions tests/codegen-deno/ipc_smoke.harness.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// SPDX-License-Identifier: MPL-2.0
// bindings #9 — Node ESM harness for the Ipc binding.
//
// MessageChannel + structuredClone are standard web-platform globals
// in Node 16+, so no host injection is required — we just import the
// compiled module and assert side-effects on real port instances.

import assert from "node:assert/strict";

const { smokeChannelFlow, smokeCloseFlow, smokeTargetFlow, smokeStructuredClone } = await import("./ipc_smoke.deno.js");

// Channel flow: assert handlers fire on the peer side, postMessage
// round-trips identical payload, and close()s don't throw.
const receivedOn1 = [];
const receivedOn2 = [];
const handler1 = (event) => { receivedOn1.push(event.data); };
const handler2 = (event) => { receivedOn2.push(event.data); };
const payload = { kind: "level.load", levelId: 42, params: { difficulty: "hard" } };
// Keep the returned channel alive across the drain — both ports are
// GC-eligible on Node the moment their last reference drops.
const channelHandle = smokeChannelFlow(payload, handler1, handler2);
assert.ok(channelHandle, "smokeChannelFlow returns a non-null channel handle");

// MessagePort delivery is async — on Node the worker_threads-backed
// MessageChannel batches across at least one real `setTimeout`
// (microtask + a single setImmediate aren't enough). 50 ms is
// well-above the empirical ~few-ms delivery window.
await new Promise((r) => setTimeout(r, 50));
assert.equal(receivedOn1.length, 1, "port1 handler received one message");
assert.equal(receivedOn2.length, 1, "port2 handler received one message");
assert.deepEqual(receivedOn2[0], payload, "port2 receives the payload posted from port1");
assert.deepEqual(receivedOn1[0], payload, "port1 receives the payload posted from port2");

// Close flow: should not throw, and a subsequent post on the closed
// port should land quietly (this is a pure host-side smoke of the
// close lifecycle path).
assert.equal(smokeCloseFlow(), 0, "smokeCloseFlow returns 0");

// Target flow: a custom target object satisfying the
// `.postMessage(msg)` shape should observe the call.
const targetCalls = [];
const target = { postMessage: (msg) => targetCalls.push(msg) };
assert.equal(smokeTargetFlow(target, { ack: 1 }), 0, "smokeTargetFlow returns 0");
assert.deepEqual(targetCalls, [{ ack: 1 }], "target.postMessage observed with payload");

// Structured clone: the result must equal the source by value but
// not by reference (the whole point of the helper).
const source = { a: 1, nested: { b: [1, 2, { c: 3 }] } };
const clone = smokeStructuredClone(source);
assert.deepEqual(clone, source, "clone deep-equals source");
assert.notStrictEqual(clone, source, "clone is a distinct object");
assert.notStrictEqual(clone.nested, source.nested, "clone.nested is a distinct object");
assert.notStrictEqual(clone.nested.b[2], source.nested.b[2], "deep clone reaches nested array elements");
source.a = 999;
assert.equal(clone.a, 1, "mutating source does not affect clone");

// Close the channel's ports so the active listeners don't keep the
// event loop alive after the assertions complete.
channelHandle.port1.close();
channelHandle.port2.close();

console.log("ipc_smoke.harness.mjs OK");
Loading