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
12 changes: 8 additions & 4 deletions docs/ECOSYSTEM.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
42 changes: 42 additions & 0 deletions packages/affine-js/README.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,48 @@ await mod.runMain();

Effect import names follow the pattern `affine_<EffectName>_<opName>` 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:
Expand Down
1 change: 1 addition & 0 deletions packages/affine-js/deno.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"version": "0.1.0",
"exports": {
".": "./mod.js",
"./loader": "./loader.js",
"./marshal": "./marshal.js",
"./runtime": "./runtime.js"
},
Expand Down
245 changes: 245 additions & 0 deletions packages/affine-js/loader.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,245 @@
// SPDX-License-Identifier: PMPL-1.0-or-later
// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
//
// 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<Uint8Array>}
*/
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<string, Function>} runtimeImports
* @param {{ imports?: Record<string, Function>,
* modules?: Record<string, Record<string, Function>> }} [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;
}
Loading
Loading