Skip to content

Commit 0a9fb3a

Browse files
committed
feat(loader): INT-02 host-agnostic loader bridge — fix SAT-02 (Refs #179)
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).
1 parent 81a59bf commit 0a9fb3a

7 files changed

Lines changed: 581 additions & 20 deletions

File tree

docs/ECOSYSTEM.adoc

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,10 @@ satellite (internal `lib/tea_router.ml` contract exists).
161161
|`affinescriptiser`, `road-skate` |adjunct |In-tree tooling/experiments;
162162
not part of the integration critical path.
163163

164-
|`packages/affine-js` / `-ts` / `-res` / `-vscode` |works (with debt) |
165-
`affine-js` has a hardcoded path (SAT-02) addressed by INT-02.
164+
|`packages/affine-js` / `-ts` / `-res` / `-vscode` |works |
165+
`affine-js` SAT-02 fixed by INT-02 (#179): host-agnostic loader
166+
(`loader.js`) — Deno/Node/browser parity, multi-namespace import
167+
object, `affinescript.ownership` accessor.
166168
|===
167169

168170
== Integration roadmap — INT-01..12
@@ -176,8 +178,10 @@ link:TECH-DEBT.adoc[TECH-DEBT.adoc].
176178
|ID |Item |Issue |Status
177179

178180
|INT-01 |Cross-module WASM import emission (the substrate) |#178 |open, S1
179-
|INT-02 |Host-agnostic loader bridge (`affinescript-dom-loader`) |#179 |open,
180-
S1 (blocks INT-05/08/11)
181+
|INT-02 |Host-agnostic loader bridge (`affinescript-dom-loader`) |#179 |loader
182+
landed in `packages/affine-js` (SAT-02 fixed; Deno/Node/browser parity,
183+
multi-namespace import object, ownership-section accessor). S1; unblocks
184+
INT-05/08/11. The `affinescript-dom-loader` satellite shell is downstream.
181185
|INT-03 |WASI preview2 / host I/O beyond stdout |#180 |open, S1
182186
|INT-04 |Publish compiler + runtime to JSR (then npm) |#181 |open, S2
183187
(blocked by INT-01)

packages/affine-js/README.adoc

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,48 @@ await mod.runMain();
7878

7979
Effect import names follow the pattern `affine_<EffectName>_<opName>` in snake_case.
8080

81+
== Host-agnostic loading (INT-02)
82+
83+
`fromFile` / `run` work on Deno, Node, and in the browser. Relative
84+
specifiers resolve against `options.base` (default: the affine-js module
85+
URL) — pass `import.meta.url` from your own module when the `.wasm` is
86+
relative to *your* file:
87+
88+
[source,javascript]
89+
----
90+
const mod = await AffineModule.fromFile("./app.wasm", {
91+
base: import.meta.url,
92+
});
93+
----
94+
95+
`http(s):`, `data:`, and `blob:` URLs are fetched; `file:` URLs are read
96+
via the native filesystem on Deno/Node and via `fetch` in the browser.
97+
Windows and POSIX absolute paths are both accepted.
98+
99+
== Cross-module imports (INT-01)
100+
101+
`use Mod::{fn}` lowers to a WASM import under the `Mod` namespace, not
102+
`env`. Supply per-namespace implementations with `options.modules`:
103+
104+
[source,javascript]
105+
----
106+
const mod = await AffineModule.fromBytes(bytes, {
107+
modules: { Mod: { helper: (x) => x + 1 } },
108+
});
109+
----
110+
111+
== Ownership section (typed-wasm contract)
112+
113+
The compiler embeds an `affinescript.ownership` custom section carrying
114+
per-function parameter/return ownership kinds (the AffineScript ↔
115+
typed-wasm contract — see `docs/ECOSYSTEM.adoc`). Read it via:
116+
117+
[source,javascript]
118+
----
119+
mod.ownership;
120+
// => [{ funcIdx, paramKinds: ["linear", ...], retKind: "unrestricted" }, ...]
121+
----
122+
81123
== AffineValue types
82124

83125
All JS ↔ WASM value exchanges use the `AffineValue` tagged-union type:

packages/affine-js/deno.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
"version": "0.1.0",
44
"exports": {
55
".": "./mod.js",
6+
"./loader": "./loader.js",
67
"./marshal": "./marshal.js",
78
"./runtime": "./runtime.js"
89
},

packages/affine-js/loader.js

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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+
// affine-js/loader: host-agnostic loader bridge (INT-02, issue #179).
5+
//
6+
// Prior to this module `AffineModule.fromFile` was Deno-only: it called
7+
// `Deno.readFile(url.pathname)`. `url.pathname` is *not* a filesystem path
8+
// (it is percent-encoded, drops the Windows drive letter, and is meaningless
9+
// for non-`file:` URLs), and `Deno.readFile` does not exist on Node or in a
10+
// browser. That was SAT-02.
11+
//
12+
// This module provides the four pieces INT-02 requires:
13+
//
14+
// 1. relative URL resolution that is correct on every host;
15+
// 2. a host-agnostic byte reader (Deno / Node / browser parity);
16+
// 3. a full import-object builder (multi-namespace, for the cross-module
17+
// WASM imports INT-01/#178 emits — not `env`-only);
18+
// 4. an accessor for the `affinescript.ownership` custom section (the
19+
// typed-wasm contract carrier — see docs/ECOSYSTEM.adoc).
20+
//
21+
// It has no dependency on `mod.js`; `mod.js` consumes it.
22+
23+
/**
24+
* The JavaScript host we are running under.
25+
* @typedef {"deno"|"node"|"browser"|"unknown"} Host
26+
*/
27+
28+
/**
29+
* Detect the current JavaScript host by feature, not by user agent.
30+
* @returns {Host}
31+
*/
32+
export function detectHost() {
33+
if (typeof Deno !== "undefined" && Deno?.version?.deno) return "deno";
34+
if (
35+
typeof process !== "undefined" &&
36+
process?.versions?.node &&
37+
// A bundled-for-browser build can shim `process`; require real fs too.
38+
typeof globalThis.WebAssembly !== "undefined"
39+
) {
40+
return "node";
41+
}
42+
if (
43+
typeof globalThis.fetch === "function" &&
44+
(typeof window !== "undefined" || typeof self !== "undefined")
45+
) {
46+
return "browser";
47+
}
48+
return "unknown";
49+
}
50+
51+
/**
52+
* Resolve a module specifier to an absolute URL.
53+
*
54+
* Accepts a `URL`, an absolute URL string, a `file:`-relative specifier, or a
55+
* filesystem path (POSIX or Windows). `base` is required for relative
56+
* specifiers; callers pass their own `import.meta.url`.
57+
*
58+
* @param {string | URL} spec
59+
* @param {string | URL} [base]
60+
* @returns {URL}
61+
*/
62+
export function resolveUrl(spec, base) {
63+
if (spec instanceof URL) return spec;
64+
if (typeof spec !== "string") {
65+
throw new TypeError(
66+
`affine-js/loader: specifier must be a string or URL, got ${typeof spec}`,
67+
);
68+
}
69+
// Absolute URL (has a scheme like file:, http:, https:, data:, blob:).
70+
if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(spec) && !/^[a-zA-Z]:[\\/]/.test(spec)) {
71+
return new URL(spec);
72+
}
73+
// Windows absolute path, e.g. C:\dir\x.wasm -> file:///C:/dir/x.wasm
74+
if (/^[a-zA-Z]:[\\/]/.test(spec)) {
75+
return new URL(`file:///${spec.replace(/\\/g, "/")}`);
76+
}
77+
// POSIX absolute path.
78+
if (spec.startsWith("/")) return new URL(`file://${spec}`);
79+
// Relative specifier — needs a base.
80+
if (base === undefined) {
81+
throw new Error(
82+
`affine-js/loader: relative specifier ${JSON.stringify(spec)} needs a ` +
83+
`base URL; pass { base: import.meta.url }`,
84+
);
85+
}
86+
return new URL(spec, base);
87+
}
88+
89+
/**
90+
* Read a WASM module's bytes from any source, on any host.
91+
*
92+
* @param {string | URL | Uint8Array | ArrayBuffer} source
93+
* @param {{ base?: string | URL }} [options]
94+
* @returns {Promise<Uint8Array>}
95+
*/
96+
export async function readBytes(source, options = {}) {
97+
if (source instanceof Uint8Array) return source;
98+
if (source instanceof ArrayBuffer) return new Uint8Array(source);
99+
if (ArrayBuffer.isView(source)) {
100+
return new Uint8Array(source.buffer, source.byteOffset, source.byteLength);
101+
}
102+
103+
const url = resolveUrl(source, options.base);
104+
105+
if (url.protocol === "file:") {
106+
const host = detectHost();
107+
if (host === "deno") {
108+
// Deno.readFile accepts a URL directly — no pathname mangling.
109+
return await Deno.readFile(url);
110+
}
111+
if (host === "node") {
112+
const { readFile } = await import("node:fs/promises");
113+
const { fileURLToPath } = await import("node:url");
114+
const buf = await readFile(fileURLToPath(url));
115+
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
116+
}
117+
// Browser: a `file:` URL may still be reachable via fetch when the page
118+
// is itself served from disk; otherwise this throws a clear error.
119+
try {
120+
const res = await fetch(url);
121+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
122+
return new Uint8Array(await res.arrayBuffer());
123+
} catch (cause) {
124+
throw new Error(
125+
`affine-js/loader: cannot read ${url.href} in a browser host; ` +
126+
`serve the .wasm over http(s) or pass its bytes directly`,
127+
{ cause },
128+
);
129+
}
130+
}
131+
132+
// http:, https:, data:, blob: — fetch works on every host that has it.
133+
if (typeof globalThis.fetch !== "function") {
134+
throw new Error(
135+
`affine-js/loader: no fetch available to read ${url.href} on this host`,
136+
);
137+
}
138+
const res = await fetch(url);
139+
if (!res.ok) {
140+
throw new Error(
141+
`affine-js/loader: failed to fetch ${url.href} (HTTP ${res.status})`,
142+
);
143+
}
144+
return new Uint8Array(await res.arrayBuffer());
145+
}
146+
147+
/**
148+
* Build the full WebAssembly import object.
149+
*
150+
* The legacy shape merged everything into a single `env` namespace. INT-01
151+
* (#178) emits genuine cross-module imports under the *callee module's* name,
152+
* so the import object must carry arbitrary namespaces. This builder keeps
153+
* `env` backward-compatible (runtime defaults + flat `options.imports`) and
154+
* adds any `options.modules` namespaces verbatim.
155+
*
156+
* @param {Record<string, Function>} runtimeImports
157+
* @param {{ imports?: Record<string, Function>,
158+
* modules?: Record<string, Record<string, Function>> }} [options]
159+
* @returns {WebAssembly.Imports}
160+
*/
161+
export function buildImportObject(runtimeImports, options = {}) {
162+
/** @type {WebAssembly.Imports} */
163+
const importObject = {
164+
env: {
165+
...runtimeImports,
166+
...(options.imports ?? {}),
167+
},
168+
};
169+
for (const [ns, members] of Object.entries(options.modules ?? {})) {
170+
if (ns === "env") {
171+
// Merge rather than clobber the runtime namespace.
172+
Object.assign(importObject.env, members);
173+
} else {
174+
importObject[ns] = { ...(importObject[ns] ?? {}), ...members };
175+
}
176+
}
177+
return importObject;
178+
}
179+
180+
/** @typedef {"unrestricted"|"linear"|"sharedBorrow"|"exclBorrow"} OwnershipKind */
181+
182+
const OWNERSHIP_KINDS = /** @type {const} */ ([
183+
"unrestricted",
184+
"linear",
185+
"sharedBorrow",
186+
"exclBorrow",
187+
]);
188+
189+
/**
190+
* One per-function ownership annotation.
191+
* @typedef {Object} OwnershipEntry
192+
* @property {number} funcIdx
193+
* @property {OwnershipKind[]} paramKinds
194+
* @property {OwnershipKind} retKind
195+
*/
196+
197+
/**
198+
* Parse the `affinescript.ownership` custom section.
199+
*
200+
* Binary encoding (must match `Codegen.build_ownership_section` /
201+
* `Tw_verify.parse_ownership_section_payload` in the compiler):
202+
*
203+
* u32le count
204+
* for each entry:
205+
* u32le func_idx
206+
* u8 n_params
207+
* u8[n] param_kinds (0=Unrestricted,1=Linear,2=SharedBorrow,3=ExclBorrow)
208+
* u8 ret_kind
209+
*
210+
* @param {WebAssembly.Module} wasmModule
211+
* @returns {OwnershipEntry[]}
212+
*/
213+
export function parseOwnershipSection(wasmModule) {
214+
const sections = WebAssembly.Module.customSections(
215+
wasmModule,
216+
"affinescript.ownership",
217+
);
218+
if (sections.length === 0) return [];
219+
const view = new DataView(sections[0]);
220+
let pos = 0;
221+
const u32 = () => {
222+
const v = view.getUint32(pos, /* littleEndian */ true);
223+
pos += 4;
224+
return v;
225+
};
226+
const u8 = () => {
227+
const v = view.getUint8(pos);
228+
pos += 1;
229+
return v;
230+
};
231+
const kind = (b) => OWNERSHIP_KINDS[b] ?? "unrestricted";
232+
233+
const count = u32();
234+
/** @type {OwnershipEntry[]} */
235+
const entries = [];
236+
for (let i = 0; i < count; i++) {
237+
const funcIdx = u32();
238+
const nParams = u8();
239+
const paramKinds = [];
240+
for (let p = 0; p < nParams; p++) paramKinds.push(kind(u8()));
241+
const retKind = kind(u8());
242+
entries.push({ funcIdx, paramKinds, retKind });
243+
}
244+
return entries;
245+
}

0 commit comments

Comments
 (0)