refactor: cleanup event handler dispatcher

This commit is contained in:
Nurul Huda (Apon) 2026-06-22 13:49:53 +06:00
parent a5a93e7255
commit a7f47a4137
No known key found for this signature in database
GPG Key ID: 5D3F1DE2855A2F79
4 changed files with 78 additions and 121 deletions

View File

@ -83,7 +83,7 @@ export class ZxBridge extends ZxBridgeCore {
fetch(window.location.href, {
method: 'POST',
// `>>> 0` reinterprets the i32 from wasm as u32 (ids can exceed 2^31).
headers: { 'X-ZX-Action': String(actionId >>> 0) },
headers: { 'x-action': String(actionId >>> 0) },
body: formData,
})
.then(async (response) => {
@ -366,7 +366,7 @@ export class ZxBridge extends ZxBridgeCore {
fetch(window.location.href, {
method: 'POST',
// `>>> 0` reinterprets the i32 from wasm as u32 (ids can exceed 2^31).
headers: { 'X-ZX-Action': String(actionId >>> 0) },
headers: { 'x-action': String(actionId >>> 0) },
body: formData,
}).catch(() => {});
},

View File

@ -1,20 +1,14 @@
/// Create an HTML/SVG element by tag enum id, register it in the JS domNodes
/// registry under vnode_id, and set __zx_ref = vnode_id on the element.
/// Returns the jsz ref (needed to construct the root HTMLElement for CommentMarker).
// DOM
/// Create element
pub extern "__zx" fn _ce(id: usize, vnode_id: u64) u64;
/// Create a text node with the given content, register it in the JS domNodes
/// registry under vnode_id, and set __zx_ref = vnode_id on the node.
/// Create text
pub extern "__zx" fn _ct(ptr: [*]const u8, len: usize, vnode_id: u64) u64;
// Attribute / property mutation
/// setAttribute on the element identified by vnode_id.
pub extern "__zx" fn _sa(vnode_id: u64, name_ptr: [*]const u8, name_len: usize, val_ptr: [*]const u8, val_len: usize) void;
/// Set a DOM property (not attribute) on the element identified by vnode_id.
/// Used for properties like checked, value, selected, muted where
/// setAttribute does not reflect the current state after user interaction.
/// Set property
pub extern "__zx" fn _sp(vnode_id: u64, name_ptr: [*]const u8, name_len: usize, val_ptr: [*]const u8, val_len: usize) void;
/// removeAttribute on the element identified by vnode_id.
@ -23,24 +17,19 @@ pub extern "__zx" fn _ra(vnode_id: u64, name_ptr: [*]const u8, name_len: usize)
/// Set nodeValue on the text node identified by vnode_id.
pub extern "__zx" fn _snv(vnode_id: u64, ptr: [*]const u8, len: usize) void;
/// Set innerHTML (raw, unescaped HTML) on the element identified by vnode_id.
/// Used for elements with @escaping={.none}.
/// Set innerHTML
pub extern "__zx" fn _srh(vnode_id: u64, ptr: [*]const u8, len: usize) void;
// DOM tree mutation
/// parent.appendChild(child) - both nodes looked up by vnode_id.
/// parent.appendChild(child)
pub extern "__zx" fn _ac(parent_id: u64, child_id: u64) void;
/// parent.insertBefore(child, ref) - all nodes looked up by vnode_id.
/// parent.insertBefore(child, ref)
pub extern "__zx" fn _ib(parent_id: u64, child_id: u64, ref_id: u64) void;
/// parent.removeChild(child) - looked up by vnode_id.
/// Also recursively removes all descendants from the JS domNodes registry.
/// parent.removeChild(child)
pub extern "__zx" fn _rc(parent_id: u64, child_id: u64) void;
/// parent.replaceChild(new_child, old_child) - looked up by vnode_id.
/// Also removes old_child subtree from the JS domNodes registry.
/// parent.replaceChild(new_child, old_child)
pub extern "__zx" fn _rpc(parent_id: u64, new_id: u64, old_id: u64) void;
// Async / timer
@ -57,20 +46,13 @@ pub extern "__zx" fn _wsClose(ws_id: u64, code: u16, reason_ptr: [*]const u8, re
/// Write window.location.href into buf. Returns the number of bytes written.
pub extern "__zx" fn _getLocationHref(buf: [*]u8, buf_len: usize) usize;
/// Serialize the form data of the form DOM element identified by vnode_id as a
/// URL-encoded string (application/x-www-form-urlencoded). Returns the number
/// of bytes written to buf (0 if the element is not a form or not found).
/// Get serialized form data
pub extern "__zx" fn _getFormData(vnode_id: u64, buf_ptr: [*]u8, buf_len: usize) usize;
/// Submit a form action: reads the DOM form identified by vnode_id, builds a
/// multipart/form-data request with the X-ZX-Action header set to action_id,
/// and fires it via the JS fetch API. The browser handles multipart
/// serialization (including file inputs) so no WASM-side encoding is required.
/// Submits a form with action identity
pub extern "__zx" fn _submitFormAction(vnode_id: u64, action_id: u32) void;
/// Like _submitFormAction but stateful: injects the serialised bound-state JSON
/// as a `__$states` multipart field and calls __zx_fetch_complete(fetch_id,)
/// when the response arrives so WASM can apply the returned state updates.
/// Submits a form with component states in __$states field
pub extern "__zx" fn _submitFormActionAsync(
vnode_id: u64,
action_id: u32,

View File

@ -10,6 +10,13 @@ fn getGlobalAllocator() std.mem.Allocator {
return zx.allocator;
}
pub const Constants = struct {
pub const action_form_name = "__$action";
pub const states_form_name = "__$states";
pub const action_header_name = "x-action";
pub const event_header_name = "x-event";
};
pub const Bound = struct {
state_ptr: *anyopaque,
/// Serialize the current state value to its positional JSON representation.
@ -394,7 +401,7 @@ pub fn actionHandler(ctx: *anyopaque, event: zx.client.Event) void {
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{handler_id}) catch "0";
const headers = [_]CoreFetch.RequestInit.Header{
.{ .name = "Content-Type", .value = "application/json" },
.{ .name = "X-ZX-Action", .value = id_str },
.{ .name = Constants.action_header_name, .value = id_str },
};
if (bound_states.len > 0) {
@ -432,7 +439,7 @@ pub fn actionHandler(ctx: *anyopaque, event: zx.client.Event) void {
}
fn eventHandler(ctx: *anyopaque, event: zx.client.Event) void {
if (!is_wasm) return;
if (comptime (!is_wasm)) return;
event.preventDefault();
const client_fetch = @import("../client/fetch.zig");
@ -458,9 +465,11 @@ fn eventHandler(ctx: *anyopaque, event: zx.client.Event) void {
state_jsons.append(getGlobalAllocator(), json) catch {};
}
var id_buf: [16]u8 = undefined;
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{handler_id}) catch "0";
const headers = [_]CoreFetch.RequestInit.Header{
.{ .name = "Content-Type", .value = "application/json" },
.{ .name = "X-ZX-Server-Event", .value = "1" },
.{ .name = Constants.event_header_name, .value = id_str },
};
const payload = Payload{

View File

@ -3,6 +3,9 @@ const zx = @import("../../root.zig");
const registry = @import("registry.zig");
const render = @import("render.zig");
const EventHandler = zx.EventHandler;
const Constants = EventHandler.Constants;
pub const PageFn = *const fn (zx.PageContext, ?*const anyopaque, ?*const anyopaque) anyerror!zx.Component;
pub const DispatchResult = union(enum) {
@ -18,33 +21,27 @@ pub const DispatchResult = union(enum) {
page_error: anyerror,
};
/// Returns true if the request is a server action request.
/// Checks for the x-zx-action header (JS fetch) or __$action in the form body (no-JS form POST).
pub fn isActionRequest(request: zx.server.Request) bool {
if (request.headers.has("x-zx-action")) return true;
const body = request.text() orelse return false;
const ct = request.headers.get("content-type") orelse "";
return std.mem.indexOf(u8, body, "__$action=") != null or
(std.mem.indexOf(u8, ct, "multipart/form-data") != null and
std.mem.indexOf(u8, body, "name=\"__$action\"") != null);
}
fn parseActionId(request: zx.server.Request) u32 {
fn parseActionId(request: zx.server.Request) !u32 {
const content_type = request.headers.get("content-type") orelse "";
if (std.mem.indexOf(u8, content_type, "multipart/form-data") != null) {
if (request.multiFormData().getValue("__$action")) |raw| {
return std.fmt.parseInt(u32, raw, 10) catch 1;
}
} else if (request.formData().get("__$action")) |raw| {
return std.fmt.parseInt(u32, raw, 10) catch 1;
}
const is_multipart = std.mem.indexOf(u8, content_type, "multipart/form-data") != null;
// Fall back to the header (JS fetches with no form body).
if (request.headers.get("x-zx-action")) |raw| {
return std.fmt.parseInt(u32, raw, 10) catch 1;
}
const action_id = blk: {
// Multi-part Form
if (is_multipart) if (request.multiFormData().getValue(Constants.action_form_name)) |raw|
break :blk try std.fmt.parseInt(u32, raw, 10);
return 1;
// Form
if (request.formData().get(Constants.action_form_name)) |raw|
break :blk try std.fmt.parseInt(u32, raw, 10);
// Client Form
if (request.headers.get(Constants.action_header_name)) |raw|
break :blk try std.fmt.parseInt(u32, raw, 10);
return error.NotServerAction;
};
return action_id;
}
fn serializeStateOutputs(sc: anytype, allocator: std.mem.Allocator) !?[]u8 {
@ -53,6 +50,8 @@ fn serializeStateOutputs(sc: anytype, allocator: std.mem.Allocator) !?[]u8 {
return aw.written();
}
/// it is used to register the action handler in cases where they are not in the memory,
/// this is always true for statless hosting platform like Cloudflare worker
fn slowPathRender(
page_fn: PageFn,
pagectx: zx.PageContext,
@ -85,61 +84,39 @@ pub fn dispatchAction(
state_ptr: ?*const anyopaque,
base_path: ?[]const u8,
) !DispatchResult {
if (!isActionRequest(request)) return .not_triggered;
const action_id = parseActionId(request) catch return .not_triggered;
const is_progressive = request.headers.has(Constants.action_header_name);
const mfd = request.multiFormData();
const is_js = request.headers.has("x-zx-action");
const action_id = parseActionId(request);
// TODO: cleanup
// 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;
const action_states: ?[]const []const u8 = blk: {
break :blk zx.util.zxon.parse(
[]const []const u8,
arena,
mfd.getValue(Constants.states_form_name) orelse break :blk null,
.{},
) catch null;
};
if (registry.get(route_path, action_id)) |action_fn| {
var action_ctx = zx.server.Action{
.request = request,
.response = response,
.allocator = allocator,
.arena = arena,
._internal = .{ .inputs = action_inputs },
};
action_fn(&action_ctx);
const body = if (action_ctx._internal.state_ctx) |sc| try serializeStateOutputs(sc, allocator) else null;
return if (is_js) .{ .ok = .{ .body = body } } else .ok_native;
}
// Slow path: render the page to populate the registry, then retry.
if (page_fn) |pfn| {
var action_fn = registry.get(route_path, action_id);
if (action_fn == null) if (page_fn) |pfn| {
if (slowPathRender(pfn, pagectx, route_path, arena, app_ptr, state_ptr, base_path)) |err| {
return .{ .page_error = err };
}
}
};
action_fn = registry.get(route_path, action_id);
if (registry.get(route_path, action_id)) |action_fn| {
if (action_fn) |af| {
var action_ctx = zx.server.Action{
.request = request,
.response = response,
.allocator = allocator,
.arena = arena,
._internal = .{ .inputs = action_inputs },
._internal = .{ .inputs = action_states },
};
action_fn(&action_ctx);
af(&action_ctx);
const body = if (action_ctx._internal.state_ctx) |sc| try serializeStateOutputs(sc, allocator) else null;
return if (is_js) .{ .ok = .{ .body = body } } else .ok_native;
}
return .not_found;
return if (is_progressive) .{ .ok = .{ .body = body } } else .ok_native;
} else return .not_found;
}
/// Dispatches a server event request. Performs a fast-path registry lookup and falls back
@ -156,38 +133,27 @@ pub fn dispatchServerEvent(
state_ptr: ?*const anyopaque,
base_path: ?[]const u8,
) !DispatchResult {
if (!request.headers.has("x-zx-server-event")) return .not_triggered;
if (!request.headers.has(Constants.event_header_name)) return .not_triggered;
const payload = zx.util.zxon.parse(zx.EventHandler.Payload, arena, request.text() orelse return .not_found, .{}) catch return .not_found;
if (registry.getEvent(route_path, payload.handler_id)) |event_fn| {
var event_ctx = zx.server.Event{
.allocator = allocator,
.arena = arena,
._internal = .{ .payload = payload },
};
event_fn(&event_ctx);
const body = if (event_ctx._internal.state_ctx) |sc| try serializeStateOutputs(sc, allocator) else null;
return .{ .ok = .{ .body = body } };
}
// Slow path: render the page to populate the registry, then retry.
if (page_fn) |pfn| {
// TODO: get the handler_id from header instead, currently the id in header is not accurate
const handler_id = payload.handler_id;
var event_fn = registry.getEvent(route_path, handler_id);
if (event_fn == null) if (page_fn) |pfn| {
if (slowPathRender(pfn, pagectx, route_path, arena, app_ptr, state_ptr, base_path)) |err| {
return .{ .page_error = err };
}
}
};
event_fn = registry.getEvent(route_path, handler_id);
if (registry.getEvent(route_path, payload.handler_id)) |event_fn| {
if (event_fn) |ef| {
var event_ctx = zx.server.Event{
.allocator = allocator,
.arena = arena,
._internal = .{ .payload = payload },
};
event_fn(&event_ctx);
ef(&event_ctx);
const body = if (event_ctx._internal.state_ctx) |sc| try serializeStateOutputs(sc, allocator) else null;
return .{ .ok = .{ .body = body } };
}
return .not_found;
} else return .not_found;
}