This is the page a project outside this repository needs: how to get a compiler, which target to pick, how to declare the host functions your program calls, and how to carry errors across that boundary.
It exists because the first real consumer asked for exactly this and could
not find it — hyperpolymath/blocky-writer#73 filed
hyperpolymath/affinescript#771, "a real consumer exists, and it is blocked
on the on-ramp". Every claim below is either runnable in this repository or
marked as not-yet-true. Where something is missing, it says so rather than
leaving you to discover it.
|
Important
|
Feature readiness is not stated here. The authoritative per-feature status
is CAPABILITY-MATRIX.adoc; soundness-hole status
is SOUNDNESS.adoc. This page states only what a
consumer has to do, and it is CI-gated by
|
-
Get a compiler (Getting a compiler).
-
Write
.affinein the canonical face — see Not ReScript if your sources came from ReScript. -
Declare every host function you call with
extern fnand give it a type (Declaring your host surface). -
Compile to the target you want (Targets).
-
Supply those host functions when you load the artifact (The worked example).
There is no project manifest to write, no build-file schema to learn, and no
bundler configuration. The compiler takes a file and -o. If you want a
task runner, the repository’s own justfile recipes are the model —
just on-ramp-example runs the worked example.
Three routes exist. Only the first works today; the other two are recorded with their exact state so you do not spend an afternoon on them.
| Route | State (2026-10-03) | What to do |
|---|---|---|
Build from a checkout |
Works. |
|
Release binary |
Not available. All three published Releases ( |
Watch |
JSR shim |
Cannot work yet. The shim
downloads the host binary from the Release pinned in
|
Nothing yet. This is the same blocker as the row above: it unblocks when a
|
|
Note
|
|
One flag selects the output; the file extension also implies it.
| Invocation | Output | Use it when |
|---|---|---|
|
WebAssembly (linear memory) |
You want to embed the program in a host that owns the outside world: a browser extension, a Node/Bun/Deno process, wasmtime, a plugin host. The artifact’s imports are yours to satisfy (Declaring your host surface). This is the target the worked example uses. |
|
ES module |
You want JavaScript you can read, bundle, or hand to a JS toolchain, and
your host APIs are Node-shaped ( |
|
Node CJS shim |
You are building a VS Code extension and want the |
|
WebAssembly GC |
Your runtime supports the GC proposal and you want struct/array types instead of linear memory. V8 >= 119 / SpiderMonkey >= 120. |
Deno-ESM is retired: --deno-esm (or an -o …deno.js path) is an error by
design, not a missing feature. Use --bun-esm.
--face selects the surface syntax; the affine core is the same either way,
so the guarantees do not depend on your choice:
-
canonical(default) —.affine, the shape used in this repository. -
python/rattle— Python-shaped (def, indentation). -
js/jaffa— JavaScript-shaped (const,function,⇒). -
pseudocode,lucid,cafe— the remaining documented faces.
A file can declare its own face in a leading comment instead of relying on
the flag (# face: rattlescript). The recommended layout is .affine
everywhere plus that pragma; the old face-implying extensions (.rattle,
.pyaff, …) are deprecated.
A face is a shape for AffineScript source. It is not a ReScript parser.
.res written in ReScript is not a face, and renaming Foo.res to
Foo.affine does not make it one — it produces a file that fails to parse:
$ affinescript check src/popup.affine
src/popup.affine:16:8: parse error: Syntax errorReScript constructs with no AffineScript counterpart: @val / @module /
@scope / @react.component attributes, %raw, external … = "js.path"
bindings, Js.Dict.t / Js.Promise.t, ~labelled parameters, and
'a-style type variables. All eight .affine files in the consumer that
prompted this page fail to parse for exactly these reasons.
If your sources are ReScript, the honest sequence is:
-
Decide what you are actually migrating. A React component tree is not a mechanical translation: AffineScript’s typed surfaces are
stdlib/Dom.affine(VNode builders) and the TEA runtime, not a React binding. A pure data/algorithm module — a PDF byte protocol, a parser, a storage layer — usually is close to mechanical. -
Convert, don’t rename.
tools/res-to-affine/exists for this: it reads.resand emits an.affineskeleton with migration markers naming each anti-pattern it found, or--partialfor a partial port. Usage:dune exec tools/res-to-affine/main.exe — path/to/Foo.res. See MIGRATION-ASSISTANT.adoc and RESCRIPT-ELIMINATION.adoc. -
Port the boundary first. Declare the host functions the module needs (Declaring your host surface) before porting bodies; the boundary is what fixes the types, and it is where the compiler’s feedback is most useful.
This is the whole foreign-function story for the wasm target: a declaration
with no body. The compiler emits one import per declaration, into the env
module, with i32 parameters and an i32 result.
module boundary;
// Implemented by the host — see host.mjs in the worked example.
pub extern fn bw_detect_blocks(pdf_len: Int) -> Int;
pub extern fn bw_fill_blocks(pdf_len: Int, field_count: Int) -> Int;
// An opaque host-owned type, when you need one.
pub extern type HostBuffer;Your host satisfies them in the import object:
const imports = {
env: {
bw_detect_blocks: (pdfLen) => { /* ... */ return 0; },
bw_fill_blocks: (pdfLen, fieldCount) => { /* ... */ return 1042; },
},
};
const { instance } = await WebAssembly.instantiate(bytes, imports);
const status = instance.exports.detect(4096); // your `pub fn`s are exportsThree things this example does not show, in increasing order of effort:
-
Bytes in and out. Parameters are
i32, so a byte transfer is(pointer, length)into the guest’s exportedmemory, which the host reads and writes with aDataView. Worked references in this repository:tests/codegen/test_net_send.mjs(host writes into guest memory) andtests/codegen/test_file_roundtrip.mjs. -
Closures passed to the host. A closure is a pointer to
[fnId @+0, envPtr @+4]; the host looksfnIdup in the guest’s exported__indirect_function_tableand calls it withenvPtrfirst, zero-padding to arity. A complete dispatcher is intests/codegen/test_closure_indirect_dispatch.mjs, and the same code appears inpackages/affine-vscode/mod.js. -
The ESM target. There, a declared
extern fnthe compiler has no built-in lowering for becomes a plain call to that name — so the host must put it in scope (aglobalThis.<name> = …before importing the generated module). The pattern is intests/codegen-deno/pixi_smoke.harness.mjs.
Do not send strings. Send a status code, and let the host own the prose. This keeps the guest free of allocation and encoding questions, and gives you a stable taxonomy your host can switch on:
/// 0 is success; anything else is a stable code the host defines.
pub fn detect(pdf_len: Int) -> Int {
let status = bw_detect_blocks(pdf_len);
if is_ok(status) { 0 } else { bw_error_code() }
}The example pairs this with bw_error_code / bw_error_message_len /
bw_error_message_byte, so the human-readable message is reachable
byte-by-byte without the guest ever holding a string. The host keeps the
{code, message, context} payload it already wants; the guest only ever
names codes. examples/consumers/extension-boundary/host.mjs asserts the
whole round-trip.
Two grammar rules cost consumers time, so they are stated here:
-
if,whileandforare self-terminating statements — the closing}is enough. -
A
matchthat is not a block’s final expression is an ordinary expression statement and needs its;:
pub fn f(o: Opt) -> Int {
match o { // mid-block: statement, needs the `;`
SomeV(v) => { return v; }
NoneV => {}
};
return 0;
}Omitting that ; fails at the } that closes the match, which reads as
"the match is broken" rather than "the statement is unterminated". Empty arm
bodies (Pat ⇒ {}) are fine, in any position. The rule is pinned by
test_match_statement_requires_semicolon in test/test_e2e.ml, and the
grammar question ("should a }-terminated match self-terminate, as if
does?") is open on #644.
examples/consumers/extension-boundary/ is a complete consumer in one
directory, shaped after the first real consumer request: a two-function
boundary (detect / fill), a host-supplied error taxonomy, and a host
harness that asserts the contract.
$ just on-ramp-example
Compiling boundary.affine -> boundary.wasm
Compiled …/src/boundary.affine -> …/dist/boundary.wasm (WASM)
Running host.mjs
examples/consumers/extension-boundary: OKIts build.sh locates the compiler the three ways a consumer can have one
(in-tree dune build, affinescript on PATH, dune exec), so the same
script works in a checkout and against a release binary. CI runs it in the
build job, which means the page you are reading cannot drift away from the
code it describes without the build going red.
Recorded so you can plan around it rather than discover it:
-
No release binary. See Getting a compiler. Until a
v*tag publishes assets, building from a checkout is the only route. -
No project manifest.
affine.tomlis a prototype (tools/affine-pkg/); a consumer’s "build file" is a shell script or ajustfilerecipe. -
The host surface is per-target, not declared per-project. Adding a host function needs no compiler change (the
extern fnroute above), but the ESM target’s built-in lowerings are a fixed table inlib/codegen_deno.ml, so an ESM consumer cannot rename what the compiler already knows. -
docs/reference/ABI-FFI.adocis an unfilled estate template. It describes an Idris2-plus-Zig ABI that is not how a consumer reaches this compiler (the wasm import contract above is). Treat it as unmaintained until it is rewritten or deleted. -
Async host calls. The ESM target has documented async-extern limits (
#122/#103); the wasm target’sextern fnis synchronous.