Stream: platform development

Topic: "I just want a full stack dev server, but roc zig edition"


view this post on Zulip Scott Campbell (Nov 30 2025 at 21:33):

"I just want a full stack dev server, but roc zig edition"

I'm fairly new to roc & zig...

A messy prototype, it has some features, it's missing features, it's not usable for any real projects yet, but it's a start!

Screenshot_20251201_045942.png

Screenshot_20251201_023424.png

If you're looking for something you can use today... look at the rust implementations...

https://github.com/kamenchunathan/galena
https://github.com/niclas-ahden/joy
https://github.com/lukewilliamboswell/roc-experiment-js-dom

... And I'm going to sleep...

view this post on Zulip Luke Boswell (Nov 30 2025 at 21:35):

Have you seen my template for zig https://github.com/lukewilliamboswell/roc-platform-template-zig

view this post on Zulip Luke Boswell (Nov 30 2025 at 21:35):

That will be much nicer to start from than the fx test platform

view this post on Zulip Luke Boswell (Nov 30 2025 at 21:36):

It's a real implementation and even shows how to pass refcounted things when calling into Roc

view this post on Zulip Luke Boswell (Nov 30 2025 at 21:38):

More usefully it's using the new zig compiler which has a much nicer calling convention

view this post on Zulip Scott Campbell (Dec 01 2025 at 04:55):

Yup, seen it...

I'll probably want to look at the zig platform template again soon...

view this post on Zulip Scott Campbell (Dec 01 2025 at 07:56):

Added scaffolding command...

Screenshot_20251201_154441.png

Yeah, the zig platform template, is looking pretty good right about now... need a proper standalone build.zig and related files, to throw in ./my-roc-app/platform.

view this post on Zulip Scott Campbell (Dec 01 2025 at 08:19):

Yup, perfect... no idea what just happened, but it "just works".

Screenshot_20251201_161814.png

view this post on Zulip Scott Campbell (Dec 01 2025 at 08:37):

Ah, had to call zig build, and update the build.zig.zon hash... prior to calling bundle.sh

Screenshot_20251201_163610.png

view this post on Zulip Scott Campbell (Dec 01 2025 at 09:01):

Would be nice, if it was pinned against a specific git commit instead of #HEAD, then can have a static hash.

I prefer reproduce-ability, and manually bumping, over automated bleeding edge and the occasional breakage.

Which is a good point, i'll add static commit id for the template also...

And i can override with my own zon file with a static commit id for roc.

view this post on Zulip Luke Boswell (Dec 01 2025 at 09:07):

Once we have a numbered release I would pin to that, but for now we are updating things quite frequently... though any actual changes to the builtins are rare.

In theory we could have a totally separate library for the RocStr/RocList etc we are using in the platform, but for now it's easy just to treat the roc repository as a library and borrow our builtin module directly.

view this post on Zulip Scott Campbell (Dec 01 2025 at 09:25):

All good, I'm happy to implement it in my specific project.

I'll probably fork the template anyway, when I add some platform APIs

Screenshot_20251201_172205.png

view this post on Zulip Scott Campbell (Dec 05 2025 at 09:54):

Screenshot_20251204_214518.png

Stuck on...
Server.serve!(Str)

Should really be...
Server.serve!(Str->Str)

Except I don't know how to implement accepting a function, via the args pointer, in the host platform, for a function. Let alone how to invoke it.

We get a pointer... but a pointer to what exactly? I assume it's an array or struct, that could have multiple arguments passed...

And specifically, the first element in the array, would be some kind of function pointer...?

Reading the interpreter and CallHostedFunction... it's args_buffer of type *anyopaque...

fn callHostedFunction(
    self: *Interpreter,
    hosted_fn_index: u32,
    args: []StackValue,
    roc_ops: *RocOps,
    return_rt_var: types.Var,
) !StackValue {
    // Validate index is within bounds
    if (hosted_fn_index >= roc_ops.hosted_fns.count) {
        self.triggerCrash("Hosted function index out of bounds", false, roc_ops);
        return error.Crash;
    }

    // Get the hosted function pointer from RocOps
    const hosted_fn = roc_ops.hosted_fns.fns[hosted_fn_index];

    // Allocate space for the return value using the actual return type
    const return_layout = try self.getRuntimeLayout(return_rt_var);
    const result_value = try self.pushRaw(return_layout, 0);

    // Get return pointer (for ZST returns, use a dummy stack address)
    const ret_ptr = if (result_value.ptr) |p| p else @as(*anyopaque, @ptrFromInt(@intFromPtr(&result_value)));

    // Calculate total size needed for packed arguments
    var total_args_size: usize = 0;
    var max_alignment: std.mem.Alignment = .@"1";
    for (args) |arg| {
        const arg_size: usize = self.runtime_layout_store.layoutSize(arg.layout);
        const arg_align = arg.layout.alignment(self.runtime_layout_store.targetUsize());
        max_alignment = max_alignment.max(arg_align);
        // Align to the argument's alignment
        total_args_size = std.mem.alignForward(usize, total_args_size, arg_align.toByteUnits());
        total_args_size += arg_size;
    }

    if (args.len == 0) {
        // Zero argument case - pass dummy pointer
        var dummy: u8 = 0;
        hosted_fn(roc_ops, ret_ptr, @ptrCast(&dummy));
    } else {
        // Allocate buffer for packed arguments
        const args_buffer = try self.stack_memory.alloca(@intCast(total_args_size), max_alignment);

        // Pack each argument into the buffer
        var offset: usize = 0;
        for (args) |arg| {
            const arg_size: usize = self.runtime_layout_store.layoutSize(arg.layout);
            const arg_align = arg.layout.alignment(self.runtime_layout_store.targetUsize());

            // Align offset
            offset = std.mem.alignForward(usize, offset, arg_align.toByteUnits());

            // Copy argument data
            if (arg_size > 0) {
                if (arg.ptr) |src_ptr| {
                    const dest_ptr = @as([*]u8, @ptrCast(args_buffer)) + offset;
                    @memcpy(dest_ptr[0..arg_size], @as([*]const u8, @ptrCast(src_ptr))[0..arg_size]);
                }
            }
            offset += arg_size;
        }

        // Invoke the hosted function following RocCall ABI: (ops, ret_ptr, args_ptr)
        hosted_fn(roc_ops, ret_ptr, args_buffer);
    }

    return result_value;
}

Screenshot_20251205_173745.png

I assume the StackValue, in question is actually a layout.Closure...?

/// Represents a closure with its captured environment
pub const Closure = struct {
    body_idx: CIR.Expr.Idx,
    params: CIR.Pattern.Span,
    captures_pattern_idx: CIR.Pattern.Idx,
    // Layout index for the captured environment record
    captures_layout_idx: Idx,
    // Original lambda expression index for accessing captures
    lambda_expr_idx: CIR.Expr.Idx,
    // Module environment where this closure was created (for correct expression evaluation)
    source_env: *const @import("can").ModuleEnv,
};

I added roc's layout module, to the platform... so I have the struct available... I tried copy pasting the string example...

const Args = extern struct { func: layout.Closure };
const args: *Args = @ptrCast(@alignCast(args_ptr));

Zig complains that's not not flagged as an ABI sized packed struct... And this is getting a bit more technical then used to... so i'm starting to get slightly confused.

platform/host.zig:288:46: error: extern structs cannot contain fields of type 'layout.Closure'
    const Args = extern struct { func: layout.Closure };
                                       ~~~~~~^~~~~~~~
platform/host.zig:288:46: note: only extern structs and ABI sized packed structs are extern compatible
/home/anon/.cache/zig/p/roc-0.0.0-NAC9w6FtewDruF0JVeJYUTYDDBMU9xmKwv5RCBoCplzn/src/layout/layout.zig:116:21: note: struct declared here
pub const Closure = struct {
                    ^~~~~~
referenced by:
    hostedZServerServe2: platform/host.zig:288:25
    hosted_function_ptrs: platform/host.zig:317:5

And now I'm kinda stuck...

pub const RocStr = extern struct {
// ...
pub const Closure = struct {

Hmm...

view this post on Zulip Scott Campbell (Dec 05 2025 at 10:21):

Oh, i see... @This, is actually referring to the fields below?!

const StackValue = @This();

/// Type and memory layout information for the result value
layout: Layout,
/// Ptr to the actual value in stack memory
ptr: ?*anyopaque,
/// Flag to track whether the memory has been initialized
is_initialized: bool = false,
/// Optional runtime type variable for type information (used in constant folding)
rt_var: ?types.Var = null,

view this post on Zulip Scott Campbell (Dec 05 2025 at 10:50):

if (arg.ptr) |src_ptr| {
    const dest_ptr = @as([*]u8, @ptrCast(args_buffer)) + offset;
    @memcpy(dest_ptr[0..arg_size], @as([*]const u8, @ptrCast(src_ptr))[0..arg_size]);
}

So it looks like, it's memcpying, whatever is at the ptr, for whatever the arg_size is... and arg_size is... the size of the layout.

/// Get the size in bytes of a layout, given the store's target usize.
pub fn layoutSize(self: *const Self, layout: Layout) u32 {
    // TODO change this to SizeAlign (just return both since they're packed into 4B anyway)
    // and also change it to just return that one field instead of doing any conditionals.
    // also have it take an Idx. if you already have a Layout you can just get that.
    const target_usize = self.targetUsize();
    return switch (layout.tag) {
        .scalar => switch (layout.data.scalar.tag) {
            .int => layout.data.scalar.data.int.size(),
            .frac => layout.data.scalar.data.frac.size(),
            .str => 3 * target_usize.size(), // ptr, byte length, capacity
            .opaque_ptr => target_usize.size(), // opaque_ptr is pointer-sized
        },
        .box, .box_of_zst => target_usize.size(), // a Box is just a pointer to refcounted memory
        .list => 3 * target_usize.size(), // ptr, length, capacity
        .list_of_zst => 3 * target_usize.size(), // list_of_zst has same header structure as list
        .record => self.record_data.get(@enumFromInt(layout.data.record.idx.int_idx)).size,
        .tuple => self.tuple_data.get(@enumFromInt(layout.data.tuple.idx.int_idx)).size,
        .closure => {
            // Closure layout: header + aligned capture data
            const header_size = @sizeOf(layout_mod.Closure);
            const captures_layout = self.getLayout(layout.data.closure.captures_layout_idx);
            const captures_alignment = captures_layout.alignment(self.targetUsize());
            const aligned_captures_offset = std.mem.alignForward(u32, header_size, @intCast(captures_alignment.toByteUnits()));
            const captures_size = self.layoutSize(captures_layout);
            return aligned_captures_offset + captures_size;
        },
        .tag_union => self.tag_union_data.get(@enumFromInt(layout.data.tag_union.idx.int_idx)).size,
        .zst => 0, // Zero-sized types have size 0
    };
}

Hmmm...


Last updated: Dec 21 2025 at 12:15 UTC