mirror of
https://github.com/ziex-dev/ziex.git
synced 2026-07-21 02:59:36 -06:00
fix: action handler states bounding
This commit is contained in:
parent
b1e17379d6
commit
5f8690ab06
@ -19,8 +19,9 @@ pub fn LoginForm(ctx: *zx.ComponentContext) zx.Component {
|
||||
}
|
||||
|
||||
const LoginInput = struct { name: []const u8, id: i32 };
|
||||
fn handleSubmit(data: LoginInput, sc: *zx.StateContext) void {
|
||||
const msg = sc.state([]const u8);
|
||||
fn handleSubmit(ctx: *zx.server.Action.Stateful) void {
|
||||
const data = ctx.data(LoginInput);
|
||||
const msg = ctx.state([]const u8);
|
||||
|
||||
zx.log.info("\n\nName: {s}\nID: {d}\nMessage: {s}\n", .{ data.name, data.id, msg.get()});
|
||||
|
||||
@ -29,7 +30,7 @@ fn handleSubmit(data: LoginInput, sc: *zx.StateContext) void {
|
||||
return;
|
||||
}
|
||||
|
||||
msg.set(sc.fmt("Hello, {s}! ({d})", .{ data.name, data.id }) catch "Err");
|
||||
msg.set(ctx.fmt("Hello, {s}! ({d})", .{ data.name, data.id }) catch "Err");
|
||||
zx.log.info("\n\nName: {s}\nID: {d}\n", .{ data.name, data.id });
|
||||
}
|
||||
|
||||
|
||||
@ -10,7 +10,7 @@ pub fn Page(ctx: zx.PageContext) zx.Component {
|
||||
pub fn LoginForm(ctx: *zx.ComponentContext) zx.Component {
|
||||
const msg = ctx.state([]const u8, "Please log in");
|
||||
return (
|
||||
<form @allocator={ctx.allocator} action={handleLogin}>
|
||||
<form @allocator={ctx.allocator} action={ctx.bind(handleSubmit)}>
|
||||
<p>{msg}</p>
|
||||
<input placeholder="Enter name" name="name" defaultValue="John Doe" required />
|
||||
<input placeholder="Enter ID" name="id" defaultValue="123" required />
|
||||
|
||||
@ -8,6 +8,7 @@ const Response = @import("runtime/core/Response.zig");
|
||||
const CoreEvent = @import("runtime/core/Event.zig");
|
||||
const ClientEvent = @import("runtime/client/Event.zig");
|
||||
const ServerEvent = @import("runtime/server/Event.zig");
|
||||
const ActionContext = @import("runtime/server/Action.zig");
|
||||
|
||||
const StateContext = CoreEvent.StateContext;
|
||||
const Allocator = std.mem.Allocator;
|
||||
@ -49,70 +50,6 @@ pub const ProxyContext = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Context for server-side form action handlers.
|
||||
pub const ActionContext = struct {
|
||||
request: Request = undefined,
|
||||
response: Response = undefined,
|
||||
allocator: Allocator = undefined,
|
||||
arena: Allocator = undefined,
|
||||
action_ref: u64 = 0,
|
||||
_state_ctx: ?*StateContext = null,
|
||||
|
||||
pub fn init(action_ref: u64) ActionContext {
|
||||
return .{ .action_ref = action_ref };
|
||||
}
|
||||
|
||||
pub fn data(self: ActionContext, comptime T: type) T {
|
||||
comptime if (@typeInfo(T) != .@"struct") @compileError("ctx.data() requires a struct type, got: " ++ @typeName(T));
|
||||
|
||||
const content_type = self.request.headers.get("content-type") orelse "";
|
||||
var result: T = undefined;
|
||||
|
||||
if (std.mem.indexOf(u8, content_type, "multipart/form-data") != null) {
|
||||
const mfd = self.request.multiFormData();
|
||||
inline for (@typeInfo(T).@"struct".fields) |field| {
|
||||
if (comptime field.type == zx.File) {
|
||||
const val = mfd.get(field.name);
|
||||
@field(result, field.name) = if (val) |v| zx.File.fromBytes(v.data, v.filename orelse "", "", self.arena) else zx.File{};
|
||||
} else if (comptime field.type == ?zx.File) {
|
||||
const val = mfd.get(field.name);
|
||||
@field(result, field.name) = if (val) |v| zx.File.fromBytes(v.data, v.filename orelse "", "", self.arena) else null;
|
||||
} else {
|
||||
@field(result, field.name) = parseFormField(field.type, mfd.getValue(field.name), self.arena);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fd = self.request.formData();
|
||||
inline for (@typeInfo(T).@"struct".fields) |field| {
|
||||
if (comptime field.type == zx.File or field.type == ?zx.File) {
|
||||
@field(result, field.name) = if (comptime field.type == zx.File) zx.File{} else null;
|
||||
} else {
|
||||
@field(result, field.name) = parseFormField(field.type, fd.get(field.name), self.arena);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
fn parseFormField(comptime T: type, raw: ?[]const u8, allocator: Allocator) T {
|
||||
_ = allocator;
|
||||
switch (@typeInfo(T)) {
|
||||
.optional => |opt| return parseFormField(opt.child, raw orelse return null, undefined),
|
||||
.pointer => {
|
||||
comptime if (T != []const u8) @compileError("ctx.data(): unsupported pointer type: " ++ @typeName(T));
|
||||
return raw orelse "";
|
||||
},
|
||||
.bool => {
|
||||
const val = raw orelse return false;
|
||||
return std.mem.eql(u8, val, "true") or std.mem.eql(u8, val, "1") or std.mem.eql(u8, val, "on");
|
||||
},
|
||||
.int => return std.fmt.parseInt(T, raw orelse return 0, 10) catch 0,
|
||||
.float => return std.fmt.parseFloat(T, raw orelse return 0) catch 0,
|
||||
else => @compileError("ctx.data(): unsupported field type '" ++ @typeName(T) ++ "'"),
|
||||
}
|
||||
}
|
||||
|
||||
// ----- ComponentCtx ----- //
|
||||
const BindSignMsg =
|
||||
@ -122,6 +59,8 @@ const BindSignMsg =
|
||||
\\ - fn(*zx.client.Event) void
|
||||
\\ - fn(*zx.server.Event.Stateful) void
|
||||
\\ - fn(*zx.server.Event) void
|
||||
\\ - fn(*zx.server.Action.Stateful) void
|
||||
\\ - fn(*zx.server.Action) void
|
||||
\\ - fn(ActionContext, *StateContext) void
|
||||
\\ - fn(struct, *StateContext) void
|
||||
\\
|
||||
@ -184,6 +123,7 @@ pub fn ComponentCtx(comptime PropsType: type) type {
|
||||
fn (*ServerEvent) void => zx.EventHandler.server(handler, alloc, &self._handler_index),
|
||||
|
||||
// Server Actions
|
||||
fn (*ActionContext.Stateful) void => zx.EventHandler.actionStateful(handler, alloc, self._component_id, self._state_index, &self._handler_index),
|
||||
fn (ActionContext, *StateContext) void => actionBind(handler, alloc, self),
|
||||
fn (*ActionContext) void => actionBind(handler, alloc, self),
|
||||
|
||||
@ -214,10 +154,8 @@ fn actionBind(comptime handler: anytype, alloc: Allocator, ctx: anytype) zx.Even
|
||||
{
|
||||
const FormActionWrapper = struct {
|
||||
fn wrap(action_ctx_ptr: *ActionContext) void {
|
||||
const mfd = action_ctx_ptr.request.multiFormData();
|
||||
const states_raw = mfd.getValue("__$states") orelse "[]";
|
||||
const states = zx.util.zxon.parse([]const []const u8, action_ctx_ptr.arena, states_raw, .{}) catch return;
|
||||
const sc = StateContext.init(action_ctx_ptr.arena, action_ctx_ptr.arena, states) orelse return;
|
||||
const inputs = action_ctx_ptr._inputs orelse &.{};
|
||||
const sc = StateContext.init(action_ctx_ptr.arena, action_ctx_ptr.arena, inputs) orelse return;
|
||||
action_ctx_ptr._state_ctx = sc;
|
||||
if (comptime arg0 == ActionContext) {
|
||||
handler(action_ctx_ptr.*, sc);
|
||||
@ -226,11 +164,17 @@ fn actionBind(comptime handler: anytype, alloc: Allocator, ctx: anytype) zx.Even
|
||||
}
|
||||
}
|
||||
};
|
||||
const bound = reactivity.collectStateBoundEntries(alloc, ctx._component_id, ctx._state_index);
|
||||
ctx._handler_index += 1;
|
||||
const h_id = ctx._handler_index;
|
||||
const ec = alloc.create(zx.EventHandler.Context) catch @panic("OOM");
|
||||
ec.* = .{ .handler_id = h_id, .bound_states = bound };
|
||||
return zx.EventHandler{
|
||||
.callback = &zx.EventHandler.actionHandler,
|
||||
.context = @as(*anyopaque, @ptrFromInt(1)),
|
||||
.context = @ptrCast(ec),
|
||||
.action_fn = &FormActionWrapper.wrap,
|
||||
.bound_states = reactivity.collectStateBoundEntries(alloc, ctx._component_id, ctx._state_index),
|
||||
.handler_id = h_id,
|
||||
.bound_states = bound,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -229,6 +229,36 @@ pub fn serverSS(
|
||||
return finalizeServer(alloc, handler_index, &wrap_fn, bound_states);
|
||||
}
|
||||
|
||||
/// Stateful action handler: fn(*zx.server.Action.Stateful) void (auto-binds states from component)
|
||||
pub fn actionStateful(
|
||||
comptime handler: anytype,
|
||||
alloc: Allocator,
|
||||
component_id: []const u8,
|
||||
state_index: u32,
|
||||
handler_index: *u32,
|
||||
) Self {
|
||||
const ActionWrap = struct {
|
||||
fn wrap(ctx: *zx.server.Action) void {
|
||||
const sc = zx.StateContext.init(ctx.allocator, ctx.arena, ctx._inputs orelse &.{}) orelse return;
|
||||
ctx._state_ctx = sc;
|
||||
var sf = zx.server.Action.Stateful{ ._inner = ctx, ._state_ctx = sc };
|
||||
handler(&sf);
|
||||
}
|
||||
};
|
||||
const bound = reactivity.collectStateBoundEntries(alloc, component_id, state_index);
|
||||
handler_index.* += 1;
|
||||
const h_id = handler_index.*;
|
||||
const ec = alloc.create(Context) catch @panic("OOM");
|
||||
ec.* = .{ .handler_id = h_id, .bound_states = bound };
|
||||
return .{
|
||||
.callback = &actionHandler,
|
||||
.context = @ptrCast(ec),
|
||||
.action_fn = &ActionWrap.wrap,
|
||||
.handler_id = h_id,
|
||||
.bound_states = bound,
|
||||
};
|
||||
}
|
||||
|
||||
/// Build Bound vtable for explicitly listed states.
|
||||
pub fn buildStates(alloc: Allocator, states: anytype) []const Bound {
|
||||
const state_fields = @typeInfo(@TypeOf(states)).@"struct".fields;
|
||||
@ -307,27 +337,55 @@ pub fn init(
|
||||
}
|
||||
|
||||
pub fn actionHandler(ctx: *anyopaque, event: zx.client.Event) void {
|
||||
_ = ctx;
|
||||
if (!is_wasm) return;
|
||||
event.preventDefault();
|
||||
const client_fetch = @import("../client/fetch.zig");
|
||||
const CoreFetch = @import("Fetch.zig");
|
||||
|
||||
const bound_states: []const Bound = if (@intFromPtr(ctx) == 1)
|
||||
&.{}
|
||||
else blk: {
|
||||
const ec: *Context = @ptrCast(@alignCast(ctx));
|
||||
break :blk ec.bound_states;
|
||||
};
|
||||
|
||||
const headers = [_]CoreFetch.RequestInit.Header{
|
||||
.{ .name = "Content-Type", .value = "application/json" },
|
||||
.{ .name = "X-ZX-Action", .value = "1" },
|
||||
};
|
||||
|
||||
client_fetch.fetchAsync(
|
||||
getGlobalAllocator(),
|
||||
"",
|
||||
.{
|
||||
.method = .GET,
|
||||
.headers = &headers,
|
||||
.body = "{}",
|
||||
},
|
||||
onActionResponse,
|
||||
);
|
||||
if (bound_states.len > 0) {
|
||||
var state_jsons = std.ArrayList([]const u8).empty;
|
||||
for (bound_states) |bs| {
|
||||
const json = bs.getJson(getGlobalAllocator(), bs.state_ptr);
|
||||
state_jsons.append(getGlobalAllocator(), json) catch {};
|
||||
}
|
||||
|
||||
var aw = std.Io.Writer.Allocating.init(getGlobalAllocator());
|
||||
zx.util.zxon.serialize(state_jsons.items, &aw.writer, .{}) catch {};
|
||||
const payload_buf = aw.written();
|
||||
|
||||
const cb_ctx = getGlobalAllocator().create(Context) catch return;
|
||||
cb_ctx.* = .{ .bound_states = bound_states };
|
||||
client_fetch.fetchAsyncCtx(
|
||||
getGlobalAllocator(),
|
||||
"",
|
||||
.{ .method = .POST, .headers = &headers, .body = payload_buf },
|
||||
@ptrCast(cb_ctx),
|
||||
onEventResponse,
|
||||
);
|
||||
} else {
|
||||
client_fetch.fetchAsync(
|
||||
getGlobalAllocator(),
|
||||
"",
|
||||
.{
|
||||
.method = .GET,
|
||||
.headers = &headers,
|
||||
.body = "{}",
|
||||
},
|
||||
onActionResponse,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn eventHandler(ctx: *anyopaque, event: zx.client.Event) void {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
const server = @import("server/Server.zig");
|
||||
|
||||
pub const Event = @import("server/Event.zig");
|
||||
pub const Action = @import("../contexts.zig").ActionContext;
|
||||
pub const Action = @import("server/Action.zig");
|
||||
|
||||
// Legacy --- will be renamed
|
||||
pub const SerilizableAppMeta = server.SerilizableAppMeta;
|
||||
|
||||
106
src/runtime/server/Action.zig
Normal file
106
src/runtime/server/Action.zig
Normal file
@ -0,0 +1,106 @@
|
||||
//! Server-side Action — context for server form action handlers.
|
||||
//!
|
||||
//! Provides form data parsing via `data()`.
|
||||
//! For state access, use `Action.Stateful` via `ctx.bind()` or `fn(*zx.server.Action.Stateful) void`.
|
||||
|
||||
const std = @import("std");
|
||||
const zx = @import("../../root.zig");
|
||||
const Request = @import("../core/Request.zig");
|
||||
const Response = @import("../core/Response.zig");
|
||||
const CoreEvent = @import("../core/Event.zig");
|
||||
|
||||
const StateContext = CoreEvent.StateContext;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const Action = @This();
|
||||
|
||||
request: Request = undefined,
|
||||
response: Response = undefined,
|
||||
allocator: Allocator = undefined,
|
||||
arena: Allocator = undefined,
|
||||
action_ref: u64 = 0,
|
||||
_state_ctx: ?*StateContext = null,
|
||||
/// Populated by dispatch when a stateful action sends state values in the request body.
|
||||
_inputs: ?[]const []const u8 = null,
|
||||
|
||||
pub fn init(action_ref: u64) Action {
|
||||
return .{ .action_ref = action_ref };
|
||||
}
|
||||
|
||||
pub fn data(self: Action, comptime T: type) T {
|
||||
comptime if (@typeInfo(T) != .@"struct") @compileError("ctx.data() requires a struct type, got: " ++ @typeName(T));
|
||||
|
||||
const content_type = self.request.headers.get("content-type") orelse "";
|
||||
var result: T = undefined;
|
||||
|
||||
if (std.mem.indexOf(u8, content_type, "multipart/form-data") != null) {
|
||||
const mfd = self.request.multiFormData();
|
||||
inline for (@typeInfo(T).@"struct".fields) |field| {
|
||||
if (comptime field.type == zx.File) {
|
||||
const val = mfd.get(field.name);
|
||||
@field(result, field.name) = if (val) |v| zx.File.fromBytes(v.data, v.filename orelse "", "", self.arena) else zx.File{};
|
||||
} else if (comptime field.type == ?zx.File) {
|
||||
const val = mfd.get(field.name);
|
||||
@field(result, field.name) = if (val) |v| zx.File.fromBytes(v.data, v.filename orelse "", "", self.arena) else null;
|
||||
} else {
|
||||
@field(result, field.name) = parseFormField(field.type, mfd.getValue(field.name), self.arena);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const fd = self.request.formData();
|
||||
inline for (@typeInfo(T).@"struct".fields) |field| {
|
||||
if (comptime field.type == zx.File or field.type == ?zx.File) {
|
||||
@field(result, field.name) = if (comptime field.type == zx.File) zx.File{} else null;
|
||||
} else {
|
||||
@field(result, field.name) = parseFormField(field.type, fd.get(field.name), self.arena);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Stateful server action — provides `state()` access to bound component state.
|
||||
/// Use `fn(*zx.server.Action.Stateful) void` with `ctx.bind()` to get this type.
|
||||
pub const Stateful = struct {
|
||||
_inner: *Action,
|
||||
_state_ctx: *StateContext,
|
||||
|
||||
/// Access the component's state (server-side).
|
||||
/// Must be called in the same order as `ctx.state()` in the render function.
|
||||
pub fn state(self: *Stateful, comptime T: type) CoreEvent.StateHandle(T) {
|
||||
return self._state_ctx.state(T);
|
||||
}
|
||||
|
||||
/// Parse form data from the action request into struct type T.
|
||||
pub fn data(self: *Stateful, comptime T: type) T {
|
||||
return self._inner.data(T);
|
||||
}
|
||||
|
||||
pub fn fmt(self: Stateful, comptime format: []const u8, args: anytype) ![]u8 {
|
||||
var aw: std.Io.Writer.Allocating = .init(self._state_ctx.arena);
|
||||
defer aw.deinit();
|
||||
aw.writer.print(format, args) catch |err| switch (err) {
|
||||
error.WriteFailed => return error.OutOfMemory,
|
||||
};
|
||||
return aw.toOwnedSlice();
|
||||
}
|
||||
};
|
||||
|
||||
fn parseFormField(comptime T: type, raw: ?[]const u8, allocator: Allocator) T {
|
||||
_ = allocator;
|
||||
switch (@typeInfo(T)) {
|
||||
.optional => |opt| return parseFormField(opt.child, raw orelse return null, undefined),
|
||||
.pointer => {
|
||||
comptime if (T != []const u8) @compileError("ctx.data(): unsupported pointer type: " ++ @typeName(T));
|
||||
return raw orelse "";
|
||||
},
|
||||
.bool => {
|
||||
const val = raw orelse return false;
|
||||
return std.mem.eql(u8, val, "true") or std.mem.eql(u8, val, "1") or std.mem.eql(u8, val, "on");
|
||||
},
|
||||
.int => return std.fmt.parseInt(T, raw orelse return 0, 10) catch 0,
|
||||
.float => return std.fmt.parseFloat(T, raw orelse return 0) catch 0,
|
||||
else => @compileError("ctx.data(): unsupported field type '" ++ @typeName(T) ++ "'"),
|
||||
}
|
||||
}
|
||||
@ -70,12 +70,28 @@ pub fn dispatchAction(
|
||||
|
||||
const is_js = request.headers.has("x-zx-action");
|
||||
|
||||
const sr = request.multiFormData().getValue("__$states") orelse "null";
|
||||
|
||||
std.debug.print("IsJS: {}\nStates: {s}", .{ is_js, sr });
|
||||
|
||||
// Parse state inputs for stateful actions.
|
||||
// JS path (X-ZX-Action header): states are the entire JSON body.
|
||||
// Form path (_submitFormActionAsync): states are in the __$states multipart field.
|
||||
const action_inputs: ?[]const []const u8 = if (false) blk: {
|
||||
const body_text = request.text() orelse break :blk null;
|
||||
break :blk zx.util.zxon.parse([]const []const u8, arena, body_text, .{}) catch null;
|
||||
} else blk: {
|
||||
const states_raw = request.multiFormData().getValue("__$states") orelse break :blk null;
|
||||
break :blk zx.util.zxon.parse([]const []const u8, arena, states_raw, .{}) catch null;
|
||||
};
|
||||
|
||||
if (registry.get(route_path)) |action_fn| {
|
||||
var action_ctx = zx.server.Action{
|
||||
.request = request,
|
||||
.response = response,
|
||||
.allocator = allocator,
|
||||
.arena = arena,
|
||||
._inputs = action_inputs,
|
||||
};
|
||||
action_fn(&action_ctx);
|
||||
const body = if (action_ctx._state_ctx) |sc| try serializeStateOutputs(sc, allocator) else null;
|
||||
@ -95,6 +111,7 @@ pub fn dispatchAction(
|
||||
.response = response,
|
||||
.allocator = allocator,
|
||||
.arena = arena,
|
||||
._inputs = action_inputs,
|
||||
};
|
||||
action_fn(&action_ctx);
|
||||
const body = if (action_ctx._state_ctx) |sc| try serializeStateOutputs(sc, allocator) else null;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user