Grain WASM Nodes
Grain is a strongly-typed functional language that compiles to WebAssembly. The template produces a Core Module (not a Component Model module) — see Runtime Models for the difference. With --no-gc and --elide-type-info, Grain produces compact binaries with stable memory pointers suitable for the host ABI.
Prerequisites
Section titled “Prerequisites”# macOSbrew install --no-quarantine --cask grain-lang/tap/grain
# Or download the binary directlysudo curl -L --output /usr/local/bin/grain \ https://github.com/grain-lang/grain/releases/download/grain-v0.7.2/grain-mac-x64 \ && sudo chmod +x /usr/local/bin/grainProject Structure
Section titled “Project Structure”wasm-node-grain/├── src/│ └── main.gr # Main node implementation├── flow-like.toml # Flow-Like package manifest├── mise.toml # Build tasks└── README.mdThe SDK lives in ../wasm-sdk-grain/ and is included via the -I compiler flag. It provides Types, Context, Memory, and Sdk modules.
Template Code
Section titled “Template Code”Node Definition
Section titled “Node Definition”Edit src/main.gr and modify buildDefinition to describe your node’s pins and metadata:
module Main
from "sdk" include Sdkfrom "types" include Typesfrom "memory" include Memoryfrom "context" include Context
from "string" include Stringfrom "runtime/unsafe/wasmi32" include WasmI32from "runtime/unsafe/wasmi64" include WasmI64
let buildDefinition = () => { let mut def = Types.newNodeDefinition() def.name = "my_custom_node_grain" def.friendlyName = "My Custom Node (Grain)" def.description = "A template WASM node built with Grain" def.category = "Custom/WASM"
let def = Types.addPermission(def, "streaming")
let def = Types.addPin( def, Types.inputPin("exec", "Execute", "Trigger execution", Types.Exec), ) let def = Types.addPin( def, Types.withDefault( Types.inputPin("input_text", "Input Text", "Text to process", Types.TypeString), "\"\"", ), ) let def = Types.addPin( def, Types.outputPin("exec_out", "Done", "Execution complete", Types.Exec), ) let def = Types.addPin( def, Types.outputPin("output_text", "Output Text", "Processed text", Types.TypeString), )
def}Exports
Section titled “Exports”Every Core Module node must export get_node, get_nodes, run, alloc, dealloc, and get_abi_version. Grain uses @externalName("...") to set the export name and @unsafe for raw pointer operations. Memory packing returns a packed i64 (ptr << 32 | len).
@unsafe@externalName("get_node")provide let _getNode = () => { let def = buildDefinition() Memory.packString(Types.nodeDefToJson(def))}
@unsafe@externalName("get_nodes")provide let _getNodes = () => { let def = buildDefinition() let json = "[" ++ Types.nodeDefToJson(def) ++ "]" Memory.packResult(json)}Run Handler
Section titled “Run Handler”The run export receives a pointer and length to the JSON execution input. Use Context helpers to read inputs, set outputs, log, and stream:
@unsafe@externalName("run")provide let _run = (ptr: WasmI32, len: WasmI32) => { let inputJson = Memory.ptrToString(ptr, len) let input = Types.parseExecutionInput(inputJson) let ctx = Context.init(input)
let inputText = Context.getString(ctx, "input_text", "") let multiplier = Context.getI64(ctx, "multiplier", 1)
Context.debug(ctx, "Processing: '" ++ inputText ++ "' x " ++ toString(multiplier))
let mut output = "" for (let mut i = 0; i < multiplier; i += 1) { output = output ++ inputText }
Context.setOutput(ctx, "output_text", Types.jsonString(output)) Context.setOutput(ctx, "char_count", toString(String.length(output)))
let result = Context.success(ctx) Memory.packString(Types.resultToJson(result))}Memory Management
Section titled “Memory Management”Re-export alloc and dealloc — required by the host to pass data across the WASM boundary:
@unsafe@externalName("alloc")provide let _alloc = (size: WasmI32) => { Memory.wasmAlloc(size)}
@unsafe@externalName("dealloc")provide let _dealloc = (ptr: WasmI32, size: WasmI32) => { Memory.wasmDealloc(ptr, size)}
@unsafe@externalName("get_abi_version")provide let _getAbiVersion = () => { 1n}Context API
Section titled “Context API”| Method | Description |
|---|---|
Context.getString(ctx, pin, default) | Get string input |
Context.getI64(ctx, pin, default) | Get integer input |
Context.getF64(ctx, pin, default) | Get float input |
Context.getBool(ctx, pin, default) | Get boolean input |
Context.setOutput(ctx, pin, value) | Set an output value |
Context.debug(ctx, msg) | Log debug message |
Context.logInfo(ctx, msg) | Log info message |
Context.warn(ctx, msg) | Log warning |
Context.logError(ctx, msg) | Log error |
Context.streamText(ctx, text) | Stream text to the client |
Context.success(ctx) | Finalize with exec_out activated |
Context.fail(ctx, msg) | Finalize with an error |
grain compile --release --no-gc --elide-type-info \ -I ../wasm-sdk-grain -o build/node.wasm src/main.grOutput: build/node.wasm
Or use mise tasks from the template:
mise run setup # verify Grain is installedmise run build # compile to build/node.wasmmise run test # run unit testsmise run clean # remove build artifactsCompiler Flags
Section titled “Compiler Flags”| Flag | Purpose |
|---|---|
--release | Enable optimizations (smaller + faster binary) |
--no-gc | Disable Grain’s GC — critical for core module ABI compatibility |
--elide-type-info | Strip runtime type info to reduce binary size |
-I <path> | Add include directory for SDK module resolution |
-o <file> | Output file path |
Related
Section titled “Related”- Overview — How WASM nodes work
- Runtime Models — Core Module vs Component Model
- Package Manifest — Full manifest reference
- Rust WASM Nodes — Recommended language with SDK macros