From 4be85144fa6289b899a7ee44fe95b643c2a92304 Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Tue, 19 May 2026 17:17:10 +0100 Subject: [PATCH] =?UTF-8?q?feat(loader):=20INT-02=20host-agnostic=20loader?= =?UTF-8?q?=20bridge=20=E2=80=94=20fix=20SAT-02=20(Refs=20#179)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit packages/affine-js was Deno-only and env-import-only: `fromFile` did `Deno.readFile(new URL(path, import.meta.url).pathname)`. `url.pathname` is not a filesystem path (percent-encoded, drops the Windows drive letter, meaningless for non-`file:` URLs) and `Deno.readFile` does not exist on Node or in the browser. That was SAT-02. New `packages/affine-js/loader.js` (consumed by mod.js): * detectHost() — feature-detected Deno / Node / browser. * resolveUrl() — correct relative/POSIX/Windows/absolute-URL resolution (replaces the broken `.pathname` mangling). * readBytes() — host-agnostic: Deno.readFile / node:fs+fileURLToPath / fetch; passthrough for Uint8Array/ArrayBuffer. * buildImportObject() — full multi-namespace import object; `env` stays backward-compatible, `options.modules` carries the cross-module imports INT-01 (#178) emits under the callee module's namespace. * parseOwnershipSection() — accessor for the `affinescript.ownership` custom section (the typed-wasm contract carrier); binary format kept byte-identical to Codegen.build_ownership_section / Tw_verify.parse_ownership_section_payload. mod.js: `fromFile`/`fromBytes` rewired through the loader; new `mod.ownership` getter; `LoadOptions` gains `base` + `modules`. types.d.ts (approved TS-exemption public contract) + README + deno.json export + ECOSYSTEM.adoc roadmap/registry truthed. Tests: packages/affine-js/loader_test.js (14 Deno tests, all green). Gates: dune test --force 270/270; tools/run_codegen_wasm_tests.sh all pass. Zero regression. Refs #179 (loader bridge delivered; satellite shell + INT-05/08/11 are downstream — owner closes). --- docs/ECOSYSTEM.adoc | 12 +- packages/affine-js/README.adoc | 42 +++++ packages/affine-js/deno.json | 1 + packages/affine-js/loader.js | 245 ++++++++++++++++++++++++++++++ packages/affine-js/loader_test.js | 175 +++++++++++++++++++++ packages/affine-js/mod.js | 62 ++++++-- packages/affine-js/types.d.ts | 64 ++++++++ 7 files changed, 581 insertions(+), 20 deletions(-) create mode 100644 packages/affine-js/loader.js create mode 100644 packages/affine-js/loader_test.js diff --git a/docs/ECOSYSTEM.adoc b/docs/ECOSYSTEM.adoc index 4aa9256a..e05b5d0b 100644 --- a/docs/ECOSYSTEM.adoc +++ b/docs/ECOSYSTEM.adoc @@ -161,8 +161,10 @@ satellite (internal `lib/tea_router.ml` contract exists). |`affinescriptiser`, `road-skate` |adjunct |In-tree tooling/experiments; not part of the integration critical path. -|`packages/affine-js` / `-ts` / `-res` / `-vscode` |works (with debt) | -`affine-js` has a hardcoded path (SAT-02) addressed by INT-02. +|`packages/affine-js` / `-ts` / `-res` / `-vscode` |works | +`affine-js` SAT-02 fixed by INT-02 (#179): host-agnostic loader +(`loader.js`) — Deno/Node/browser parity, multi-namespace import +object, `affinescript.ownership` accessor. |=== == Integration roadmap — INT-01..12 @@ -178,8 +180,10 @@ link:TECH-DEBT.adoc[TECH-DEBT.adoc]. |INT-01 |Cross-module WASM import emission (the substrate) |#178 | `use Mod::{fn}`/`::*` PROVEN+locked (271 gate + deno link harness); `use Mod;`+qualified-value-call resolver gap remains (distinct) -|INT-02 |Host-agnostic loader bridge (`affinescript-dom-loader`) |#179 |open, -S1 (blocks INT-05/08/11) +|INT-02 |Host-agnostic loader bridge (`affinescript-dom-loader`) |#179 |loader +landed in `packages/affine-js` (SAT-02 fixed; Deno/Node/browser parity, +multi-namespace import object, ownership-section accessor). S1; unblocks +INT-05/08/11. The `affinescript-dom-loader` satellite shell is downstream. |INT-03 |WASI preview2 / host I/O beyond stdout |#180 |open, S1 |INT-04 |Publish compiler + runtime to JSR (then npm) |#181 |open, S2 (blocked by INT-01) diff --git a/packages/affine-js/README.adoc b/packages/affine-js/README.adoc index 68dfc7fc..f5c5c67a 100644 --- a/packages/affine-js/README.adoc +++ b/packages/affine-js/README.adoc @@ -78,6 +78,48 @@ await mod.runMain(); Effect import names follow the pattern `affine__` in snake_case. +== Host-agnostic loading (INT-02) + +`fromFile` / `run` work on Deno, Node, and in the browser. Relative +specifiers resolve against `options.base` (default: the affine-js module +URL) — pass `import.meta.url` from your own module when the `.wasm` is +relative to *your* file: + +[source,javascript] +---- +const mod = await AffineModule.fromFile("./app.wasm", { + base: import.meta.url, +}); +---- + +`http(s):`, `data:`, and `blob:` URLs are fetched; `file:` URLs are read +via the native filesystem on Deno/Node and via `fetch` in the browser. +Windows and POSIX absolute paths are both accepted. + +== Cross-module imports (INT-01) + +`use Mod::{fn}` lowers to a WASM import under the `Mod` namespace, not +`env`. Supply per-namespace implementations with `options.modules`: + +[source,javascript] +---- +const mod = await AffineModule.fromBytes(bytes, { + modules: { Mod: { helper: (x) => x + 1 } }, +}); +---- + +== Ownership section (typed-wasm contract) + +The compiler embeds an `affinescript.ownership` custom section carrying +per-function parameter/return ownership kinds (the AffineScript ↔ +typed-wasm contract — see `docs/ECOSYSTEM.adoc`). Read it via: + +[source,javascript] +---- +mod.ownership; +// => [{ funcIdx, paramKinds: ["linear", ...], retKind: "unrestricted" }, ...] +---- + == AffineValue types All JS ↔ WASM value exchanges use the `AffineValue` tagged-union type: diff --git a/packages/affine-js/deno.json b/packages/affine-js/deno.json index f5c94375..bf682d98 100644 --- a/packages/affine-js/deno.json +++ b/packages/affine-js/deno.json @@ -3,6 +3,7 @@ "version": "0.1.0", "exports": { ".": "./mod.js", + "./loader": "./loader.js", "./marshal": "./marshal.js", "./runtime": "./runtime.js" }, diff --git a/packages/affine-js/loader.js b/packages/affine-js/loader.js new file mode 100644 index 00000000..4a590b35 --- /dev/null +++ b/packages/affine-js/loader.js @@ -0,0 +1,245 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// affine-js/loader: host-agnostic loader bridge (INT-02, issue #179). +// +// Prior to this module `AffineModule.fromFile` was Deno-only: it called +// `Deno.readFile(url.pathname)`. `url.pathname` is *not* a filesystem path +// (it is percent-encoded, drops the Windows drive letter, and is meaningless +// for non-`file:` URLs), and `Deno.readFile` does not exist on Node or in a +// browser. That was SAT-02. +// +// This module provides the four pieces INT-02 requires: +// +// 1. relative URL resolution that is correct on every host; +// 2. a host-agnostic byte reader (Deno / Node / browser parity); +// 3. a full import-object builder (multi-namespace, for the cross-module +// WASM imports INT-01/#178 emits — not `env`-only); +// 4. an accessor for the `affinescript.ownership` custom section (the +// typed-wasm contract carrier — see docs/ECOSYSTEM.adoc). +// +// It has no dependency on `mod.js`; `mod.js` consumes it. + +/** + * The JavaScript host we are running under. + * @typedef {"deno"|"node"|"browser"|"unknown"} Host + */ + +/** + * Detect the current JavaScript host by feature, not by user agent. + * @returns {Host} + */ +export function detectHost() { + if (typeof Deno !== "undefined" && Deno?.version?.deno) return "deno"; + if ( + typeof process !== "undefined" && + process?.versions?.node && + // A bundled-for-browser build can shim `process`; require real fs too. + typeof globalThis.WebAssembly !== "undefined" + ) { + return "node"; + } + if ( + typeof globalThis.fetch === "function" && + (typeof window !== "undefined" || typeof self !== "undefined") + ) { + return "browser"; + } + return "unknown"; +} + +/** + * Resolve a module specifier to an absolute URL. + * + * Accepts a `URL`, an absolute URL string, a `file:`-relative specifier, or a + * filesystem path (POSIX or Windows). `base` is required for relative + * specifiers; callers pass their own `import.meta.url`. + * + * @param {string | URL} spec + * @param {string | URL} [base] + * @returns {URL} + */ +export function resolveUrl(spec, base) { + if (spec instanceof URL) return spec; + if (typeof spec !== "string") { + throw new TypeError( + `affine-js/loader: specifier must be a string or URL, got ${typeof spec}`, + ); + } + // Absolute URL (has a scheme like file:, http:, https:, data:, blob:). + if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(spec) && !/^[a-zA-Z]:[\\/]/.test(spec)) { + return new URL(spec); + } + // Windows absolute path, e.g. C:\dir\x.wasm -> file:///C:/dir/x.wasm + if (/^[a-zA-Z]:[\\/]/.test(spec)) { + return new URL(`file:///${spec.replace(/\\/g, "/")}`); + } + // POSIX absolute path. + if (spec.startsWith("/")) return new URL(`file://${spec}`); + // Relative specifier — needs a base. + if (base === undefined) { + throw new Error( + `affine-js/loader: relative specifier ${JSON.stringify(spec)} needs a ` + + `base URL; pass { base: import.meta.url }`, + ); + } + return new URL(spec, base); +} + +/** + * Read a WASM module's bytes from any source, on any host. + * + * @param {string | URL | Uint8Array | ArrayBuffer} source + * @param {{ base?: string | URL }} [options] + * @returns {Promise} + */ +export async function readBytes(source, options = {}) { + if (source instanceof Uint8Array) return source; + if (source instanceof ArrayBuffer) return new Uint8Array(source); + if (ArrayBuffer.isView(source)) { + return new Uint8Array(source.buffer, source.byteOffset, source.byteLength); + } + + const url = resolveUrl(source, options.base); + + if (url.protocol === "file:") { + const host = detectHost(); + if (host === "deno") { + // Deno.readFile accepts a URL directly — no pathname mangling. + return await Deno.readFile(url); + } + if (host === "node") { + const { readFile } = await import("node:fs/promises"); + const { fileURLToPath } = await import("node:url"); + const buf = await readFile(fileURLToPath(url)); + return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength); + } + // Browser: a `file:` URL may still be reachable via fetch when the page + // is itself served from disk; otherwise this throws a clear error. + try { + const res = await fetch(url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + return new Uint8Array(await res.arrayBuffer()); + } catch (cause) { + throw new Error( + `affine-js/loader: cannot read ${url.href} in a browser host; ` + + `serve the .wasm over http(s) or pass its bytes directly`, + { cause }, + ); + } + } + + // http:, https:, data:, blob: — fetch works on every host that has it. + if (typeof globalThis.fetch !== "function") { + throw new Error( + `affine-js/loader: no fetch available to read ${url.href} on this host`, + ); + } + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `affine-js/loader: failed to fetch ${url.href} (HTTP ${res.status})`, + ); + } + return new Uint8Array(await res.arrayBuffer()); +} + +/** + * Build the full WebAssembly import object. + * + * The legacy shape merged everything into a single `env` namespace. INT-01 + * (#178) emits genuine cross-module imports under the *callee module's* name, + * so the import object must carry arbitrary namespaces. This builder keeps + * `env` backward-compatible (runtime defaults + flat `options.imports`) and + * adds any `options.modules` namespaces verbatim. + * + * @param {Record} runtimeImports + * @param {{ imports?: Record, + * modules?: Record> }} [options] + * @returns {WebAssembly.Imports} + */ +export function buildImportObject(runtimeImports, options = {}) { + /** @type {WebAssembly.Imports} */ + const importObject = { + env: { + ...runtimeImports, + ...(options.imports ?? {}), + }, + }; + for (const [ns, members] of Object.entries(options.modules ?? {})) { + if (ns === "env") { + // Merge rather than clobber the runtime namespace. + Object.assign(importObject.env, members); + } else { + importObject[ns] = { ...(importObject[ns] ?? {}), ...members }; + } + } + return importObject; +} + +/** @typedef {"unrestricted"|"linear"|"sharedBorrow"|"exclBorrow"} OwnershipKind */ + +const OWNERSHIP_KINDS = /** @type {const} */ ([ + "unrestricted", + "linear", + "sharedBorrow", + "exclBorrow", +]); + +/** + * One per-function ownership annotation. + * @typedef {Object} OwnershipEntry + * @property {number} funcIdx + * @property {OwnershipKind[]} paramKinds + * @property {OwnershipKind} retKind + */ + +/** + * Parse the `affinescript.ownership` custom section. + * + * Binary encoding (must match `Codegen.build_ownership_section` / + * `Tw_verify.parse_ownership_section_payload` in the compiler): + * + * u32le count + * for each entry: + * u32le func_idx + * u8 n_params + * u8[n] param_kinds (0=Unrestricted,1=Linear,2=SharedBorrow,3=ExclBorrow) + * u8 ret_kind + * + * @param {WebAssembly.Module} wasmModule + * @returns {OwnershipEntry[]} + */ +export function parseOwnershipSection(wasmModule) { + const sections = WebAssembly.Module.customSections( + wasmModule, + "affinescript.ownership", + ); + if (sections.length === 0) return []; + const view = new DataView(sections[0]); + let pos = 0; + const u32 = () => { + const v = view.getUint32(pos, /* littleEndian */ true); + pos += 4; + return v; + }; + const u8 = () => { + const v = view.getUint8(pos); + pos += 1; + return v; + }; + const kind = (b) => OWNERSHIP_KINDS[b] ?? "unrestricted"; + + const count = u32(); + /** @type {OwnershipEntry[]} */ + const entries = []; + for (let i = 0; i < count; i++) { + const funcIdx = u32(); + const nParams = u8(); + const paramKinds = []; + for (let p = 0; p < nParams; p++) paramKinds.push(kind(u8())); + const retKind = kind(u8()); + entries.push({ funcIdx, paramKinds, retKind }); + } + return entries; +} diff --git a/packages/affine-js/loader_test.js b/packages/affine-js/loader_test.js new file mode 100644 index 00000000..5ba61312 --- /dev/null +++ b/packages/affine-js/loader_test.js @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: PMPL-1.0-or-later +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) +// +// affine-js/loader: host-agnostic loader bridge tests (INT-02, issue #179). +// +// Run: deno test --allow-read --allow-write packages/affine-js/loader_test.js +// +// These cover the SAT-02 fix (no more `url.pathname` path mangling), host +// detection, byte-reader parity, the multi-namespace import-object builder, +// and the `affinescript.ownership` custom-section parser (whose binary format +// must stay byte-identical to Codegen.build_ownership_section / +// Tw_verify.parse_ownership_section_payload in the compiler). + +import { assertEquals, assertThrows } from "jsr:@std/assert@1"; +import { + buildImportObject, + detectHost, + parseOwnershipSection, + readBytes, + resolveUrl, +} from "./loader.js"; + +Deno.test("detectHost identifies Deno by feature", () => { + assertEquals(detectHost(), "deno"); +}); + +Deno.test("resolveUrl: URL passthrough", () => { + const u = new URL("https://example.test/a.wasm"); + assertEquals(resolveUrl(u), u); +}); + +Deno.test("resolveUrl: absolute URL string", () => { + assertEquals( + resolveUrl("https://example.test/a.wasm").href, + "https://example.test/a.wasm", + ); +}); + +Deno.test("resolveUrl: POSIX absolute path -> file URL", () => { + assertEquals(resolveUrl("/srv/app/x.wasm").href, "file:///srv/app/x.wasm"); +}); + +Deno.test("resolveUrl: Windows absolute path -> file URL (the SAT-02 case)", () => { + // The old code did `new URL(path).pathname` then `Deno.readFile`, which + // dropped the drive letter and percent-mangled the path. This is the + // regression guard for that. + assertEquals( + resolveUrl("C:\\dir\\sub\\x.wasm").href, + "file:///C:/dir/sub/x.wasm", + ); +}); + +Deno.test("resolveUrl: relative needs a base", () => { + assertThrows(() => resolveUrl("./x.wasm"), Error, "needs a base"); + assertEquals( + resolveUrl("./x.wasm", "file:///srv/app/mod.js").href, + "file:///srv/app/x.wasm", + ); +}); + +Deno.test("readBytes: Uint8Array passthrough is identity", async () => { + const src = new Uint8Array([1, 2, 3]); + assertEquals(await readBytes(src), src); +}); + +Deno.test("readBytes: ArrayBuffer -> Uint8Array", async () => { + const ab = new Uint8Array([4, 5, 6]).buffer; + assertEquals(await readBytes(ab), new Uint8Array([4, 5, 6])); +}); + +Deno.test("readBytes: file URL and relative spec parity", async () => { + const dir = await Deno.makeTempDir(); + const path = `${dir}/blob.bin`; + const payload = new Uint8Array([0xde, 0xad, 0xbe, 0xef]); + await Deno.writeFile(path, payload); + try { + const viaFileUrl = await readBytes(`file://${path}`); + assertEquals(viaFileUrl, payload); + const viaRelative = await readBytes("./blob.bin", { + base: `file://${dir}/anchor.js`, + }); + assertEquals(viaRelative, payload); + } finally { + await Deno.remove(dir, { recursive: true }); + } +}); + +Deno.test("buildImportObject: legacy env merge is preserved", () => { + const rt = { affine_io_println: () => {} }; + const extra = () => 1; + const obj = buildImportObject(rt, { imports: { custom: extra } }); + assertEquals(obj.env.affine_io_println, rt.affine_io_println); + assertEquals(obj.env.custom, extra); +}); + +Deno.test("buildImportObject: cross-module namespaces (INT-01)", () => { + const fn = () => 7; + const obj = buildImportObject({}, { modules: { Mod: { helper: fn } } }); + assertEquals(obj.Mod.helper, fn); + assertEquals(typeof obj.env, "object"); +}); + +Deno.test("buildImportObject: modules.env merges, does not clobber", () => { + const rt = { runtime_fn: () => {} }; + const guest = () => {}; + const obj = buildImportObject(rt, { modules: { env: { guest } } }); + assertEquals(obj.env.runtime_fn, rt.runtime_fn); + assertEquals(obj.env.guest, guest); +}); + +// ── ownership custom-section parser ─────────────────────────────────────────── + +/** Build a minimal valid WASM module carrying one custom section. */ +function wasmWithCustomSection(name, payload) { + const enc = new TextEncoder(); + const nameBytes = enc.encode(name); + // section content = uleb(nameLen) + name + payload + const content = [ + ...uleb(nameBytes.length), + ...nameBytes, + ...payload, + ]; + return new Uint8Array([ + 0x00, 0x61, 0x73, 0x6d, // \0asm + 0x01, 0x00, 0x00, 0x00, // version 1 + 0x00, // custom section id + ...uleb(content.length), + ...content, + ]); +} + +function uleb(n) { + const out = []; + do { + let b = n & 0x7f; + n >>>= 7; + if (n !== 0) b |= 0x80; + out.push(b); + } while (n !== 0); + return out; +} + +function u32le(n) { + return [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff]; +} + +Deno.test("parseOwnershipSection: round-trips the compiler's binary format", () => { + // One entry: func_idx=2, params=[Unrestricted,Linear,SharedBorrow,ExclBorrow], + // ret=Linear. + const payload = [ + ...u32le(1), // count + ...u32le(2), // func_idx + 4, // n_params + 0, + 1, + 2, + 3, + 1, // ret_kind + ]; + const bytes = wasmWithCustomSection("affinescript.ownership", payload); + const mod = new WebAssembly.Module(bytes); + assertEquals(parseOwnershipSection(mod), [ + { + funcIdx: 2, + paramKinds: ["unrestricted", "linear", "sharedBorrow", "exclBorrow"], + retKind: "linear", + }, + ]); +}); + +Deno.test("parseOwnershipSection: absent section -> []", () => { + const bytes = wasmWithCustomSection("something.else", [0]); + const mod = new WebAssembly.Module(bytes); + assertEquals(parseOwnershipSection(mod), []); +}); diff --git a/packages/affine-js/mod.js b/packages/affine-js/mod.js index b19b210e..d885ea04 100644 --- a/packages/affine-js/mod.js +++ b/packages/affine-js/mod.js @@ -31,6 +31,7 @@ import { makeRuntimeImports } from "./runtime.js"; import { marshal, unmarshal } from "./marshal.js"; +import { buildImportObject, parseOwnershipSection, readBytes } from "./loader.js"; /** * An instantiated AffineScript WASM module. @@ -48,15 +49,20 @@ export class AffineModule { /** @type {(n: number) => number} */ #alloc; + /** @type {import("./loader.js").OwnershipEntry[]} */ + #ownership; + /** * @param {WebAssembly.Instance} instance * @param {WebAssembly.Memory} memory * @param {(n: number) => number} alloc + * @param {import("./loader.js").OwnershipEntry[]} [ownership] */ - constructor(instance, memory, alloc) { + constructor(instance, memory, alloc, ownership = []) { this.#instance = instance; this.#memory = memory; this.#alloc = alloc; + this.#ownership = ownership; } // ── Factory methods ──────────────────────────────────────────────────────── @@ -64,7 +70,12 @@ export class AffineModule { /** * Load a compiled AffineScript `.wasm` file from the local filesystem. * - * @param {string | URL} path - Path to the `.wasm` binary + * Host-agnostic: works on Deno, Node, and in the browser (INT-02, #179). + * Relative specifiers resolve against `options.base` (default: this + * module's URL), so callers in another package should pass + * `{ base: import.meta.url }`. + * + * @param {string | URL} path - Path to / URL of the `.wasm` binary * @param {LoadOptions} [options] * @returns {Promise} * @@ -72,8 +83,9 @@ export class AffineModule { * const mod = await AffineModule.fromFile("./target/program.wasm"); */ static async fromFile(path, options = {}) { - const resolved = path instanceof URL ? path : new URL(path, import.meta.url); - const bytes = await Deno.readFile(resolved.pathname); + const bytes = await readBytes(path, { + base: options.base ?? import.meta.url, + }); return AffineModule.fromBytes(bytes, options); } @@ -93,16 +105,14 @@ export class AffineModule { */ static async fromBytes(bytes, options = {}) { const runtimeImports = makeRuntimeImports(); - - const imports = { - env: { - ...runtimeImports, - ...(options.imports ?? {}), - }, - }; + const imports = buildImportObject(runtimeImports, options); const wasmBytes = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); - const { instance } = await WebAssembly.instantiate(wasmBytes, imports); + const { instance, module } = await WebAssembly.instantiate( + wasmBytes, + imports, + ); + const ownership = parseOwnershipSection(module); const memory = instance.exports.memory; if (!(memory instanceof WebAssembly.Memory)) { @@ -127,7 +137,7 @@ export class AffineModule { ); }; - return new AffineModule(instance, memory, alloc); + return new AffineModule(instance, memory, alloc, ownership); } // ── Calling exports ──────────────────────────────────────────────────────── @@ -206,6 +216,17 @@ export class AffineModule { get instance() { return this.#instance; } + + /** + * The parsed `affinescript.ownership` custom section: per-function + * parameter/return ownership kinds carrying the typed-wasm discipline + * (see docs/ECOSYSTEM.adoc — the AffineScript ↔ typed-wasm contract). + * Empty when the module was compiled without ownership qualifiers. + * @type {import("./loader.js").OwnershipEntry[]} + */ + get ownership() { + return this.#ownership; + } } // ── Convenience helpers ─────────────────────────────────────────────────────── @@ -240,9 +261,18 @@ export { AFFINE_TAG, AFFINE_SIZE, makeRuntimeImports } from "./runtime.js"; /** * @typedef {Object} LoadOptions * @property {Record} [imports] - * Additional WASM imports to merge with the default runtime. - * Keys must match the import names generated by the AffineScript codegen - * for your declared effects. Example: `{ affine_io_println: myLog }`. + * Additional WASM imports merged into the `env` namespace alongside the + * default runtime. Keys must match the import names generated by the + * AffineScript codegen for your declared effects. + * Example: `{ affine_io_println: myLog }`. + * @property {Record>} [modules] + * Per-namespace imports for cross-module WASM imports (INT-01, #178): + * `use Mod::{fn}` lowers to an import in the `Mod` namespace, so supply + * `{ Mod: { fn: impl } }`. Merged, not clobbered, against `env`. + * @property {string | URL} [base] + * Base URL for resolving a relative `fromFile`/`run` specifier. + * Defaults to the affine-js module URL; pass `import.meta.url` from the + * calling module when loading a `.wasm` relative to *your* file. */ /** diff --git a/packages/affine-js/types.d.ts b/packages/affine-js/types.d.ts index 274f7aac..ad9363cc 100644 --- a/packages/affine-js/types.d.ts +++ b/packages/affine-js/types.d.ts @@ -64,6 +64,36 @@ export interface LoadOptions { * { imports: { affine_io_println: (ptr: number) => console.log(readString(ptr)) } } */ imports?: Record number | void>; + + /** + * Per-namespace imports for cross-module WASM imports (INT-01, #178). + * `use Mod::{fn}` lowers to an import in the `Mod` namespace. + * + * @example + * { modules: { Mod: { fn: (x: number) => x + 1 } } } + */ + modules?: Record number | void>>; + + /** + * Base URL for resolving a relative `fromFile`/`run` specifier. + * Defaults to the affine-js module URL; pass `import.meta.url` from the + * calling module when loading a `.wasm` relative to *your* file. + */ + base?: string | URL; +} + +// ── Ownership (typed-wasm contract carrier) ─────────────────────────────────── + +export type OwnershipKind = + | "unrestricted" + | "linear" + | "sharedBorrow" + | "exclBorrow"; + +export interface OwnershipEntry { + funcIdx: number; + paramKinds: OwnershipKind[]; + retKind: OwnershipKind; } // ── CallOptions ─────────────────────────────────────────────────────────────── @@ -110,8 +140,42 @@ export declare class AffineModule { /** Underlying `WebAssembly.Instance`. */ readonly instance: WebAssembly.Instance; + + /** + * Parsed `affinescript.ownership` custom section: per-function + * parameter/return ownership kinds carrying the typed-wasm discipline. + * Empty when the module was compiled without ownership qualifiers. + */ + readonly ownership: OwnershipEntry[]; } +// ── Host-agnostic loader (INT-02) ───────────────────────────────────────────── + +export type Host = "deno" | "node" | "browser" | "unknown"; + +/** Detect the current JavaScript host by feature, not by user agent. */ +export declare function detectHost(): Host; + +/** Resolve a module specifier (URL, file path, or relative) to a URL. */ +export declare function resolveUrl(spec: string | URL, base?: string | URL): URL; + +/** Read a WASM module's bytes from any source, on any host. */ +export declare function readBytes( + source: string | URL | Uint8Array | ArrayBuffer, + options?: { base?: string | URL }, +): Promise; + +/** Build the full WebAssembly import object (multi-namespace). */ +export declare function buildImportObject( + runtimeImports: Record, + options?: Pick, +): WebAssembly.Imports; + +/** Parse the `affinescript.ownership` custom section. */ +export declare function parseOwnershipSection( + wasmModule: WebAssembly.Module, +): OwnershipEntry[]; + // ── Top-level functions ─────────────────────────────────────────────────────── /** Load and run a compiled AffineScript program in one call. */