Stream: show and tell

Topic: JsGlue.roc


view this post on Zulip Steve Howell (Sep 20 2026 at 02:29):

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")

view this post on Zulip Steve Howell (Sep 20 2026 at 02:31):

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.

view this post on Zulip Steve Howell (Sep 24 2026 at 11:08):

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