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
66 changes: 66 additions & 0 deletions affinescript-tea/README.adoc
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
// SPDX-FileCopyrightText: 2026 hyperpolymath
= affinescript-tea
:toc: macro
:icons: font

The host-side *TEA (The Elm Architecture)* runtime + run loop for
AffineScript modules. INT-07 (issue #182).

toc::[]

== What this is

The compiler-internal `lib/tea_bridge.ml` defines a TEA runtime ABI a
conforming WASM module exposes: `affinescript_init()`,
`affinescript_update(msg: i32)` (with `msg` *Linear* — consumed exactly
once per update cycle), optional `affinescript_get_*` getters /
`affinescript_set_screen`, an exported `memory`, and two custom
sections — `affinescript.tea_layout` (model field layout) and
`affinescript.ownership` (the per-function ownership kinds, including
the Linear `msg` proof).

This package is the *generic* host runtime for any such module. It
discovers the model from `affinescript.tea_layout` (it does **not**
hard-code the bridge's demo `TitleModel`) and reuses the INT-02
host-agnostic loader (`packages/affine-js/loader.js`) for Deno / Node /
browser parity.

== Usage

[source,javascript]
----
import { TeaApp } from "@hyperpolymath/affinescript-tea";

const app = await TeaApp.load("./app.wasm");

// Low-level: drive cycles yourself.
app.init(); // -> initial model object
app.dispatch(0); // one TEA update cycle
app.model(); // current model (layout-driven)

// Managed run loop: `messages` is any (async) iterable of i32 msgs
// (DOM events, a channel, a timer, a test array — all adapt).
await app.run({
messages: eventStream,
view: (model) => render(model),
});
----

== Linear-msg invariant

`affinescript_update`'s `msg` is annotated `Linear` in
`affinescript.ownership` — a message is consumed exactly once per
update cycle. `dispatch()` enforces this host-side: `affinescript_update`
is invoked exactly once, and the call is non-re-entrant — a message
source or effect that tries to dispatch again *before the in-flight
cycle completes* throws (that would consume a second message inside one
cycle). `app.ownership` exposes the annotations.

== Status

INT-07 first general runtime: `TeaApp` (`load`/`init`/`dispatch`/
`model`/`setScreen`/`run`) + `parseTeaLayout`. Verified against the
canonical bridge (`affinescript tea-bridge`) and a hand-built
re-entrancy fixture — see `mod_test.js` (9 tests). The router/navigation
runtime is a separate satellite (INT-09, `lib/tea_router.ml`).
11 changes: 11 additions & 0 deletions affinescript-tea/deno.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"name": "@hyperpolymath/affinescript-tea",
"version": "0.1.0",
"exports": {
".": "./mod.js"
},
"license": "PMPL-1.0-or-later",
"tasks": {
"test": "deno test --allow-read --allow-write --allow-run mod_test.js"
}
}
243 changes: 243 additions & 0 deletions affinescript-tea/mod.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// affinescript-tea: the host-side TEA (The Elm Architecture) runtime + run
// loop for AffineScript modules (INT-07, issue #182).
//
// The compiler-internal `lib/tea_bridge.ml` defines the TEA runtime ABI a
// conforming WASM module exposes:
//
// exports:
// affinescript_init() -> () write the initial model
// affinescript_update(msg: i32) -> () msg is LINEAR (consumed
// exactly once per cycle)
// affinescript_get_<field>() -> i32 (optional getters)
// affinescript_set_screen(w, h) -> () (optional)
// memory
// custom sections:
// affinescript.tea_layout model field layout (parsed here, generically)
// affinescript.ownership per-fn ownership kinds (the Linear-msg proof)
//
// This runtime is GENERIC: it discovers the model from `tea_layout` rather
// than hard-coding any one model (the bridge's TitleModel was only a demo).
// It reuses the INT-02 host-agnostic loader for Deno/Node/browser parity.

import {
parseOwnershipSection,
readBytes,
} from "../packages/affine-js/loader.js";

/**
* One model field, decoded from the `affinescript.tea_layout` section.
* @typedef {Object} TeaField
* @property {string} name
* @property {number} offset byte offset relative to the model base
* @property {"i32"} type
*/

/**
* @typedef {Object} TeaLayout
* @property {number} version
* @property {number} baseAddr
* @property {TeaField[]} fields
*/

/**
* Parse the `affinescript.tea_layout` custom section.
*
* Binary format (must match `Tea_bridge.build_tea_layout_section`):
* u8 version
* u8 base_addr
* u8 field_count
* per field: u8 name_len, name_bytes, u8 offset, u8 type_tag (0x49='i32')
*
* @param {WebAssembly.Module} wasmModule
* @returns {TeaLayout | null} null when the section is absent
*/
export function parseTeaLayout(wasmModule) {
const secs = WebAssembly.Module.customSections(
wasmModule,
"affinescript.tea_layout",
);
if (secs.length === 0) return null;
const b = new Uint8Array(secs[0]);
let p = 0;
const version = b[p++];
const baseAddr = b[p++];
const count = b[p++];
const dec = new TextDecoder("utf-8");
/** @type {TeaField[]} */
const fields = [];
for (let i = 0; i < count; i++) {
const nameLen = b[p++];
const name = dec.decode(b.subarray(p, p + nameLen));
p += nameLen;
const offset = b[p++];
const typeTag = b[p++];
fields.push({
name,
offset,
type: typeTag === 0x49 ? "i32" : `tag:0x${typeTag.toString(16)}`,
});
}
return { version, baseAddr, fields };
}

/**
* A loaded, running TEA application.
*
* Lifecycle: `await TeaApp.load(src)` → `app.init()` → `app.dispatch(msg)`
* (each call drives one TEA update cycle) → `app.model()`. Or hand it to
* `app.run(...)` for a managed loop.
*/
export class TeaApp {
/** @type {WebAssembly.Instance} */ #instance;
/** @type {WebAssembly.Memory} */ #memory;
/** @type {TeaLayout} */ #layout;
/** @type {import("../packages/affine-js/loader.js").OwnershipEntry[]} */ #ownership;
/** Re-entrancy guard enforcing the Linear-msg invariant. */
#inCycle = false;

/**
* @param {WebAssembly.Instance} instance
* @param {TeaLayout} layout
* @param {import("../packages/affine-js/loader.js").OwnershipEntry[]} ownership
*/
constructor(instance, layout, ownership) {
this.#instance = instance;
const mem = instance.exports.memory;
if (!(mem instanceof WebAssembly.Memory)) {
throw new Error(
"affinescript-tea: module must export 'memory' (TEA ABI)",
);
}
this.#memory = mem;
if (!layout) {
throw new Error(
"affinescript-tea: module has no 'affinescript.tea_layout' custom " +
"section — not a TEA-conformant module",
);
}
this.#layout = layout;
this.#ownership = ownership;
for (const fn of ["affinescript_init", "affinescript_update"]) {
if (typeof instance.exports[fn] !== "function") {
throw new Error(`affinescript-tea: module must export '${fn}' (TEA ABI)`);
}
}
}

/**
* Load + instantiate a TEA-conformant module from any source/host.
* @param {string | URL | Uint8Array | ArrayBuffer} source
* @param {{ base?: string | URL, imports?: Record<string, Function> }} [options]
* @returns {Promise<TeaApp>}
*/
static async load(source, options = {}) {
const bytes = await readBytes(source, { base: options.base });
const { instance, module } = await WebAssembly.instantiate(bytes, {
env: { ...(options.imports ?? {}) },
});
const layout = parseTeaLayout(module);
const ownership = parseOwnershipSection(module);
return new TeaApp(instance, layout, ownership);
}

/** The parsed model layout (from `affinescript.tea_layout`). */
get layout() {
return this.#layout;
}

/**
* The ownership annotations. `affinescript_update`'s `msg` parameter is
* Linear (kind `"linear"`) — the host-visible proof that a message is
* consumed exactly once per update cycle.
* @type {import("../packages/affine-js/loader.js").OwnershipEntry[]}
*/
get ownership() {
return this.#ownership;
}

/** Read the current model as a plain object (generic, layout-driven). */
model() {
const dv = new DataView(this.#memory.buffer);
/** @type {Record<string, number>} */
const m = {};
for (const f of this.#layout.fields) {
m[f.name] = dv.getInt32(this.#layout.baseAddr + f.offset, true);
}
return m;
}

/** Run `affinescript_init()`; returns the initial model. */
init() {
this.#instance.exports.affinescript_init();
return this.model();
}

/**
* Drive one TEA update cycle with `msg`, then return the new model.
*
* Enforces the Linear-msg invariant host-side: `affinescript_update` is
* invoked exactly once, and the call is non-re-entrant — dispatching
* again from within a view/effect triggered by this cycle throws (that
* would consume a second message inside one cycle, violating the
* linearity the `affinescript.ownership` section asserts).
*
* @param {number} msg
* @returns {Record<string, number>} the model after the update
*/
dispatch(msg) {
if (!Number.isInteger(msg)) {
throw new TypeError(`affinescript-tea: msg must be an i32, got ${msg}`);
}
if (this.#inCycle) {
throw new Error(
"affinescript-tea: re-entrant dispatch — a message must be consumed " +
"exactly once per update cycle (Linear-msg invariant)",
);
}
this.#inCycle = true;
try {
this.#instance.exports.affinescript_update(msg);
} finally {
this.#inCycle = false;
}
return this.model();
}

/** Optional resize hook (present iff the module exports it). */
setScreen(w, h) {
const fn = this.#instance.exports.affinescript_set_screen;
if (typeof fn !== "function") {
throw new Error(
"affinescript-tea: module does not export 'affinescript_set_screen'",
);
}
fn(w, h);
return this.model();
}

/**
* The managed run loop. Generic over the message source: `messages` is
* any (async) iterable of i32 msgs (DOM events, a channel, a test array,
* a timer — all adapt to this). `view(model)` is called once after
* `init()` and once after every dispatched message.
*
* @param {{ messages: Iterable<number> | AsyncIterable<number>,
* view: (model: Record<string, number>) => void }} driver
* @returns {Promise<Record<string, number>>} the final model
*/
async run({ messages, view }) {
if (typeof view !== "function") {
throw new TypeError("affinescript-tea: run() needs a view(model) fn");
}
let model = this.init();
view(model);
for await (const msg of messages) {
model = this.dispatch(msg);
view(model);
}
return model;
}
}
Loading
Loading