diff --git a/build.zig.zon b/build.zig.zon index acd7326..e087537 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -25,8 +25,9 @@ .hash = "jetkv-0.0.0-zCv0fmCGAgCyYqwHjk0P5KrYVRew1MJAtbtAcIO-WPpT", }, .zmpl = .{ - .url = "https://github.com/jetzig-framework/zmpl/archive/9c54edd62b47aabdcb0e5f17beb45637b9de6a33.tar.gz", - .hash = "zmpl-0.0.1-SYFGBl5sAwDu45IUKKH3TQRc4qZ8A1P2nNaU4iO3Zf-6", + // .url = "https://github.com/jetzig-framework/zmpl/archive/89ee0ce9b4c96c316cc0575266fb66c864f24a49.tar.gz", + // .hash = "zmpl-0.0.1-SYFGBtuNAwCj2YbqnoEJt3bk1iFIZjGK6JwMc72toZBR", + .path = "../zmpl", }, .httpz = .{ .url = "https://github.com/karlseguin/http.zig/archive/37d7cb9819b804ade5f4b974b82f8dd0622225ed.tar.gz", diff --git a/demo/src/app/views/layouts/application.zmpl b/demo/src/app/views/layouts/application.zmpl index c776ae2..09bb722 100644 --- a/demo/src/app/views/layouts/application.zmpl +++ b/demo/src/app/views/layouts/application.zmpl @@ -6,10 +6,12 @@ + {{context.middleware.renderHeader()}}
{{zmpl.content}}
+ {{context.middleware.renderFooter()}} diff --git a/demo/src/app/views/websockets.zig b/demo/src/app/views/websockets.zig index 04eced0..972efc8 100644 --- a/demo/src/app/views/websockets.zig +++ b/demo/src/app/views/websockets.zig @@ -3,6 +3,8 @@ const jetzig = @import("jetzig"); const Game = @import("../lib/Game.zig"); +pub const layout = "application"; + pub fn index(request: *jetzig.Request) !jetzig.View { return request.render(.ok); } diff --git a/demo/src/app/views/websockets/index.zmpl b/demo/src/app/views/websockets/index.zmpl index 738ce9d..6b12018 100644 --- a/demo/src/app/views/websockets/index.zmpl +++ b/demo/src/app/views/websockets/index.zmpl @@ -1,143 +1,3 @@ - -
🏆
@@ -155,7 +15,15 @@
@for (0..9) |index| { -
+
+
}
@@ -167,20 +35,20 @@ diff --git a/demo/src/main.zig b/demo/src/main.zig index 740d6db..48f463a 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -17,6 +17,7 @@ pub const jetzig_options = struct { // jetzig.middleware.AuthMiddleware, // jetzig.middleware.AntiCsrfMiddleware, jetzig.middleware.HtmxMiddleware, + jetzig.middleware.ChannelsMiddleware, // jetzig.middleware.InertiaMiddleware, // jetzig.middleware.CompressionMiddleware, // @import("app/middleware/DemoMiddleware.zig"), diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index 91c5e66..769e900 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -28,16 +28,16 @@ const AppOptions = struct { pub fn start(self: *const App, routes_module: type, options: AppOptions) !void { defer self.env.deinit(); - const action_router = comptime jetzig.channels.ActionRouter.initComptime(routes_module); - _ = action_router; - // inline for (action_router.actions) |action| std.debug.print("{s}\n", .{@typeName(action.params[0].type.?)}); - if (self.initHook) |hook| try hook(@constCast(self)); var mime_map = jetzig.http.mime.MimeMap.init(self.allocator); defer mime_map.deinit(); try mime_map.build(); + inline for (jetzig.http.middleware.middlewares) |middleware| { + if (@hasDecl(middleware, "setup")) try middleware.setup(@constCast(self)); + } + const routes = try createRoutes(self.allocator, if (@hasDecl(routes_module, "routes")) &routes_module.routes else diff --git a/src/jetzig/TemplateContext.zig b/src/jetzig/TemplateContext.zig index b413285..f76833b 100644 --- a/src/jetzig/TemplateContext.zig +++ b/src/jetzig/TemplateContext.zig @@ -9,6 +9,31 @@ pub const TemplateContext = @This(); request: ?*http.Request = null, route: ?views.Route = null, +middleware: Middleware = .{}, + +pub const Middleware = struct { + context: *TemplateContext = undefined, + + pub inline fn renderHeader(middleware: Middleware) ![]const u8 { + return try middleware.render("header"); + } + + pub inline fn renderFooter(middleware: Middleware) ![]const u8 { + return try middleware.render("footer"); + } + + fn render(middleware: Middleware, comptime section: []const u8) ![]const u8 { + var buf = std.ArrayList(u8).init(middleware.context.*.request.?.allocator); + const writer = buf.writer(); + inline for (http.middleware.middlewares) |middleware_type| { + if (@hasDecl(middleware_type, "Blocks") and @hasDecl(middleware_type.Blocks, section)) { + const renderFn = @field(middleware_type.Blocks, section); + try renderFn(middleware.context.*, writer); + } + } + return try buf.toOwnedSlice(); + } +}; /// Return an authenticity token stored in the current request's session. If no token exists, /// generate and store before returning. diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 52822b9..597f057 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -17,6 +17,7 @@ pub const RequestState = enum { after_request, // Initial middleware processing after_view, // View returned, response data ready for full response render rendered, // Rendered by middleware or view + rendered_content, // Rendered a plain string by middleware or view redirected, // Redirected by middleware or view failed, // Failed by middleware or view before_response, // Post middleware processing @@ -249,8 +250,8 @@ pub fn renderContent( ) jetzig.views.View { if (self.isRendered()) self.rendered_multiple = true; - self.state = .rendered; self.rendered_view = .{ .data = self.response_data, .status_code = status_code, .content = content }; + self.state = .rendered_content; return self.rendered_view.?; } @@ -267,7 +268,7 @@ pub fn fail(self: *Request, status_code: jetzig.http.status_codes.StatusCode) je pub inline fn isRendered(self: *const Request) bool { return switch (self.state) { .initial, .processed, .after_request, .before_response => false, - .after_view, .rendered, .redirected, .failed, .finalized => true, + .after_view, .rendered, .rendered_content, .redirected, .failed, .finalized => true, }; } @@ -335,6 +336,9 @@ pub fn renderRedirect(self: *Request, state: RedirectState) !void { var root = try self.response_data.root(.object); try root.put("location", self.response_data.string(state.location)); + var template_context = jetzig.TemplateContext{ .request = self }; + template_context.middleware.context = &template_context; + const content = switch (self.requestFormat()) { .HTML, .UNKNOWN => if (maybe_template) |template| blk: { try view.data.addConst("jetzig_view", view.data.string("internal")); @@ -342,7 +346,7 @@ pub fn renderRedirect(self: *Request, state: RedirectState) !void { break :blk try template.render( self.response_data, jetzig.TemplateContext, - .{ .request = self }, + template_context, &.{}, .{}, ); diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index d16a7c2..b1d04c4 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -208,9 +208,12 @@ pub fn RoutedServer(Routes: type) type { ) !bool { const route = self.matchChannelRoute(request.path.view_name) orelse return false; const session = try request.session(); - const session_id = session.getT(.string, "_id") orelse { - try self.logger.ERROR("Error fetching session ID for websocket, aborting.", .{}); - return false; + const session_id = session.getT(.string, "_id") orelse blk: { + try session.reset(); + break :blk session.getT(.string, "_id") orelse { + try self.logger.ERROR("Error fetching session ID for websocket, aborting.", .{}); + return false; + }; }; return try httpz.upgradeWebsocket( @@ -344,9 +347,7 @@ pub fn RoutedServer(Routes: type) type { return request.setResponse(rendered_error, .{}); }; - return if (request.state == .redirected or - request.state == .failed or - request.dynamic_assigned_template != null) + return if (request.isRendered() or request.dynamic_assigned_template != null) request.setResponse(rendered, .{}) else request.setResponse(try self.renderNotFound(request), .{}); @@ -429,6 +430,10 @@ pub fn RoutedServer(Routes: type) type { if (request.rendered_view) |rendered_view| { if (request.state == .redirected) return .{ .view = rendered_view, .content = "" }; + if (request.state == .rendered_content) return .{ + .view = rendered_view, + .content = rendered_view.content.?, + }; if (template) |capture| { return .{ @@ -471,7 +476,8 @@ pub fn RoutedServer(Routes: type) type { ) ![]const u8 { try addTemplateConstants(view, route); - const template_context = jetzig.TemplateContext{ .request = request }; + var template_context = jetzig.TemplateContext{ .request = request }; + template_context.middleware.context = &template_context; if (request.getLayout(route)) |layout_name| { // TODO: Allow user to configure layouts directory other than src/app/views/layouts/ @@ -487,6 +493,7 @@ pub fn RoutedServer(Routes: type) type { view.data, jetzig.TemplateContext, template_context, + &.{}, .{ .layout = layout }, ); } else { @@ -495,6 +502,7 @@ pub fn RoutedServer(Routes: type) type { view.data, jetzig.TemplateContext, template_context, + &.{}, .{}, ); } @@ -666,6 +674,7 @@ pub fn RoutedServer(Routes: type) type { request.response_data, jetzig.TemplateContext, .{ .request = request }, + &.{}, .{}, ), }; diff --git a/src/jetzig/middleware.zig b/src/jetzig/middleware.zig index 0da095c..83eb799 100644 --- a/src/jetzig/middleware.zig +++ b/src/jetzig/middleware.zig @@ -6,6 +6,7 @@ pub const CompressionMiddleware = @import("middleware/CompressionMiddleware.zig" pub const AuthMiddleware = @import("middleware/AuthMiddleware.zig"); pub const AntiCsrfMiddleware = @import("middleware/AntiCsrfMiddleware.zig"); pub const InertiaMiddleware = @import("middleware/InertiaMiddleware.zig"); +pub const ChannelsMiddleware = @import("middleware/ChannelsMiddleware.zig"); const RouteOptions = struct { content: ?[]const u8 = null, diff --git a/src/jetzig/middleware/ChannelsMiddleware.zig b/src/jetzig/middleware/ChannelsMiddleware.zig new file mode 100644 index 0000000..86663aa --- /dev/null +++ b/src/jetzig/middleware/ChannelsMiddleware.zig @@ -0,0 +1,35 @@ +const std = @import("std"); +const jetzig = @import("../../jetzig.zig"); + +const ChannelsMiddleware = @This(); + +pub fn setup(app: *jetzig.App) !void { + app.route(.GET, "/_channels.js", ChannelsMiddleware, .renderChannels); +} + +pub const Blocks = struct { + pub fn header(_: jetzig.TemplateContext, writer: anytype) !void { + try writer.writeAll( + \\ + ); + } + + pub fn footer(context: jetzig.TemplateContext, writer: anytype) !void { + const request = context.request orelse return; + const host = request.headers.getLower("host") orelse return; + try writer.print( + \\ + \\ + , .{ host, request.path.base_path }); + } +}; + +pub fn renderChannels(request: *jetzig.Request) !jetzig.View { + return request.renderContent(.ok, @embedFile("channels/channels.js")); +} diff --git a/src/jetzig/middleware/HtmxMiddleware.zig b/src/jetzig/middleware/HtmxMiddleware.zig index 06d7ffe..9346430 100644 --- a/src/jetzig/middleware/HtmxMiddleware.zig +++ b/src/jetzig/middleware/HtmxMiddleware.zig @@ -9,7 +9,7 @@ const HtmxMiddleware = @This(); /// content rendered directly by the view function. pub fn afterRequest(request: *jetzig.http.Request) !void { if (request.headers.get("HX-Request")) |_| { - try request.server.logger.DEBUG( + try request.logger.DEBUG( "[middleware-htmx] HX-Request header, disabling layout.", .{}, ); diff --git a/src/jetzig/middleware/channels/channels.js b/src/jetzig/middleware/channels/channels.js new file mode 100644 index 0000000..bada58b --- /dev/null +++ b/src/jetzig/middleware/channels/channels.js @@ -0,0 +1,145 @@ +window.jetzig = window.jetzig ? window.jetzig : {} +jetzig = window.jetzig; + +(() => { + const transform = (value, state, element) => { + const id = element.getAttribute('jetzig-id'); + const key = id && jetzig.channel.transformerMap[id]; + const transformers = key && jetzig.channel.transformers[key]; + if (transformers) { + return transformers.reduce((acc, val) => val(acc), value); + } else { + return value === undefined || value == null ? '' : `${value}` + } + }; + + jetzig.channel = { + websocket: null, + actions: {}, + stateChangedCallbacks: [], + messageCallbacks: [], + invokeCallbacks: {}, + elementMap: {}, + transformerMap: {}, + transformers: {}, + 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__:"; + const actions_tag = "__jetzig_actions__:"; + const event_tag = "__jetzig_event__:"; + + if (event.data.startsWith(state_tag)) { + const state = JSON.parse(event.data.slice(state_tag.length)); + Object.entries(this.elementMap).forEach(([ref, elements]) => { + const value = reduceState(ref, state); + elements.forEach(element => element.innerHTML = transform(value, state, element)); + }); + this.stateChangedCallbacks.forEach((callback) => { + callback(state); + }); + } else if (event.data.startsWith(event_tag)) { + const data = JSON.parse(event.data.slice(event_tag.length)); + if (Object.hasOwn(this.invokeCallbacks, data.method)) { + this.invokeCallbacks[data.method].forEach(callback => { + callback(data); + }); + } + } 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] = { + callback: (...params) => { + if (action.params.length != params.length) { + throw new Error(`Invalid params for action '${action.name}'`); + } + [...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]}`); + } + }); + + this.websocket.send(`_invoke:${action.name}:${JSON.stringify(params)}`); + }, + spec: { ...action }, + }; + }); + console.log(this.actions); + } else { + const data = JSON.parse(event.data); + this.messageCallbacks.forEach((callback) => { + callback(data); + }); + } + + }); + + const reduceState = (ref, state) => { + if (!ref.startsWith('$.')) throw new Error(`Unexpected ref format: ${ref}`); + const args = ref.split('.'); + args.shift(); + const isNumeric = (string) => [...string].every(char => '0123456789'.includes(char)); + const isObject = (object) => object && typeof object === 'object'; + return args.reduce((acc, arg) => { + if (isNumeric(arg)) { + if (acc && Array.isArray(acc) && acc.length > arg) return acc[parseInt(arg)]; + return null; + } else { + if (acc && isObject(acc)) return acc[arg]; + return null; + } + }, state); + }; + + document.querySelectorAll('[jetzig-connect]').forEach(element => { + const ref = element.getAttribute('jetzig-connect'); + if (!this.elementMap[ref]) this.elementMap[ref] = []; + const id = `jetzig-${crypto.randomUUID()}`; + element.setAttribute('jetzig-id', id); + 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 + // this.publish("websockets", {}); + // }); + }, + transform: function(ref, callback) { + if (Object.hasOwn(this.transformers, ref)) { + this.transformers[ref].push(callback); + } else { + this.transformers[ref] = [callback]; + } + }, + receive: function(ref, callback) { + if (Object.hasOwn(this.invokeCallbacks, ref)) { + this.invokeCallbacks[ref].push(callback); + } else { + this.invokeCallbacks[ref] = [callback]; + } + }, + publish: function(data) { + if (this.websocket) { + const json = JSON.stringify(data); + this.websocket.send(json); + } + }, + }; +})(); diff --git a/src/jetzig/templates/channels.zmpl b/src/jetzig/templates/channels.zmpl new file mode 100644 index 0000000..b6eb3ac --- /dev/null +++ b/src/jetzig/templates/channels.zmpl @@ -0,0 +1,3 @@ +@block head { + +} diff --git a/src/jetzig/views/Route.zig b/src/jetzig/views/Route.zig index c60b79d..9090dfc 100644 --- a/src/jetzig/views/Route.zig +++ b/src/jetzig/views/Route.zig @@ -92,6 +92,8 @@ pub fn format(self: Route, _: []const u8, _: anytype, writer: anytype) !void { pub fn match(self: Route, request: *const jetzig.http.Request) bool { if (self.method != request.method) return false; + if (std.mem.eql(u8, request.path.file_path, self.uri_path)) return true; + var request_path_it = std.mem.splitScalar(u8, request.path.base_path, '/'); var uri_path_it = std.mem.splitScalar(u8, self.uri_path, '/');