I've been working on making little Roc apps that run in the browser, and a common pattern is to have a small JS runner that delegates all the important details to Roc.
Claude wrote some code for me that seemed like boilerplate code, and I said that I bet Roc programmers would get real tired of writing that kind of glue code. Claude astutely pointed that "glue" is a concept in Roc. :)
## JsGlue -- generate JavaScript readers for a Roc platform's types.
##
## Roc ships ZigGlue, RustGlue and CGlue; there is no JavaScript one, so a page
## that wants a Roc value has to be told the layout by hand. That is what
## `arcade/web/shapewire.js` is: a decoder written twice, once as
## `ShapeWire.pack` in Roc and once as a reader in JavaScript, with nothing
## checking that the two agree. This generates the reader instead.
##
## roc glue glue/JsGlue.roc <out-dir> <platform>/main.roc
##
## What it emits is a plain script (not a module) defining `RocGlue`, because
## the arcade's pages load scripts with `<script src>`, and:
##
## - one reader per type the platform's provided functions mention, reading
## a value out of a DataView over the wasm memory;
## - the signature of every provided function, as a comment, so a host author
## can see what it is binding.
##
## **WASM IS 32-BIT**, so every layout fact used here is the `*32` one. The
## compiler carries both widths; a 64-bit host would take the others.
app [make_glue] { pf: platform glue }
import pf.Types exposing [Types]
import pf.File exposing [File]
import pf.AbiLayout
make_glue : List(Types) -> Try(List(File), Str)
make_glue = |types_list|
match List.first(types_list) {
Err(_) => Err("JsGlue: the compiler passed no type table")
Ok(types) => Ok([File.{ name: "roc_glue.js", content: render(types) }])
}
render : Types -> Str
render = |types| {
entries = types.provides_entries
Str.concat(
Str.concat(header(types), readers(types)),
footer(entries, types),
)
}
header : Types -> Str
header = |_types|
Str.concat(
"// Generated by glue/JsGlue.roc. Do not edit; regenerate.\n//\n// Readers for the Roc values this platform hands a page. Each takes a\n",
"// DataView over the wasm memory and a byte offset, and answers a plain\n// JavaScript value. Layouts are the compiler's own, for a 32-bit pointer.\nconst RocGlue = (() => {\n\n",
)
## One reader per type mentioned by a provided function, in id order, so a
## reader is defined before anything that calls it.
readers : Types -> Str
readers = |types| {
n = List.len(types.types)
var $out = ""
var $i = 0
while $i < n {
$out = Str.concat($out, reader(types, $i))
$i = $i + 1
}
$out
}
## A type is readable when every part of it is. A function is not a value in
## memory; a rigid type variable is whatever an app chose and the platform
## never says. An aggregate is readable only if its parts are.
readable : Types, U64, U64 -> Bool
readable = |types, id, depth|
if depth > 8 {
Bool.False
} else {
match at(types, id).repr {
RocFunction(_) => Bool.False
RocUnknown(_) => Bool.False
RocStr => Bool.False
RocTagUnion(_) => Bool.False
RocDec => Bool.False
# A box of something unreadable is still a handle a page can hold.
RocBox(_) => Bool.True
RocList(elem) => readable(types, elem, depth + 1)
RocRecord(_) => all_readable(types, AbiLayout.record_fields(at(types, id).layout), depth)
_ => Bool.True
}
}
all_readable : Types, List(_), U64 -> Bool
all_readable = |types, fields, depth| {
n = List.len(fields)
var $ok = Bool.True
var $i = 0
while $i < n {
field = List.get(fields, $i) ?? crash("glue: field out of range")
$ok = if field.is_padding { $ok } else { $ok and readable(types, field.type_id, depth + 1) }
$i = $i + 1
}
$ok
}
reader : Types, U64 -> Str
reader = |types, id| {
info = at(types, id)
name = reader_name(id)
match if readable(types, id, 0) { body(types, id, info.repr) } else { Skipped } {
Skipped => ""
Reads(expr) => " // ${shape(info.repr)}\n const ${name} = (view, at) => ${expr};\n\n"
}
}
## The expression that reads one value of this type at `at`.
body : Types, U64, _ -> [Reads(Str), Skipped]
body = |types, id, repr|
match repr {
RocBool => Reads("view.getUint8(at) !== 0")
RocU8 => Reads("view.getUint8(at)")
RocU16 => Reads("view.getUint16(at, true)")
RocU32 => Reads("view.getUint32(at, true)")
RocU64 => Reads("view.getBigUint64(at, true)")
RocI8 => Reads("view.getInt8(at)")
RocI16 => Reads("view.getInt16(at, true)")
RocI32 => Reads("view.getInt32(at, true)")
RocI64 => Reads("view.getBigInt64(at, true)")
RocF32 => Reads("view.getFloat32(at, true)")
RocF64 => Reads("view.getFloat64(at, true)")
RocUnit => Reads("null")
RocBox(inner) =>
if readable(types, inner, 0) {
Reads("${reader_name(inner)}(view, view.getUint32(at, true))")
} else {
# An opaque model: a page can hold the pointer and hand it back.
Reads("view.getUint32(at, true)")
}
RocList(elem) => Reads(list_body(types, elem))
RocRecord(record) => Reads(record_body(types, id, record))
_ => Skipped
}
## A list is a pointer, a length and a capacity; the elements sit end to end,
## each as wide as the compiler says that element is.
list_body : Types, U64 -> Str
list_body = |types, elem| {
stride = at(types, elem).layout.size32
Str.concat(
"{\n const start = view.getUint32(at, true);\n const length = view.getUint32(at + 4, true);\n const out = [];\n",
" for (let i = 0; i < length; i++) out.push(${reader_name(elem)}(view, start + i * ${U64.to_str(stride)}));\n return out;\n }",
)
}
## A record is its fields, each at the offset the compiler committed to.
record_body : Types, U64, _ -> Str
record_body = |types, id, _record| {
fields = AbiLayout.record_fields(at(types, id).layout)
n = List.len(fields)
var $out = "({"
var $i = 0
while $i < n {
field = List.get(fields, $i) ?? crash("glue: field out of range")
$out = if field.is_padding {
$out
} else {
Str.concat($out, " ${field.name}: ${reader_name(field.type_id)}(view, at + ${U64.to_str(field.offset32)}),")
}
$i = $i + 1
}
Str.concat($out, " })")
}
footer : List(_), Types -> Str
footer = |entries, types| {
n = List.len(entries)
var $sigs = ""
var $i = 0
while $i < n {
entry = List.get(entries, $i) ?? crash("glue: entry out of range")
$sigs = Str.concat($sigs, " // ${entry.ffi_symbol} : ${shape(at(types, entry.type_id).repr)}\n")
$i = $i + 1
}
sigs = $sigs
Str.concat(
Str.concat(" // What this platform provides:\n", sigs),
"\n return { ${exported(types)} };\n})();\n",
)
}
## Every reader that got emitted, so a host can reach them by type id.
exported : Types -> Str
exported = |types| {
n = List.len(types.types)
var $out = ""
var $i = 0
while $i < n {
$out = if readable(types, $i, 0) {
if Str.is_empty($out) { reader_name($i) } else { Str.concat($out, ", ${reader_name($i)}") }
} else {
$out
}
$i = $i + 1
}
$out
}
## A one-line description of a type, for the comments.
shape : _ -> Str
shape = |repr|
match repr {
RocBool => "Bool"
RocU8 => "U8"
RocU16 => "U16"
RocU32 => "U32"
RocU64 => "U64"
RocI8 => "I8"
RocI16 => "I16"
RocI32 => "I32"
RocI64 => "I64"
RocF32 => "F32"
RocF64 => "F64"
RocStr => "Str"
RocDec => "Dec"
RocUnit => "{}"
RocBox(inner) => "Box(t${U64.to_str(inner)})"
RocList(elem) => "List(t${U64.to_str(elem)})"
RocRecord(_) => "a record"
RocTagUnion(_) => "a tag union"
RocFunction(f) => "${args_of(f.args)} -> t${U64.to_str(f.ret)}"
RocUnknown(what) => "unknown (${what})"
_ => "a vector"
}
args_of : List(U64) -> Str
args_of = |args| {
n = List.len(args)
var $out = ""
var $i = 0
while $i < n {
$out = Str.concat($out, "t${U64.to_str(List.get(args, $i) ?? 0)}")
$out = if $i + 1 < n { Str.concat($out, ", ") } else { $out }
$i = $i + 1
}
if n == 0 { "()" } else { $out }
}
reader_name : U64 -> Str
reader_name = |id| "read_t${U64.to_str(id)}"
at : Types, U64 -> _
at = |types, id| List.get(types.types, id) ?? crash("glue: type id out of range")
Using actual glue retired a fiddly hand-written module. Folks are welcome to borrow this code: https://github.com/showell/roc-apps/tree/master/glue
It's lightly tested and not necessarily complete, but feel free to crib off of it.
Apparently "glue handling" broke in the recent nightly build:
https://github.com/roc-lang/roc/issues/11662
Last updated: Sep 24 2026 at 15:59 UTC