diff --git a/demo/src/app/views/websockets/index.zmpl b/demo/src/app/views/websockets/index.zmpl index 6b12018..4cccd35 100644 --- a/demo/src/app/views/websockets/index.zmpl +++ b/demo/src/app/views/websockets/index.zmpl @@ -27,7 +27,7 @@ } - +
@@ -70,14 +70,4 @@ jetzig.channel.transform("cell", (value) => ( { player: "✈️", cpu: "🦎" }[value] || "" )); - document.querySelectorAll("#board div.cell").forEach(element => { - element.addEventListener("click", () => { - jetzig.channel.actions.move(parseInt(element.dataset.cell)); - }); - }); - - document.querySelector("#reset-button").addEventListener("click", () => { - jetzig.channel.actions.reset(); - }); - diff --git a/src/Routes.zig b/src/Routes.zig index c561eee..e18b3df 100644 --- a/src/Routes.zig +++ b/src/Routes.zig @@ -12,6 +12,7 @@ buffer: std.ArrayList(u8), dynamic_routes: std.ArrayList(Function), static_routes: std.ArrayList(Function), channel_routes: std.ArrayList([]const u8), +channel_actions: std.StringHashMap(std.StringHashMap([]const []const u8)), module_paths: std.StringHashMap(void), data: *jetzig.data.Data, @@ -124,6 +125,7 @@ pub fn init( .static_routes = std.ArrayList(Function).init(allocator), .dynamic_routes = std.ArrayList(Function).init(allocator), .channel_routes = std.ArrayList([]const u8).init(allocator), + .channel_actions = std.StringHashMap(std.StringHashMap([]const []const u8)).init(allocator), .module_paths = std.StringHashMap(void).init(allocator), .data = data, }; @@ -163,6 +165,7 @@ pub fn generateRoutes(self: *Routes) ![]const u8 { \\}); \\ ); + try writer.writeAll( \\ \\pub const mailers = [_]jetzig.MailerDefinition{ @@ -410,10 +413,35 @@ fn writeChannelRoutes(self: *Routes, writer: anytype) !void { defer self.allocator.free(relative_path); const view_name = chompExtension(relative_path); + var actions_buf = std.ArrayList(u8).init(self.allocator); + const actions_writer = actions_buf.writer(); + if (self.channel_actions.get(path)) |actions| { + var it = actions.iterator(); + while (it.next()) |entry| { + try actions_writer.print( + \\.{{ .name = "{s}", .params = &.{{ + , .{entry.key_ptr.*}); + for (entry.value_ptr.*, 0..) |param, index| { + if (index == 0) continue; // Skip `self` argument. + try actions_writer.print( + \\.{{ .name = "{s}" }}, + , .{param}); + } + try actions_writer.writeAll("},},"); + } + } + try writer.print( - \\.{{ "{0s}", jetzig.channels.Route.initComptime(@import("{1s}"), "{0s}") }} + \\.{{ + \\ "{0s}", + \\ jetzig.channels.Route.initComptime( + \\ @import("{1s}"), + \\ "{0s}", + \\ &.{{{2s}}} + \\ ), + \\}} \\ - , .{ view_name, module_path }); + , .{ view_name, module_path, actions_buf.items }); } } @@ -496,6 +524,7 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout const decl_name = self.ast.tokenSlice(container_token - 2); if (std.mem.eql(u8, decl_name, "Channel")) { try channel_routes.append(path); + try self.parseChannel(container, path); } }, else => {}, @@ -527,6 +556,83 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout }; } +// Although we mostly evaluate channel routes at comptime, we need to parse the function +// signatures in `Actions` to get argument names (Zig only reflects the types). +fn parseChannel(self: *Routes, channel: std.zig.Ast.full.ContainerDecl, path: []const u8) !void { + for (channel.ast.members) |member| { + const tag = self.ast.nodeTag(member); + switch (tag) { + .simple_var_decl => { + const var_decl = self.ast.simpleVarDecl(member); + const var_name = self.ast.tokenSlice(self.ast.nodeMainToken(member) + 1); + if (std.mem.eql(u8, var_name, "Actions")) { + const init_node = var_decl.ast.init_node.unwrap() orelse continue; + switch (self.ast.nodeTag(init_node)) { + .container_decl_two, + .container_decl_two_trailing, + .container_decl, + .container_decl_trailing, + => |container_tag| { + var buf: [2]std.zig.Ast.Node.Index = undefined; + const container = switch (container_tag) { + .container_decl_two, + .container_decl_two_trailing, + => self.ast.containerDeclTwo(&buf, init_node), + .container_decl, + .container_decl_trailing, + => self.ast.containerDecl(init_node), + else => unreachable, + }; + const container_token = container.ast.main_token; + const decl_name = self.ast.tokenSlice(container_token - 2); + if (std.mem.eql(u8, decl_name, "Actions")) { + try self.parseChannelActions(container, path); + } + }, + else => continue, + } + } + }, + else => {}, + } + } +} + +fn parseChannelActions(self: *Routes, actions: std.zig.Ast.full.ContainerDecl, path: []const u8) !void { + for (actions.ast.members) |member| { + const tag = self.ast.nodes.items(.tag)[@intFromEnum(member)]; + switch (tag) { + .fn_proto, + .fn_proto_multi, + .fn_proto_one, + .fn_proto_simple, + .fn_decl, + => { + var buf: [1]std.zig.Ast.Node.Index = undefined; + const func = self.ast.fullFnProto(&buf, member).?; + const visib_token = func.visib_token orelse continue; + if (!std.mem.eql(u8, self.ast.tokenSlice(visib_token), "pub")) continue; + const func_name_token = func.name_token orelse continue; + const func_name = self.ast.tokenSlice(func_name_token); + + var params_buf = std.ArrayList([]const u8).init(self.allocator); + var params_it = func.iterate(&self.ast); + while (params_it.next()) |param| { + try params_buf.append( + try self.allocator.dupe(u8, self.ast.tokenSlice(param.name_token.?)), + ); + } + const result = try self.channel_actions.getOrPut(path); + if (!result.found_existing) { + result.value_ptr.* = std.StringHashMap([]const []const u8).init(self.allocator); + } + try result.value_ptr.put(try self.allocator.dupe(u8, func_name), try params_buf.toOwnedSlice()); + }, + else => {}, + } + } +} + // Parse the `pub const static_params` definition and into a `jetzig.data.Value`. fn parseStaticParamsDecl(self: *Routes, decl: std.zig.Ast.full.VarDecl, params: *jetzig.data.Value) !void { const init_node = decl.ast.init_node.unwrap() orelse return; diff --git a/src/jetzig/channels/ActionRouter.zig b/src/jetzig/channels/ActionRouter.zig index bfc7b16..074fbc6 100644 --- a/src/jetzig/channels/ActionRouter.zig +++ b/src/jetzig/channels/ActionRouter.zig @@ -148,7 +148,12 @@ fn encodeParams(Routes: type) !std.StaticStringMap([]const u8) { actions: []ActionSpec, pub const ActionSpec = struct { name: []const u8, - params: []const u8, + params: []const ParamSpec, + + pub const ParamSpec = struct { + type: []const u8, + name: []const u8, + }; }; }; const Tuple = std.meta.Tuple(&.{ []const u8, []const u8 }); @@ -168,10 +173,17 @@ fn encodeParams(Routes: type) !std.StaticStringMap([]const u8) { switch (@typeInfo(@TypeOf(@field(view.module.Channel.Actions, decl.name)))) { .@"fn" => |info| { verifyParams(info.params, view.name, decl.name); + const route = Routes.channel_routes.get(view.name).?; + const action = for (route.actions) |action| { + if (std.mem.eql(u8, action.name, decl.name)) break action; + } else unreachable; if (info.params.len > 1) { - var params: [info.params.len - 1]u8 = undefined; + var params: [info.params.len - 1]Spec.ActionSpec.ParamSpec = undefined; for (info.params[1..], 0..) |param, param_index| { - params[param_index] = jsonTypeName(param.type.?); + params[param_index] = .{ + .type = jsonTypeName(param.type.?), + .name = action.params[param_index].name, + }; } actions[decl_index] = .{ .name = decl.name, .params = ¶ms }; } else { @@ -210,13 +222,13 @@ fn verifyParams( if (params[0].type.? != jetzig.channels.Channel) @compileError(missing_param); } -fn jsonTypeName(T: type) u8 { +fn jsonTypeName(T: type) []const u8 { return switch (T) { - []const u8 => 's', + []const u8 => "string", else => switch (@typeInfo(T)) { - .float, .comptime_float => 'f', - .bool => 'b', - .int, .comptime_int => 'i', + .float, .comptime_float => "float", + .int, .comptime_int => "integer", + .bool => "bool", else => @compileError("Unsupported Channel Action argument type: " ++ @typeName(T)), }, }; diff --git a/src/jetzig/channels/Route.zig b/src/jetzig/channels/Route.zig index c6cd34f..6acf303 100644 --- a/src/jetzig/channels/Route.zig +++ b/src/jetzig/channels/Route.zig @@ -5,12 +5,22 @@ const Route = @This(); receiveMessageFn: ?*const fn (jetzig.channels.Message) anyerror!void = null, openConnectionFn: ?*const fn (jetzig.channels.Channel) anyerror!void = null, path: []const u8, +actions: []const Action, + +pub const Action = struct { + name: []const u8, + params: []const Param, + + pub const Param = struct { + name: []const u8, + }; +}; pub fn receiveMessage(route: Route, message: jetzig.channels.Message) !void { if (route.receiveMessageFn) |func| try func(message); } -pub fn initComptime(T: type, path: []const u8) Route { +pub fn initComptime(T: type, path: []const u8, actions: []const Action) Route { comptime { if (!@hasDecl(T, "Channel")) return .{}; const openConnectionFn = if (@hasDecl(T.Channel, "open")) T.Channel.open else null; @@ -20,6 +30,7 @@ pub fn initComptime(T: type, path: []const u8) Route { .openConnectionFn = openConnectionFn, .receiveMessageFn = receiveMessageFn, .path = path, + .actions = actions, }; } } diff --git a/src/jetzig/middleware/channels/channels.js b/src/jetzig/middleware/channels/channels.js index bada58b..af90710 100644 --- a/src/jetzig/middleware/channels/channels.js +++ b/src/jetzig/middleware/channels/channels.js @@ -16,6 +16,7 @@ jetzig = window.jetzig; jetzig.channel = { websocket: null, actions: {}, + action_specs: {}, stateChangedCallbacks: [], messageCallbacks: [], invokeCallbacks: {}, @@ -25,7 +26,6 @@ jetzig = window.jetzig; onStateChanged: function(callback) { this.stateChangedCallbacks.push(callback); }, onMessage: function(callback) { this.messageCallbacks.push(callback); }, init: function(host, path) { - console.log("here"); this.websocket = new WebSocket(`ws://${host}${path}`); this.websocket.addEventListener("message", (event) => { const state_tag = "__jetzig_channel_state__:"; @@ -51,20 +51,33 @@ jetzig = window.jetzig; } else if (event.data.startsWith(actions_tag)) { const data = JSON.parse(event.data.slice(actions_tag.length)); data.actions.forEach(action => { - this.actions[action.name] = { + this.action_specs[action.name] = { callback: (...params) => { if (action.params.length != params.length) { - throw new Error(`Invalid params for action '${action.name}'`); + throw new Error(`Invalid params for action '${action.name}'. Expected ${action.params.length} params, found ${params.length}`); } [...action.params].forEach((param, index) => { - const map = { - s: "string", - b: "boolean", - i: "number", - f: "number", - }; - if (map[param] !== typeof params[index]) { - throw new Error(`Incorrect argument type for argument ${index} in '${action.name}'. Expected: ${map[param]}, found ${typeof params[index]}`); + if (param.type !== typeof params[index]) { + const err = `Incorrect argument type for argument ${index} in '${action.name}'. Expected: ${param.type}, found ${typeof params[index]}`; + switch (param.type) { + case "string": + params[index] = `${params[index]}`; + break; + case "integer": + try { params[index] = parseInt(params[index]) } catch { + throw new Error(err); + }; + break; + case "float": + try { params[index] = parseFloat(params[index]) } catch { + throw new Error(err); + }; + case "boolean": + params[index] = ["true", "y", "1", "yes", "t"].includes(params[index]); + break; + default: + throw new Error(err); + } } }); @@ -72,8 +85,28 @@ jetzig = window.jetzig; }, spec: { ...action }, }; + this.actions[action.name] = this.action_specs[action.name].callback; + }); + document.querySelectorAll('[jetzig-click]').forEach(element => { + const ref = element.getAttribute('jetzig-click'); + const action = this.action_specs[ref]; + if (action) { + element.addEventListener('click', () => { + const args = []; + action.spec.params.forEach(param => { + const arg = element.dataset[param.name]; + if (arg === undefined) { + throw new Error(`Expected 'data-${param.name}' attribute for '${action.name}' click handler.`); + } else { + args.push(element.dataset[param.name]); + } + }); + action.callback(...args); + }); + } else { + throw new Error(`Unknown click handler: '${ref}'`); + } }); - console.log(this.actions); } else { const data = JSON.parse(event.data); this.messageCallbacks.forEach((callback) => { @@ -108,13 +141,6 @@ jetzig = window.jetzig; this.elementMap[ref].push(element); this.transformerMap[id] = element.getAttribute('jetzig-transform'); }); - document.querySelectorAll('[jetzig-click]').forEach(element => { - const ref = element.getAttribute('jetzig-click'); - const action = this.actions[ref]; - if (action) { - - } - }); // this.websocket.addEventListener("open", (event) => { // // TODO