|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +// |
| 4 | +// affinescript-tea: the host-side TEA (The Elm Architecture) runtime + run |
| 5 | +// loop for AffineScript modules (INT-07, issue #182). |
| 6 | +// |
| 7 | +// The compiler-internal `lib/tea_bridge.ml` defines the TEA runtime ABI a |
| 8 | +// conforming WASM module exposes: |
| 9 | +// |
| 10 | +// exports: |
| 11 | +// affinescript_init() -> () write the initial model |
| 12 | +// affinescript_update(msg: i32) -> () msg is LINEAR (consumed |
| 13 | +// exactly once per cycle) |
| 14 | +// affinescript_get_<field>() -> i32 (optional getters) |
| 15 | +// affinescript_set_screen(w, h) -> () (optional) |
| 16 | +// memory |
| 17 | +// custom sections: |
| 18 | +// affinescript.tea_layout model field layout (parsed here, generically) |
| 19 | +// affinescript.ownership per-fn ownership kinds (the Linear-msg proof) |
| 20 | +// |
| 21 | +// This runtime is GENERIC: it discovers the model from `tea_layout` rather |
| 22 | +// than hard-coding any one model (the bridge's TitleModel was only a demo). |
| 23 | +// It reuses the INT-02 host-agnostic loader for Deno/Node/browser parity. |
| 24 | + |
| 25 | +import { |
| 26 | + parseOwnershipSection, |
| 27 | + readBytes, |
| 28 | +} from "../packages/affine-js/loader.js"; |
| 29 | + |
| 30 | +/** |
| 31 | + * One model field, decoded from the `affinescript.tea_layout` section. |
| 32 | + * @typedef {Object} TeaField |
| 33 | + * @property {string} name |
| 34 | + * @property {number} offset byte offset relative to the model base |
| 35 | + * @property {"i32"} type |
| 36 | + */ |
| 37 | + |
| 38 | +/** |
| 39 | + * @typedef {Object} TeaLayout |
| 40 | + * @property {number} version |
| 41 | + * @property {number} baseAddr |
| 42 | + * @property {TeaField[]} fields |
| 43 | + */ |
| 44 | + |
| 45 | +/** |
| 46 | + * Parse the `affinescript.tea_layout` custom section. |
| 47 | + * |
| 48 | + * Binary format (must match `Tea_bridge.build_tea_layout_section`): |
| 49 | + * u8 version |
| 50 | + * u8 base_addr |
| 51 | + * u8 field_count |
| 52 | + * per field: u8 name_len, name_bytes, u8 offset, u8 type_tag (0x49='i32') |
| 53 | + * |
| 54 | + * @param {WebAssembly.Module} wasmModule |
| 55 | + * @returns {TeaLayout | null} null when the section is absent |
| 56 | + */ |
| 57 | +export function parseTeaLayout(wasmModule) { |
| 58 | + const secs = WebAssembly.Module.customSections( |
| 59 | + wasmModule, |
| 60 | + "affinescript.tea_layout", |
| 61 | + ); |
| 62 | + if (secs.length === 0) return null; |
| 63 | + const b = new Uint8Array(secs[0]); |
| 64 | + let p = 0; |
| 65 | + const version = b[p++]; |
| 66 | + const baseAddr = b[p++]; |
| 67 | + const count = b[p++]; |
| 68 | + const dec = new TextDecoder("utf-8"); |
| 69 | + /** @type {TeaField[]} */ |
| 70 | + const fields = []; |
| 71 | + for (let i = 0; i < count; i++) { |
| 72 | + const nameLen = b[p++]; |
| 73 | + const name = dec.decode(b.subarray(p, p + nameLen)); |
| 74 | + p += nameLen; |
| 75 | + const offset = b[p++]; |
| 76 | + const typeTag = b[p++]; |
| 77 | + fields.push({ |
| 78 | + name, |
| 79 | + offset, |
| 80 | + type: typeTag === 0x49 ? "i32" : `tag:0x${typeTag.toString(16)}`, |
| 81 | + }); |
| 82 | + } |
| 83 | + return { version, baseAddr, fields }; |
| 84 | +} |
| 85 | + |
| 86 | +/** |
| 87 | + * A loaded, running TEA application. |
| 88 | + * |
| 89 | + * Lifecycle: `await TeaApp.load(src)` → `app.init()` → `app.dispatch(msg)` |
| 90 | + * (each call drives one TEA update cycle) → `app.model()`. Or hand it to |
| 91 | + * `app.run(...)` for a managed loop. |
| 92 | + */ |
| 93 | +export class TeaApp { |
| 94 | + /** @type {WebAssembly.Instance} */ #instance; |
| 95 | + /** @type {WebAssembly.Memory} */ #memory; |
| 96 | + /** @type {TeaLayout} */ #layout; |
| 97 | + /** @type {import("../packages/affine-js/loader.js").OwnershipEntry[]} */ #ownership; |
| 98 | + /** Re-entrancy guard enforcing the Linear-msg invariant. */ |
| 99 | + #inCycle = false; |
| 100 | + |
| 101 | + /** |
| 102 | + * @param {WebAssembly.Instance} instance |
| 103 | + * @param {TeaLayout} layout |
| 104 | + * @param {import("../packages/affine-js/loader.js").OwnershipEntry[]} ownership |
| 105 | + */ |
| 106 | + constructor(instance, layout, ownership) { |
| 107 | + this.#instance = instance; |
| 108 | + const mem = instance.exports.memory; |
| 109 | + if (!(mem instanceof WebAssembly.Memory)) { |
| 110 | + throw new Error( |
| 111 | + "affinescript-tea: module must export 'memory' (TEA ABI)", |
| 112 | + ); |
| 113 | + } |
| 114 | + this.#memory = mem; |
| 115 | + if (!layout) { |
| 116 | + throw new Error( |
| 117 | + "affinescript-tea: module has no 'affinescript.tea_layout' custom " + |
| 118 | + "section — not a TEA-conformant module", |
| 119 | + ); |
| 120 | + } |
| 121 | + this.#layout = layout; |
| 122 | + this.#ownership = ownership; |
| 123 | + for (const fn of ["affinescript_init", "affinescript_update"]) { |
| 124 | + if (typeof instance.exports[fn] !== "function") { |
| 125 | + throw new Error(`affinescript-tea: module must export '${fn}' (TEA ABI)`); |
| 126 | + } |
| 127 | + } |
| 128 | + } |
| 129 | + |
| 130 | + /** |
| 131 | + * Load + instantiate a TEA-conformant module from any source/host. |
| 132 | + * @param {string | URL | Uint8Array | ArrayBuffer} source |
| 133 | + * @param {{ base?: string | URL, imports?: Record<string, Function> }} [options] |
| 134 | + * @returns {Promise<TeaApp>} |
| 135 | + */ |
| 136 | + static async load(source, options = {}) { |
| 137 | + const bytes = await readBytes(source, { base: options.base }); |
| 138 | + const { instance, module } = await WebAssembly.instantiate(bytes, { |
| 139 | + env: { ...(options.imports ?? {}) }, |
| 140 | + }); |
| 141 | + const layout = parseTeaLayout(module); |
| 142 | + const ownership = parseOwnershipSection(module); |
| 143 | + return new TeaApp(instance, layout, ownership); |
| 144 | + } |
| 145 | + |
| 146 | + /** The parsed model layout (from `affinescript.tea_layout`). */ |
| 147 | + get layout() { |
| 148 | + return this.#layout; |
| 149 | + } |
| 150 | + |
| 151 | + /** |
| 152 | + * The ownership annotations. `affinescript_update`'s `msg` parameter is |
| 153 | + * Linear (kind `"linear"`) — the host-visible proof that a message is |
| 154 | + * consumed exactly once per update cycle. |
| 155 | + * @type {import("../packages/affine-js/loader.js").OwnershipEntry[]} |
| 156 | + */ |
| 157 | + get ownership() { |
| 158 | + return this.#ownership; |
| 159 | + } |
| 160 | + |
| 161 | + /** Read the current model as a plain object (generic, layout-driven). */ |
| 162 | + model() { |
| 163 | + const dv = new DataView(this.#memory.buffer); |
| 164 | + /** @type {Record<string, number>} */ |
| 165 | + const m = {}; |
| 166 | + for (const f of this.#layout.fields) { |
| 167 | + m[f.name] = dv.getInt32(this.#layout.baseAddr + f.offset, true); |
| 168 | + } |
| 169 | + return m; |
| 170 | + } |
| 171 | + |
| 172 | + /** Run `affinescript_init()`; returns the initial model. */ |
| 173 | + init() { |
| 174 | + this.#instance.exports.affinescript_init(); |
| 175 | + return this.model(); |
| 176 | + } |
| 177 | + |
| 178 | + /** |
| 179 | + * Drive one TEA update cycle with `msg`, then return the new model. |
| 180 | + * |
| 181 | + * Enforces the Linear-msg invariant host-side: `affinescript_update` is |
| 182 | + * invoked exactly once, and the call is non-re-entrant — dispatching |
| 183 | + * again from within a view/effect triggered by this cycle throws (that |
| 184 | + * would consume a second message inside one cycle, violating the |
| 185 | + * linearity the `affinescript.ownership` section asserts). |
| 186 | + * |
| 187 | + * @param {number} msg |
| 188 | + * @returns {Record<string, number>} the model after the update |
| 189 | + */ |
| 190 | + dispatch(msg) { |
| 191 | + if (!Number.isInteger(msg)) { |
| 192 | + throw new TypeError(`affinescript-tea: msg must be an i32, got ${msg}`); |
| 193 | + } |
| 194 | + if (this.#inCycle) { |
| 195 | + throw new Error( |
| 196 | + "affinescript-tea: re-entrant dispatch — a message must be consumed " + |
| 197 | + "exactly once per update cycle (Linear-msg invariant)", |
| 198 | + ); |
| 199 | + } |
| 200 | + this.#inCycle = true; |
| 201 | + try { |
| 202 | + this.#instance.exports.affinescript_update(msg); |
| 203 | + } finally { |
| 204 | + this.#inCycle = false; |
| 205 | + } |
| 206 | + return this.model(); |
| 207 | + } |
| 208 | + |
| 209 | + /** Optional resize hook (present iff the module exports it). */ |
| 210 | + setScreen(w, h) { |
| 211 | + const fn = this.#instance.exports.affinescript_set_screen; |
| 212 | + if (typeof fn !== "function") { |
| 213 | + throw new Error( |
| 214 | + "affinescript-tea: module does not export 'affinescript_set_screen'", |
| 215 | + ); |
| 216 | + } |
| 217 | + fn(w, h); |
| 218 | + return this.model(); |
| 219 | + } |
| 220 | + |
| 221 | + /** |
| 222 | + * The managed run loop. Generic over the message source: `messages` is |
| 223 | + * any (async) iterable of i32 msgs (DOM events, a channel, a test array, |
| 224 | + * a timer — all adapt to this). `view(model)` is called once after |
| 225 | + * `init()` and once after every dispatched message. |
| 226 | + * |
| 227 | + * @param {{ messages: Iterable<number> | AsyncIterable<number>, |
| 228 | + * view: (model: Record<string, number>) => void }} driver |
| 229 | + * @returns {Promise<Record<string, number>>} the final model |
| 230 | + */ |
| 231 | + async run({ messages, view }) { |
| 232 | + if (typeof view !== "function") { |
| 233 | + throw new TypeError("affinescript-tea: run() needs a view(model) fn"); |
| 234 | + } |
| 235 | + let model = this.init(); |
| 236 | + view(model); |
| 237 | + for await (const msg of messages) { |
| 238 | + model = this.dispatch(msg); |
| 239 | + view(model); |
| 240 | + } |
| 241 | + return model; |
| 242 | + } |
| 243 | +} |
0 commit comments