"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...
Have you seen my template for zig https://github.com/lukewilliamboswell/roc-platform-template-zig
That will be much nicer to start from than the fx test platform
It's a real implementation and even shows how to pass refcounted things when calling into Roc
More usefully it's using the new zig compiler which has a much nicer calling convention
Yup, seen it...
I'll probably want to look at the zig platform template again soon...
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.
Yup, perfect... no idea what just happened, but it "just works".
Screenshot_20251201_161814.png
Ah, had to call zig build, and update the build.zig.zon hash... prior to calling bundle.sh
Screenshot_20251201_163610.png
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.
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.
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
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...
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,
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