Compare commits

..

13 Commits

Author SHA1 Message Date
Bob Farrell
74bb659528 WIP 2025-04-28 20:03:02 +01:00
Bob Farrell
94f67dd4a2 WIP 2025-04-27 22:16:12 +01:00
Bob Farrell
3bd296821c WIP 2025-04-27 16:27:59 +01:00
Bob Farrell
ff6d8cf942 Merge branch 'inertia' into websockets 2025-04-27 14:31:11 +01:00
Bob Farrell
6a4f99ca14 Inertia - WIP
Not yet complete but provides some functionality with internal templates
that Websockets/Channels needs so merging in to main and can pick up
later.
2025-04-27 13:00:19 +01:00
Bob Farrell
fa76b75c12 WIP 2025-04-25 21:41:37 +01:00
Bob Farrell
58403986d7 JS RPC 2025-04-23 20:26:28 +01:00
Bob Farrell
e9802bf546 WIP 2025-04-23 19:11:02 +01:00
Bob Farrell
8c2d6806b5 Refactor Server into generic type
We need to have routes available within the server if we are going to do
any kind of dynamic dispatch for Channel Actions.
2025-04-23 12:58:41 +01:00
Bob Farrell
d3b3ae63cf WIP 2025-04-22 19:42:20 +01:00
Bob Farrell
9847efdf4a WIP 2025-04-21 19:50:31 +01:00
Bob Farrell
cd5a00d85f WIP 2025-04-21 15:55:12 +01:00
Bob Farrell
2072b59937 WIP 2025-04-21 15:55:12 +01:00
47 changed files with 2592 additions and 882 deletions

View File

@ -12,11 +12,22 @@ const use_llvm_default = builtin.os.tag != .linux;
pub fn build(b: *std.Build) !void { pub fn build(b: *std.Build) !void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
const optimize = b.standardOptimizeOption(.{}); const optimize = b.standardOptimizeOption(.{});
var jetzig_templates_path = std.ArrayList([]const u8).init(b.allocator);
try jetzig_templates_path.append("/");
var it = std.mem.splitSequence(
u8,
try b.path("src/jetzig/templates").getPath3(b, null).toString(b.allocator),
std.fs.path.sep_str,
);
while (it.next()) |segment| {
try jetzig_templates_path.append(segment);
}
const templates_paths = try zmpl_build.templatesPaths( const templates_paths = try zmpl_build.templatesPaths(
b.allocator, b.allocator,
&.{ &.{
.{ .prefix = "views", .path = &.{ "src", "app", "views" } }, .{ .prefix = "views", .path = &.{ "src", "app", "views" } },
.{ .prefix = "mailers", .path = &.{ "src", "app", "mailers" } }, .{ .prefix = "mailers", .path = &.{ "src", "app", "mailers" } },
.{ .prefix = "jetzig", .path = jetzig_templates_path.items },
}, },
); );

View File

@ -25,8 +25,9 @@
.hash = "jetkv-0.0.0-zCv0fmCGAgCyYqwHjk0P5KrYVRew1MJAtbtAcIO-WPpT", .hash = "jetkv-0.0.0-zCv0fmCGAgCyYqwHjk0P5KrYVRew1MJAtbtAcIO-WPpT",
}, },
.zmpl = .{ .zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/c57fc9b83027e8c1459d9625c3509f59f0fb89f3.tar.gz", // .url = "https://github.com/jetzig-framework/zmpl/archive/89ee0ce9b4c96c316cc0575266fb66c864f24a49.tar.gz",
.hash = "zmpl-0.0.1-SYFGBgdqAwDeA6xm4KAhpKoNrWs5CMQK6x447zhWclCs", // .hash = "zmpl-0.0.1-SYFGBtuNAwCj2YbqnoEJt3bk1iFIZjGK6JwMc72toZBR",
.path = "../zmpl",
}, },
.httpz = .{ .httpz = .{
.url = "https://github.com/karlseguin/http.zig/archive/37d7cb9819b804ade5f4b974b82f8dd0622225ed.tar.gz", .url = "https://github.com/karlseguin/http.zig/archive/37d7cb9819b804ade5f4b974b82f8dd0622225ed.tar.gz",

139
demo/public/party.css Normal file
View File

@ -0,0 +1,139 @@
body {
font-family: Arial, sans-serif;
display: flex;
flex-direction: column;
align-items: center;
background-color: #f0f0f0;
overflow-x: hidden;
position: relative;
padding-top: 5rem;
}
#board {
display: grid;
grid-template-columns: repeat(3, 100px);
grid-gap: 5px;
margin: 20px;
}
.cell {
width: 100px;
height: 100px;
background: white;
border: 2px solid #333;
display: flex;
align-items: center;
justify-content: center;
font-size: 40px;
cursor: pointer;
}
.cell:hover {
background: #e0e0e0;
}
#status {
font-size: 24px;
margin-bottom: 20px;
}
#reset-button {
padding: 10px 20px;
font-size: 16px;
cursor: pointer;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 5px;
display: inline;
}
#reset-button:hover {
background-color: #45a049;
}
#reset-wrapper {
text-align: center;
}
#party-container {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.animal {
position: absolute;
font-size: 50px;
opacity: 0;
}
.run-dog {
animation: runAcross 2s linear forwards;
}
.run-cat {
animation: runAcross 2s linear forwards;
}
.run-lizard {
animation: runAcross 2s linear forwards;
}
.run-jet {
animation: runAcross 2s linear forwards;
}
@keyframes runAcross {
0% {
left: -100px;
opacity: 1;
}
100% {
left: 100%;
opacity: 1;
}
}
.confetti {
position: absolute;
font-size: 30px;
opacity: 0.8;
}
.fall {
animation: fall 3s linear forwards;
}
@keyframes fall {
0% {
top: -50px;
opacity: 1;
transform: rotate(0deg);
}
100% {
top: 100%;
opacity: 0.5;
transform: rotate(360deg);
}
}
#results {
display: grid;
grid-template-columns: repeat(2, 5rem);
text-align: center;
font-weight: bold;
font-family: monospace;
}
#results-wrapper {
display: flex;
justify-content: space-between;
}
.trophy {
font-size: 5rem;
}
#victor {
margin: 3rem;
font-size: 3.5rem;
text-align: center;
vertical-align: center;
}
#grid {
transition: background-color 0.5s ease;
}
.flash-animation {
animation: flashRed 0.5s ease;
}
@keyframes flashRed {
0% { background-color: #e55; }
100% { background-color: white; }
}

47
demo/public/party.js Normal file
View File

@ -0,0 +1,47 @@
function triggerPartyAnimation() {
const container = document.getElementById('party-container');
container.innerHTML = ''; // Clear previous animations
// Define entities
const entities = [
{ type: 'dog', emoji: '🐶' },
{ type: 'cat', emoji: '🐱' },
{ type: 'lizard', emoji: '🦎' },
{ type: 'jet', emoji: '✈' }
];
// Create random number of each entity (2-5 per type)
entities.forEach(entity => {
const count = Math.floor(Math.random() * 4) + 2; // Random 2-5
for (let i = 0; i < count; i++) {
const div = document.createElement('div');
div.className = 'animal';
div.innerHTML = entity.emoji;
// Random vertical position (between 20% and 80% of screen height)
div.style.top = `${20 + Math.random() * 60}%`;
// Random delay (0 to 1.5s)
div.style.animationDelay = `${Math.random() * 1.5}s`;
container.appendChild(div);
// Trigger animation
setTimeout(() => {
div.classList.add(`run-${entity.type}`);
}, 10);
}
});
// Create confetti (20 pieces)
for (let i = 0; i < 20; i++) {
const div = document.createElement('div');
div.className = 'confetti';
div.innerHTML = '&#127881;';
// Random horizontal position
div.style.left = `${Math.random() * 100}%`;
// Random delay (0 to 2s)
div.style.animationDelay = `${Math.random() * 2}s`;
container.appendChild(div);
// Trigger fall animation
setTimeout(() => {
div.classList.add('fall');
}, 10);
}
}

83
demo/src/app/lib/Game.zig Normal file
View File

@ -0,0 +1,83 @@
const std = @import("std");
const jetzig = @import("jetzig");
grid: Grid,
victor: ?State = null,
pub const Grid = [9]State;
pub const State = enum { empty, player, cpu, tie };
const Game = @This();
pub fn gridFromValues(values: []*jetzig.data.Value) Grid {
var grid: [9]Game.State = undefined;
for (0..9) |id| {
if (values[id].* != .null) {
grid[id] = if (values[id].eql("player")) .player else .cpu;
} else {
grid[id] = .empty;
}
}
return grid;
}
pub fn movePlayer(game: *Game, cell: usize) bool {
if (cell >= game.grid.len) return false;
if (game.grid[cell] != .empty) return false;
game.grid[cell] = .player;
game.evaluate();
return true;
}
pub fn moveCpu(game: *Game) usize {
std.debug.assert(game.victor == null);
var available: [9]usize = undefined;
var available_len: usize = 0;
for (game.grid, 0..) |cell, cell_index| {
if (cell == .empty) {
available[available_len] = cell_index;
available_len += 1;
}
}
std.debug.assert(available_len > 0);
const choice = available[std.crypto.random.intRangeAtMost(usize, 0, available_len - 1)];
game.grid[choice] = .cpu;
game.evaluate();
return choice;
}
pub fn evaluate(game: *Game) void {
var full = true;
for (game.grid) |cell| {
if (cell == .empty) full = false;
}
if (full) {
game.victor = .tie;
return;
}
const patterns = [_][3]usize{
.{ 0, 1, 2 },
.{ 3, 4, 5 },
.{ 6, 7, 8 },
.{ 0, 3, 6 },
.{ 1, 4, 7 },
.{ 2, 5, 8 },
.{ 0, 4, 8 },
.{ 2, 4, 6 },
};
for (patterns) |pattern| {
var cpu_victor = true;
var player_victor = true;
for (pattern) |cell_index| {
if (game.grid[cell_index] != .cpu) cpu_victor = false;
if (game.grid[cell_index] != .player) player_victor = false;
}
std.debug.assert(!(cpu_victor and player_victor));
if (cpu_victor) game.victor = .cpu;
if (player_victor) game.victor = .player;
}
}

View File

@ -0,0 +1 @@
<title>My Inertia App</title>

View File

@ -6,10 +6,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<script src="https://cdn.tailwindcss.com"></script> <script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/prism.css" /> <link rel="stylesheet" href="/prism.css" />
{{context.middleware.renderHeader()}}
</head> </head>
<body> <body>
<main>{{zmpl.content}}</main> <main>{{zmpl.content}}</main>
<script src="/prism.js"></script> <script src="/prism.js"></script>
{{context.middleware.renderFooter()}}
</body> </body>
</html> </html>

View File

@ -16,7 +16,7 @@ pub fn index(request: *jetzig.Request, data: *jetzig.Data) !jetzig.View {
} }
pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View { pub fn edit(id: []const u8, request: *jetzig.Request) !jetzig.View {
try request.server.logger.INFO("id: {s}", .{id}); try request.logger.INFO("id: {s}", .{id});
return request.render(.ok); return request.render(.ok);
} }

View File

@ -0,0 +1,77 @@
const std = @import("std");
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);
}
pub const Channel = struct {
pub fn open(channel: jetzig.channels.Channel) !void {
if (channel.get("cells") == null) try initGame(channel);
try channel.sync();
}
pub const Actions = struct {
pub fn move(channel: jetzig.channels.Channel, cell: usize) !void {
const cells = channel.getT(.array, "cells") orelse return;
const grid = Game.gridFromValues(cells.items());
var game = Game{ .grid = grid };
game.evaluate();
if (game.victor != null) {
try channel.invoke(.game_over, .{});
return;
} else {
try movePlayer(channel, &game, cells, cell);
try channel.sync();
}
}
pub fn reset(channel: jetzig.channels.Channel) !void {
try resetGame(channel);
try channel.sync();
}
};
fn resetGame(channel: jetzig.channels.Channel) !void {
try channel.put("victor", null);
var cells = try channel.put("cells", .array);
for (0..9) |_| try cells.append(null);
}
fn initGame(channel: jetzig.channels.Channel) !void {
var results = try channel.put("results", .object);
try results.put("cpu", 0);
try results.put("player", 0);
try results.put("tie", 0);
try resetGame(channel);
}
fn movePlayer(
channel: jetzig.channels.Channel,
game: *Game,
cells: *const jetzig.data.Array,
cell: usize,
) !void {
const values = cells.items();
if (game.movePlayer(cell)) {
values[cell] = channel.data.string("player");
if (game.victor == null) {
values[game.moveCpu()] = channel.data.string("cpu");
}
if (game.victor) |victor| try setVictor(channel, victor);
}
}
fn setVictor(channel: jetzig.channels.Channel, victor: Game.State) !void {
try channel.put("victor", @tagName(victor));
var results = channel.getT(.object, "results") orelse return;
const count = results.getT(.integer, @tagName(victor)) orelse return;
try results.put(@tagName(victor), count + 1);
try channel.invoke(.victor, .{ .type = @tagName(victor) });
}
};

View File

@ -0,0 +1,64 @@
<link rel="stylesheet" href="/party.css" />
<div id="party-container"></div>
<div id="results-wrapper">
<span class="trophy">&#127942;</span>
<div id="results">
<div>Player</div>
<div id="player-wins" jetzig-connect="$.results.player"></div>
<div>CPU</div>
<div id="cpu-wins" jetzig-connect="$.results.cpu"></div>
<div>Tie</div>
<div id="ties" jetzig-connect="$.results.tie"></div>
</div>
<span class="trophy">&#127942;</span>
</div>
<div class="board" id="board">
@for (0..9) |index| {
<div
class="cell"
jetzig-connect="$.cells.{{index}}"
jetzig-transform="{ player: '&#9992;&#65039;', cpu: '&#129422;', tie: '&#129309;' }[value] || ''"
jetzig-click="move"
id="tic-tac-toe-cell-{{index}}"
data-cell="{{index}}"
>
</div>
}
</div>
<div id="reset-wrapper">
<button jetzig-click="reset" id="reset-button">Reset Game</button>
</div>
<div jetzig-style="{ visibility: $.victor === null ? 'hidden' : 'visible' }" id="victor">
<span>&#127942;</span>
<span
jetzig-connect="$.victor"
jetzig-transform="{ player: '&#9992;&#65039;', cpu: '&#129422;', tie: '&#129309;' }[value] || ''"
></span>
<span>&#127942;</span>
</div>
<script>
@// jetzig.channel.onStateChanged(state => {
@// });
@//
@// jetzig.channel.onMessage(data => {
@// });
@//
@// jetzig.channel.receive("victor", data => {
@// triggerPartyAnimation();
@// });
@//
@// jetzig.channel.receive("game_over", data => {
@// const element = document.querySelector("#board");
@// element.classList.remove('flash-animation');
@// void element.offsetWidth;
@// element.classList.add('flash-animation');
@// });
@//
</script>

View File

@ -7,6 +7,8 @@ const zmd = @import("zmd");
pub const routes = @import("routes"); pub const routes = @import("routes");
pub const static = @import("static"); pub const static = @import("static");
pub const std_options = jetzig.std_options;
// Override default settings in `jetzig.config` here: // Override default settings in `jetzig.config` here:
pub const jetzig_options = struct { pub const jetzig_options = struct {
/// Middleware chain. Add any custom middleware here, or use middleware provided in /// Middleware chain. Add any custom middleware here, or use middleware provided in
@ -14,9 +16,11 @@ pub const jetzig_options = struct {
pub const middleware: []const type = &.{ pub const middleware: []const type = &.{
// jetzig.middleware.AuthMiddleware, // jetzig.middleware.AuthMiddleware,
// jetzig.middleware.AntiCsrfMiddleware, // jetzig.middleware.AntiCsrfMiddleware,
// jetzig.middleware.HtmxMiddleware, jetzig.middleware.HtmxMiddleware,
// jetzig.middleware.CompressionMiddleware, jetzig.middleware.ChannelsMiddleware,
// @import("app/middleware/DemoMiddleware.zig"), // jetzig.middleware.InertiaMiddleware,
// jetzig.middleware.CompressionMiddleware,
// @import("app/middleware/DemoMiddleware.zig"),
}; };
// Maximum bytes to allow in request body. // Maximum bytes to allow in request body.

View File

@ -11,9 +11,13 @@ mailers_path: []const u8,
buffer: std.ArrayList(u8), buffer: std.ArrayList(u8),
dynamic_routes: std.ArrayList(Function), dynamic_routes: std.ArrayList(Function),
static_routes: std.ArrayList(Function), static_routes: std.ArrayList(Function),
module_paths: std.ArrayList([]const u8), channel_routes: std.ArrayList([]const u8),
channel_actions: std.StringHashMap(std.StringHashMap([]const []const u8)),
module_paths: std.StringHashMap(void),
data: *jetzig.data.Data, data: *jetzig.data.Data,
const receive_message = "receiveMessage";
const Routes = @This(); const Routes = @This();
const Function = struct { const Function = struct {
@ -120,7 +124,9 @@ pub fn init(
.buffer = std.ArrayList(u8).init(allocator), .buffer = std.ArrayList(u8).init(allocator),
.static_routes = std.ArrayList(Function).init(allocator), .static_routes = std.ArrayList(Function).init(allocator),
.dynamic_routes = std.ArrayList(Function).init(allocator), .dynamic_routes = std.ArrayList(Function).init(allocator),
.module_paths = std.ArrayList([]const u8).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, .data = data,
}; };
} }
@ -130,6 +136,7 @@ pub fn deinit(self: *Routes) void {
self.buffer.deinit(); self.buffer.deinit();
self.static_routes.deinit(); self.static_routes.deinit();
self.dynamic_routes.deinit(); self.dynamic_routes.deinit();
self.channel_routes.deinit();
} }
/// Generates the complete route set for the application /// Generates the complete route set for the application
@ -137,6 +144,7 @@ pub fn generateRoutes(self: *Routes) ![]const u8 {
const writer = self.buffer.writer(); const writer = self.buffer.writer();
try writer.writeAll( try writer.writeAll(
\\const std = @import("std");
\\const jetzig = @import("jetzig"); \\const jetzig = @import("jetzig");
\\ \\
\\pub const routes = [_]jetzig.Route{ \\pub const routes = [_]jetzig.Route{
@ -148,6 +156,16 @@ pub fn generateRoutes(self: *Routes) ![]const u8 {
\\ \\
); );
try writer.writeAll(
\\pub const channel_routes = std.StaticStringMap(jetzig.channels.Route).initComptime(.{
\\
);
try self.writeChannelRoutes(writer);
try writer.writeAll(
\\});
\\
);
try writer.writeAll( try writer.writeAll(
\\ \\
\\pub const mailers = [_]jetzig.MailerDefinition{ \\pub const mailers = [_]jetzig.MailerDefinition{
@ -171,16 +189,29 @@ pub fn generateRoutes(self: *Routes) ![]const u8 {
\\ \\
); );
try writer.writeAll(
\\
\\pub const View = struct { name: []const u8, module: type };
\\pub const views = std.StaticStringMap(View).initComptime(.{
\\
);
try self.writeViewsMap(writer);
try writer.writeAll(
\\});
\\
);
try writer.writeAll( try writer.writeAll(
\\test { \\test {
\\ \\
); );
for (self.module_paths.items) |module_path| { var it = self.module_paths.keyIterator();
while (it.next()) |module_path| {
try writer.print( try writer.print(
\\ _ = @import("{s}"); \\ _ = @import("{s}");
\\ \\
, .{module_path}); , .{module_path.*});
} }
try writer.writeAll( try writer.writeAll(
@ -254,6 +285,10 @@ fn writeRoutes(self: *Routes, writer: anytype) !void {
for (view_routes.dynamic) |view_route| { for (view_routes.dynamic) |view_route| {
try self.dynamic_routes.append(view_route); try self.dynamic_routes.append(view_route);
} }
for (view_routes.channel) |view_route| {
try self.channel_routes.append(view_route);
}
} }
std.sort.pdq(Function, self.static_routes.items, {}, Function.lessThanFn); std.sort.pdq(Function, self.static_routes.items, {}, Function.lessThanFn);
@ -343,7 +378,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
unreachable; unreachable;
std.mem.replaceScalar(u8, module_path, '\\', '/'); std.mem.replaceScalar(u8, module_path, '\\', '/');
try self.module_paths.append(try self.allocator.dupe(u8, module_path)); try self.module_paths.put(try self.allocator.dupe(u8, module_path), {});
var buf: [32]u8 = undefined; var buf: [32]u8 = undefined;
const id = jetzig.util.generateVariableName(&buf); const id = jetzig.util.generateVariableName(&buf);
@ -367,8 +402,49 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
const RouteSet = struct { const RouteSet = struct {
dynamic: []Function, dynamic: []Function,
static: []Function, static: []Function,
channel: [][]const u8,
}; };
fn writeChannelRoutes(self: *Routes, writer: anytype) !void {
for (self.channel_routes.items) |path| {
const module_path = try self.relativePathFrom(.root, path, .posix);
defer self.allocator.free(module_path);
const relative_path = try self.relativePathFrom(.views, path, .posix);
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}",
\\ &.{{{2s}}}
\\ ),
\\}}
\\
, .{ view_name, module_path, actions_buf.items });
}
}
fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !RouteSet { fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !RouteSet {
const stat = try dir.statFile(path); const stat = try dir.statFile(path);
const source = try dir.readFileAllocOptions( const source = try dir.readFileAllocOptions(
@ -385,30 +461,37 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
var static_routes = std.ArrayList(Function).init(self.allocator); var static_routes = std.ArrayList(Function).init(self.allocator);
var dynamic_routes = std.ArrayList(Function).init(self.allocator); var dynamic_routes = std.ArrayList(Function).init(self.allocator);
var channel_routes = std.ArrayList([]const u8).init(self.allocator);
var static_params: ?*jetzig.data.Value = null; var static_params: ?*jetzig.data.Value = null;
for (self.ast.nodes.items(.tag), 0..) |tag, index| { for (self.ast.nodes.items(.tag), 0..) |tag, index| {
switch (tag) { switch (tag) {
.fn_proto_multi, .fn_proto_one, .fn_proto_simple => |function_tag| { .fn_proto_multi, .fn_proto_one, .fn_proto_simple => |function_tag| {
var function = try self.parseFunction(function_tag, @enumFromInt(index), path, source); var maybe_function = try self.parseFunction(
if (function) |*capture| { function_tag,
if (capture.args.len == 0) { @enumFromInt(index),
path,
source,
);
if (maybe_function) |*function| {
if (!std.mem.eql(u8, function.name, receive_message) and function.args.len == 0) {
std.debug.print( std.debug.print(
"Expected at least 1 argument for view function `{s}` in `{s}`", "Expected at least 1 argument for view function `{s}` in `{s}`",
.{ capture.name, path }, .{ function.name, path },
); );
return error.JetzigMissingViewArgument; return error.JetzigMissingViewArgument;
} }
for (capture.args, 0..) |arg, arg_index| { for (function.args, 0..) |arg, arg_index| {
if (std.mem.eql(u8, try arg.typeBasename(), "StaticRequest")) { if (std.mem.eql(u8, try arg.typeBasename(), "StaticRequest")) {
capture.static = jetzig.build_options.build_static; function.static = jetzig.build_options.build_static;
capture.legacy = arg_index + 1 < capture.args.len; function.legacy = arg_index + 1 < function.args.len;
try static_routes.append(capture.*); try static_routes.append(function.*);
} else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) { } else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) {
capture.static = false; function.static = false;
capture.legacy = arg_index + 1 < capture.args.len; function.legacy = arg_index + 1 < function.args.len;
try dynamic_routes.append(capture.*); try dynamic_routes.append(function.*);
} }
} }
} }
@ -422,6 +505,28 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
static_params = self.data.value; static_params = self.data.value;
} }
}, },
.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, @enumFromInt(index)),
.container_decl,
.container_decl_trailing,
=> self.ast.containerDecl(@enumFromInt(index)),
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, "Channel")) {
try channel_routes.append(path);
try self.parseChannel(container, path);
}
},
else => {}, else => {},
} }
} }
@ -447,9 +552,87 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
return .{ return .{
.dynamic = dynamic_routes.items, .dynamic = dynamic_routes.items,
.static = static_routes.items, .static = static_routes.items,
.channel = channel_routes.items,
}; };
} }
// 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`. // 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 { 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; const init_node = decl.ast.init_node.unwrap() orelse return;
@ -618,6 +801,10 @@ fn parseFunction(
var it = fn_proto.iterate(&self.ast); var it = fn_proto.iterate(&self.ast);
while (it.next()) |arg| { while (it.next()) |arg| {
// We don't need to resolve args for `receiveMessage` as it only has one form (it was
// added after the removal of the `data` arg from view functions).
if (std.mem.eql(u8, receive_message, function_name)) continue;
if (arg.name_token) |arg_token| { if (arg.name_token) |arg_token| {
const arg_name = self.ast.tokenSlice(arg_token); const arg_name = self.ast.tokenSlice(arg_token);
const node = self.ast.nodes.get(@intFromEnum(arg.type_expr.?)); const node = self.ast.nodes.get(@intFromEnum(arg.type_expr.?));
@ -645,7 +832,7 @@ fn parseFunction(
fn parseTypeExpr(self: *Routes, node: std.zig.Ast.Node) ![]const u8 { fn parseTypeExpr(self: *Routes, node: std.zig.Ast.Node) ![]const u8 {
switch (node.tag) { switch (node.tag) {
// Currently all expected params are pointers, keeping this here in case that changes in future: // Currently all expected params are pointers, keeping this here in case that changes in future:
.identifier => {}, .identifier => return self.ast.tokenSlice(@as(u32, @intCast(node.main_token))),
.ptr_type_aligned => { .ptr_type_aligned => {
var buf = std.ArrayList([]const u8).init(self.allocator); var buf = std.ArrayList([]const u8).init(self.allocator);
defer buf.deinit(); defer buf.deinit();
@ -663,6 +850,14 @@ fn parseTypeExpr(self: *Routes, node: std.zig.Ast.Node) ![]const u8 {
else => {}, else => {},
} }
// TODO: Output source line
std.log.err(
"Unexpected token type `{s}` in `{s}`",
.{
@tagName(node.tag),
self.ast.tokenSlice(@as(u32, @intCast(node.main_token))),
},
);
return error.JetzigAstParserError; return error.JetzigAstParserError;
} }
@ -671,7 +866,7 @@ fn isActionFunctionName(name: []const u8) bool {
if (std.mem.eql(u8, field.name, name)) return true; if (std.mem.eql(u8, field.name, name)) return true;
} }
return false; return std.mem.eql(u8, receive_message, name);
} }
inline fn chompExtension(path: []const u8) []const u8 { inline fn chompExtension(path: []const u8) []const u8 {
@ -803,3 +998,18 @@ fn writeJobs(self: Routes, writer: anytype) !void {
std.debug.print("[jetzig] Imported {} job(s)\n", .{count}); std.debug.print("[jetzig] Imported {} job(s)\n", .{count});
} }
fn writeViewsMap(self: Routes, writer: anytype) !void {
var it = self.module_paths.keyIterator();
while (it.next()) |path| {
try writer.print(
\\.{{ "{0s}", View{{ .name = "{0s}", .module = @import("{1s}") }} }},
\\
,
.{
chompExtension(try self.relativePathFrom(.views, path.*, .posix)),
path.*,
},
);
}
}

View File

@ -147,7 +147,7 @@ fn renderMarkdown(
if (zmpl.findPrefixed("views", prefixed_name)) |layout| { if (zmpl.findPrefixed("views", prefixed_name)) |layout| {
view.data.content = .{ .data = content }; view.data.content = .{ .data = content };
return try layout.render(view.data, jetzig.TemplateContext, .{}, .{}); return try layout.render(view.data, jetzig.TemplateContext, .{}, &.{}, .{});
} else { } else {
std.debug.print("Unknown layout: {s}\n", .{layout_name}); std.debug.print("Unknown layout: {s}\n", .{layout_name});
return content; return content;
@ -174,6 +174,7 @@ fn renderZmplTemplate(
view.data, view.data,
jetzig.TemplateContext, jetzig.TemplateContext,
.{}, .{},
&.{},
.{ .layout = layout }, .{ .layout = layout },
); );
} else { } else {
@ -181,7 +182,7 @@ fn renderZmplTemplate(
return try allocator.dupe(u8, ""); return try allocator.dupe(u8, "");
} }
} else { } else {
return try template.render(view.data, jetzig.TemplateContext, .{}, .{}); return try template.render(view.data, jetzig.TemplateContext, .{}, &.{}, .{});
} }
} else return null; } else return null;
} }

View File

@ -25,6 +25,8 @@ pub const auth = @import("jetzig/auth.zig");
pub const callbacks = @import("jetzig/callbacks.zig"); pub const callbacks = @import("jetzig/callbacks.zig");
pub const debug = @import("jetzig/debug.zig"); pub const debug = @import("jetzig/debug.zig");
pub const TemplateContext = @import("jetzig/TemplateContext.zig"); pub const TemplateContext = @import("jetzig/TemplateContext.zig");
pub const websockets = @import("jetzig/websockets.zig");
pub const channels = @import("jetzig/channels.zig");
pub const DateTime = jetcommon.types.DateTime; pub const DateTime = jetcommon.types.DateTime;
pub const Time = jetcommon.types.Time; pub const Time = jetcommon.types.Time;
@ -38,6 +40,20 @@ pub const environment = @field(
@tagName(build_options.environment), @tagName(build_options.environment),
); );
pub fn logFn(
comptime level: std.log.Level,
comptime scope: @Type(.enum_literal),
comptime format: []const u8,
args: anytype,
) void {
if (scope == .websocket) return; // We handle our own websocket event logging.
std.log.defaultLog(level, scope, format, args);
}
pub const std_options: std.Options = .{
.logFn = logFn,
};
/// The primary interface for a Jetzig application. Create an `App` in your application's /// The primary interface for a Jetzig application. Create an `App` in your application's
/// `src/main.zig` and call `start` to launch the application. /// `src/main.zig` and call `start` to launch the application.
pub const App = @import("jetzig/App.zig"); pub const App = @import("jetzig/App.zig");

View File

@ -11,7 +11,7 @@ env: jetzig.Environment,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
custom_routes: std.ArrayList(jetzig.views.Route), custom_routes: std.ArrayList(jetzig.views.Route),
initHook: ?*const fn (*App) anyerror!void, initHook: ?*const fn (*App) anyerror!void,
server: *jetzig.http.Server = undefined, server: *anyopaque = undefined,
pub fn deinit(self: *const App) void { pub fn deinit(self: *const App) void {
@constCast(self).custom_routes.deinit(); @constCast(self).custom_routes.deinit();
@ -34,7 +34,15 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void {
defer mime_map.deinit(); defer mime_map.deinit();
try mime_map.build(); try mime_map.build();
const routes = try createRoutes(self.allocator, if (@hasDecl(routes_module, "routes")) &routes_module.routes else &.{}); 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
&.{});
defer { defer {
for (routes) |var_route| { for (routes) |var_route| {
var_route.deinitParams(); var_route.deinitParams();
@ -47,6 +55,11 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void {
self.allocator.free(custom_route.template); self.allocator.free(custom_route.template);
}; };
const channel_routes = if (@hasDecl(routes_module, "channel_routes"))
routes_module.channel_routes
else
std.StaticStringMap(jetzig.channels.Route).initComptime(.{});
var store = try jetzig.kv.Store.GeneralStore.init(self.allocator, self.env.logger, .general); var store = try jetzig.kv.Store.GeneralStore.init(self.allocator, self.env.logger, .general);
defer store.deinit(); defer store.deinit();
@ -56,6 +69,9 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void {
var cache = try jetzig.kv.Store.CacheStore.init(self.allocator, self.env.logger, .cache); var cache = try jetzig.kv.Store.CacheStore.init(self.allocator, self.env.logger, .cache);
defer cache.deinit(); defer cache.deinit();
var channels = try jetzig.kv.Store.CacheStore.init(self.allocator, self.env.logger, .channels);
defer channels.deinit();
var repo = try jetzig.database.repo(self.allocator, self); var repo = try jetzig.database.repo(self.allocator, self);
defer repo.deinit(); defer repo.deinit();
@ -81,10 +97,11 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void {
std.process.exit(0); std.process.exit(0);
} }
var server = jetzig.http.Server.init( var server = jetzig.http.Server.RoutedServer(routes_module).init(
self.allocator, self.allocator,
self.env, self.env,
routes, routes,
channel_routes,
self.custom_routes.items, self.custom_routes.items,
if (@hasDecl(routes_module, "jobs")) &routes_module.jobs else &.{}, if (@hasDecl(routes_module, "jobs")) &routes_module.jobs else &.{},
if (@hasDecl(routes_module, "jobs")) &routes_module.mailers else &.{}, if (@hasDecl(routes_module, "jobs")) &routes_module.mailers else &.{},
@ -92,6 +109,7 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void {
&store, &store,
&job_queue, &job_queue,
&cache, &cache,
&channels,
&repo, &repo,
options.global, options.global,
); );

View File

@ -1,13 +1,43 @@
const std = @import("std"); const std = @import("std");
pub const http = @import("http.zig"); pub const http = @import("http.zig");
pub const views = @import("views.zig");
pub const config = @import("config.zig"); pub const config = @import("config.zig");
/// Context available in every Zmpl template as `context`. /// Context available in every Zmpl template as `context`.
pub const TemplateContext = @This(); pub const TemplateContext = @This();
request: ?*http.Request = null, 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.
/// Use to create a form element which can be verified by `AntiCsrfMiddleware`.
pub fn authenticityToken(self: TemplateContext) !?[]const u8 { pub fn authenticityToken(self: TemplateContext) !?[]const u8 {
return if (self.request) |request| return if (self.request) |request|
try request.authenticityToken() try request.authenticityToken()
@ -15,6 +45,8 @@ pub fn authenticityToken(self: TemplateContext) !?[]const u8 {
null; null;
} }
/// Generate a hidden form element containing an authenticity token provided by
/// `authenticityToken`. Use as `{{context.authenticityFormElement()}}` in a Zmpl template.
pub fn authenticityFormElement(self: TemplateContext) !?[]const u8 { pub fn authenticityFormElement(self: TemplateContext) !?[]const u8 {
return if (self.request) |request| blk: { return if (self.request) |request| blk: {
const token = try request.authenticityToken(); const token = try request.authenticityToken();
@ -23,3 +55,10 @@ pub fn authenticityFormElement(self: TemplateContext) !?[]const u8 {
, .{ config.get([]const u8, "authenticity_token_name"), token }); , .{ config.get([]const u8, "authenticity_token_name"), token });
} else null; } else null;
} }
pub fn path(self: TemplateContext) ?[]const u8 {
return if (self.request) |request|
request.path.path
else
null;
}

7
src/jetzig/channels.zig Normal file
View File

@ -0,0 +1,7 @@
pub const RoutedChannel = @import("channels/Channel.zig").RoutedChannel;
pub const Message = @import("channels/Message.zig");
pub const Route = @import("channels/Route.zig");
pub const ActionRouter = @import("channels/ActionRouter.zig");
// For convenience in channel callback functions implemented by users.
pub const Channel = RoutedChannel(@import("root").routes);

View File

@ -0,0 +1,235 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
pub const Action = struct {
view: []const u8,
name: []const u8,
params: []const std.builtin.Type.Fn.Param,
};
pub const ActionRouter = struct {
actions: []const Action,
routes: type,
encoded_params: std.StaticStringMap([]const u8),
pub fn invoke(
comptime router: ActionRouter,
allocator: std.mem.Allocator,
path: []const u8,
data: []const u8,
Channel: type,
channel: Channel,
) !?[]const u8 {
inline for (router.actions) |action| {
if (match(action, path, data)) {
var d = jetzig.data.Data.init(allocator);
defer d.deinit();
// Format should be at least e.g.: `_invoke:foo:[]`
if (data.len < prefix(action).len + 2) return error.InvalidChannelActionArguments;
try d.fromJson(data[prefix(action).len..]);
const received_args = switch (d.value.?.*) {
.array => |array| array.array.items,
else => return error.InvalidChannelActionArguments,
};
const view = router.routes.views.get(action.view).?;
const func = @field(view.module.Channel.Actions, action.name);
const Args = std.meta.ArgsTuple(@TypeOf(func));
var args: Args = undefined;
const expected_args = std.meta.fields(Args);
if (expected_args.len < 1 or received_args.len != expected_args.len - 1) {
return error.InvalidChannelActionArguments;
}
args[0] = channel;
if (comptime action.params.len > 1) {
inline for (action.params[1..], 0..) |param, index| {
args[index + 1] = try coerce(param.type.?, received_args[index].*);
}
}
try @call(.auto, func, args);
return action.name;
}
}
return null;
}
pub fn encodedParams(comptime router: ActionRouter, route: jetzig.channels.Route) ?[]const u8 {
if (router.routes.channel_routes.get(route.path)) |matched_route| {
_ = matched_route;
}
}
fn match(comptime action: Action, path: []const u8, data: []const u8) bool {
return (std.mem.eql(u8, action.view, path)) and std.mem.startsWith(
u8,
data,
prefix(action),
);
}
inline fn prefix(comptime action: Action) []const u8 {
return "_invoke:" ++ action.name ++ ":";
}
fn coerce(T: type, value: jetzig.data.Value) !T {
return switch (T) {
[]const u8 => switch (value) {
.string => |v| v.value,
else => error.InvalidChannelActionArguments,
},
else => switch (@typeInfo(T)) {
.int => switch (value) {
.integer => |v| @intCast(v.value),
else => error.InvalidChannelActionArguments,
},
.float => switch (value) {
.float => |v| @floatCast(v.value),
else => error.InvalidChannelActionArguments,
},
.bool => switch (value) {
.boolean => |v| v.value,
else => error.InvalidChannelActionArguments,
},
else => error.InvalidChannelActionArguments,
},
};
}
};
pub fn initComptime(Routes: type) ActionRouter {
comptime {
var len: usize = 0;
for (Routes.views.values()) |view| {
if (!@hasDecl(view.module, "Channel")) continue;
if (!@hasDecl(view.module.Channel, "Actions")) continue;
const actions = view.module.Channel.Actions;
for (std.meta.declarations(actions)) |_| {
len += 1;
}
}
var actions: [len]Action = undefined;
var index: usize = 0;
for (Routes.views.values()) |view| {
if (!@hasDecl(view.module, "Channel")) continue;
if (!@hasDecl(view.module.Channel, "Actions")) continue;
const channel_actions = view.module.Channel.Actions;
const decls = std.meta.declarations(channel_actions);
for (decls) |decl| {
const params = @typeInfo(
@TypeOf(@field(view.module.Channel.Actions, decl.name)),
).@"fn".params;
actions[index] = .{
.view = view.name,
.name = decl.name,
.params = params,
};
index += 1;
}
}
const encoded_params = try encodeParams(Routes);
const result = actions;
return .{ .actions = &result, .routes = Routes, .encoded_params = encoded_params };
}
}
fn encodeParams(Routes: type) !std.StaticStringMap([]const u8) {
// We do a bit of awkward encoding here to ensure that we have a pre-compiled JSON string
// that we can send to the websocket after intialization to give the Jetzig Javascript code a
// spec for all available actions.
comptime {
const Spec = struct {
actions: []ActionSpec,
pub const ActionSpec = struct {
name: []const u8,
params: []const ParamSpec,
pub const ParamSpec = struct {
type: []const u8,
name: []const u8,
};
};
};
const Tuple = std.meta.Tuple(&.{ []const u8, []const u8 });
var map: [Routes.views.keys().len]Tuple = undefined;
for (Routes.views.values(), 0..) |view, view_index| {
const has_actions = @hasDecl(view.module, "Channel") and
@hasDecl(view.module.Channel, "Actions");
const channel_actions = if (has_actions) view.module.Channel.Actions else struct {};
const decls = std.meta.declarations(channel_actions);
var channel_params: Spec = undefined;
var actions: [decls.len]Spec.ActionSpec = undefined;
for (decls, 0..) |decl, decl_index| {
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]Spec.ActionSpec.ParamSpec = undefined;
for (info.params[1..], 0..) |param, param_index| {
params[param_index] = .{
.type = jsonTypeName(param.type.?),
.name = action.params[param_index].name,
};
}
actions[decl_index] = .{ .name = decl.name, .params = &params };
} else {
actions[decl_index] = .{ .name = decl.name, .params = &.{} };
}
},
else => {},
}
}
channel_params.actions = &actions;
var counting_stream = std.io.countingWriter(std.io.null_writer);
try std.json.stringify(channel_params, .{}, counting_stream.writer());
var buf: [counting_stream.bytes_written]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
try std.json.stringify(channel_params, .{}, stream.writer());
const written = buf;
map[view_index] = .{ view.name, &written };
}
return std.StaticStringMap([]const u8).initComptime(map);
}
}
fn verifyParams(
params: []const std.builtin.Type.Fn.Param,
view: []const u8,
action: []const u8,
) void {
const humanized = std.fmt.comptimePrint("Channel Action {s}:{s}", .{ view, action });
const too_few_params = "Expected at least 1 parameter for " ++ humanized;
const missing_param = "Incorrect first argument (must be jetzig.channels.Channel) for " ++ humanized;
if (params.len < 1) @compileError(too_few_params);
if (params[0].type.? != jetzig.channels.Channel) @compileError(missing_param);
}
fn jsonTypeName(T: type) []const u8 {
return switch (T) {
[]const u8 => "string",
else => switch (@typeInfo(T)) {
.float, .comptime_float => "float",
.int, .comptime_int => "integer",
.bool => "bool",
else => @compileError("Unsupported Channel Action argument type: " ++ @typeName(T)),
},
};
}

View File

@ -0,0 +1,82 @@
const std = @import("std");
const httpz = @import("httpz");
const jetzig = @import("../../jetzig.zig");
pub fn RoutedChannel(Routes: type) type {
return struct {
const Channel = @This();
allocator: std.mem.Allocator,
websocket: *jetzig.websockets.RoutedWebsocket(Routes),
state: *jetzig.data.Value,
data: *jetzig.data.Data,
pub fn publish(channel: Channel, data: anytype) !void {
var stack_fallback = std.heap.stackFallback(4096, channel.allocator);
const allocator = stack_fallback.get();
var write_buffer = channel.websocket.connection.writeBuffer(allocator, .text);
defer write_buffer.deinit();
const writer = write_buffer.writer();
try std.json.stringify(data, .{}, writer);
try write_buffer.flush();
channel.websocket.logger.DEBUG(
"Published Channel message for `{s}`",
.{channel.websocket.route.path},
) catch {};
}
pub fn invoke(
channel: Channel,
comptime method: @TypeOf(.enum_literal),
args: anytype,
) !void {
// TODO: DRY
var stack_fallback = std.heap.stackFallback(4096, channel.allocator);
const allocator = stack_fallback.get();
var write_buffer = channel.websocket.connection.writeBuffer(allocator, .text);
defer write_buffer.deinit();
const writer = write_buffer.writer();
try writer.writeAll("__jetzig_event__:");
try std.json.stringify(.{ .method = method, .params = args }, .{}, writer);
try write_buffer.flush();
channel.websocket.logger.DEBUG(
"Invoked Javascript function `{s}` for `{s}`",
.{ @tagName(method), channel.websocket.route.path },
) catch {};
}
pub fn getT(
channel: Channel,
comptime T: jetzig.data.Data.ValueType,
key: []const u8,
) @TypeOf(channel.state.getT(T, key)) {
return channel.state.getT(T, key);
}
pub fn get(channel: Channel, key: []const u8) ?*jetzig.data.Value {
return channel.state.get(key);
}
pub fn put(
channel: Channel,
key: []const u8,
value: anytype,
) @TypeOf(channel.state.put(key, value)) {
return try channel.state.put(key, value);
}
pub fn remove(channel: Channel, key: []const u8) bool {
return channel.state.remove(key);
}
pub fn sync(channel: Channel) !void {
try channel.websocket.syncState(channel);
}
};
}

View File

@ -0,0 +1,54 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
const Message = @This();
allocator: std.mem.Allocator,
payload: []const u8,
data: *jetzig.data.Data,
channel: jetzig.channels.Channel,
pub fn init(
allocator: std.mem.Allocator,
channel: jetzig.channels.Channel,
payload: []const u8,
) Message {
return .{
.allocator = allocator,
.channel = channel,
.data = channel.data,
.payload = payload,
};
}
pub fn params(message: Message) !?*jetzig.data.Value {
var d = try message.allocator.create(jetzig.data.Data);
d.* = jetzig.data.Data.init(message.allocator);
d.fromJson(message.payload) catch |err| {
switch (err) {
error.SyntaxError => {
message.channel.websocket.logger.ERROR("Invalid JSON received in Channel message.", .{}) catch {};
},
else => {
message.channel.websocket.logger.logError(@errorReturnTrace(), err) catch {};
},
}
return null;
};
return d.value;
}
test "message with payload" {
const message = Message.init(
std.testing.allocator,
jetzig.channels.Channel{
.websocket = undefined,
.state = undefined,
.allocator = undefined,
.data = undefined,
},
"foo",
);
try std.testing.expectEqualStrings(message.payload, "foo");
}

View File

@ -0,0 +1,36 @@
const jetzig = @import("../../jetzig.zig");
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, actions: []const Action) Route {
comptime {
if (!@hasDecl(T, "Channel")) return .{};
const openConnectionFn = if (@hasDecl(T.Channel, "open")) T.Channel.open else null;
const receiveMessageFn = if (@hasDecl(T.Channel, "receive")) T.Channel.receive else null;
return .{
.openConnectionFn = openConnectionFn,
.receiveMessageFn = receiveMessageFn,
.path = path,
.actions = actions,
};
}
}

View File

@ -145,6 +145,19 @@ pub const cache: kv.Store.Options = .{
// }, // },
}; };
/// Channels. Identical to `store` options, but allows using different
/// backends (e.g. `.memory` for key-value store, `.file` for cache.
/// Channel state data is stored here.
pub const channels: kv.Store.Options = .{
.backend = .memory,
// .backend = .file,
// .file_options = .{
// .path = "/path/to/jetkv-channels.db",
// .truncate = false, // Set to `true` to clear the store on each server launch.
// .address_space_size = jetzig.jetkv.JetKV.addressSpace(4096),
// },
};
/// SMTP configuration for Jetzig Mail. /// SMTP configuration for Jetzig Mail.
pub const smtp: mail.SMTPConfig = .{ pub const smtp: mail.SMTPConfig = .{
.port = 25, .port = 25,

View File

@ -39,9 +39,10 @@ pub fn repo(allocator: std.mem.Allocator, app: anytype) !Repo {
} }
fn eventCallback(event: jetzig.jetquery.events.Event, app: anytype) !void { fn eventCallback(event: jetzig.jetquery.events.Event, app: anytype) !void {
try app.server.logger.logSql(event); var server: *jetzig.http.Server.RoutedServer(@import("root").routes) = @ptrCast(@alignCast(app.server));
try server.logger.logSql(event);
if (event.err) |err| { if (event.err) |err| {
try app.server.logger.ERROR("[database] {?s}", .{err.message}); try server.logger.ERROR("[database] {?s}", .{err.message});
} }
} }

View File

@ -41,6 +41,12 @@ pub fn get(self: Headers, name: []const u8) ?[]const u8 {
return self.httpz_headers.get(lower); return self.httpz_headers.get(lower);
} }
/// Get the first value for a given header identified by `name`, which is assumed to be lower case.
pub fn getLower(self: Headers, name: []const u8) ?[]const u8 {
std.debug.assert(name.len <= max_bytes_header_name);
return self.httpz_headers.get(name);
}
/// Get all values for a given header identified by `name`. Names are case insensitive. /// Get all values for a given header identified by `name`. Names are case insensitive.
pub fn getAll(self: Headers, name: []const u8) []const []const u8 { pub fn getAll(self: Headers, name: []const u8) []const []const u8 {
var headers = std.ArrayList([]const u8).init(self.allocator); var headers = std.ArrayList([]const u8).init(self.allocator);

View File

@ -13,6 +13,7 @@ path: []const u8,
base_path: []const u8, base_path: []const u8,
directory: []const u8, directory: []const u8,
file_path: []const u8, file_path: []const u8,
view_name: []const u8,
resource_id: []const u8, resource_id: []const u8,
extension: ?[]const u8, extension: ?[]const u8,
query: ?[]const u8, query: ?[]const u8,
@ -29,6 +30,7 @@ pub fn init(path: []const u8) Path {
.base_path = base_path, .base_path = base_path,
.directory = getDirectory(base_path), .directory = getDirectory(base_path),
.file_path = getFilePath(path), .file_path = getFilePath(path),
.view_name = std.mem.trimLeft(u8, base_path, "/"),
.resource_id = getResourceId(base_path), .resource_id = getResourceId(base_path),
.extension = getExtension(path), .extension = getExtension(path),
.query = getQuery(path), .query = getQuery(path),
@ -414,3 +416,8 @@ test ".method (/foo/bar/1/_PATCH" {
const path = Path.init("/foo/bar/1/_PATCH"); const path = Path.init("/foo/bar/1/_PATCH");
try std.testing.expect(path.method.? == .PATCH); try std.testing.expect(path.method.? == .PATCH);
} }
test ".view_name" {
const path = Path.init("/foo/bar");
try std.testing.expectEqualStrings("foo/bar", path.view_name);
}

View File

@ -15,7 +15,9 @@ pub const RequestState = enum {
initial, // No processing has taken place initial, // No processing has taken place
processed, // Request headers have been processed processed, // Request headers have been processed
after_request, // Initial middleware processing after_request, // Initial middleware processing
after_view, // View returned, response data ready for full response render
rendered, // Rendered by middleware or view rendered, // Rendered by middleware or view
rendered_content, // Rendered a plain string by middleware or view
redirected, // Redirected by middleware or view redirected, // Redirected by middleware or view
failed, // Failed by middleware or view failed, // Failed by middleware or view
before_response, // Post middleware processing before_response, // Post middleware processing
@ -26,7 +28,7 @@ allocator: std.mem.Allocator,
path: jetzig.http.Path, path: jetzig.http.Path,
method: Method, method: Method,
headers: jetzig.http.Headers, headers: jetzig.http.Headers,
server: *jetzig.http.Server, host: []const u8,
httpz_request: *httpz.Request, httpz_request: *httpz.Request,
httpz_response: *httpz.Response, httpz_response: *httpz.Response,
response: *jetzig.http.Response, response: *jetzig.http.Response,
@ -52,8 +54,14 @@ rendered_view: ?jetzig.views.View = null,
start_time: i128, start_time: i128,
store: RequestStore(jetzig.kv.Store.GeneralStore), store: RequestStore(jetzig.kv.Store.GeneralStore),
cache: RequestStore(jetzig.kv.Store.CacheStore), cache: RequestStore(jetzig.kv.Store.CacheStore),
job_queue: RequestStore(jetzig.kv.Store.JobQueueStore),
job_definitions: []const jetzig.JobDefinition,
mailer_definitions: []const jetzig.MailerDefinition,
repo: *jetzig.database.Repo, repo: *jetzig.database.Repo,
global: *jetzig.Global, global: *jetzig.Global,
env: jetzig.Environment,
routes: []const *const jetzig.views.Route,
logger: jetzig.loggers.Logger,
/// Wrapper for KV store that uses the request's arena allocator for fetching values. /// Wrapper for KV store that uses the request's arena allocator for fetching values.
pub fn RequestStore(T: type) type { pub fn RequestStore(T: type) type {
@ -122,12 +130,20 @@ pub fn RequestStore(T: type) type {
pub fn init( pub fn init(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
server: *jetzig.http.Server,
start_time: i128, start_time: i128,
httpz_request: *httpz.Request, httpz_request: *httpz.Request,
httpz_response: *httpz.Response, httpz_response: *httpz.Response,
response: *jetzig.http.Response, response: *jetzig.http.Response,
repo: *jetzig.database.Repo, repo: *jetzig.database.Repo,
env: jetzig.Environment,
routes: []const *const jetzig.views.Route,
logger: jetzig.loggers.Logger,
store: *jetzig.kv.Store.GeneralStore,
cache: *jetzig.kv.Store.CacheStore,
job_queue: *jetzig.kv.Store.JobQueueStore,
job_definitions: []const jetzig.JobDefinition,
mailer_definitions: []const jetzig.MailerDefinition,
global: *anyopaque,
) !Request { ) !Request {
const path = jetzig.http.Path.init(httpz_request.url.raw); const path = jetzig.http.Path.init(httpz_request.url.raw);
@ -146,25 +162,33 @@ pub fn init(
const response_data = try allocator.create(jetzig.data.Data); const response_data = try allocator.create(jetzig.data.Data);
response_data.* = jetzig.data.Data.init(allocator); response_data.* = jetzig.data.Data.init(allocator);
const headers = jetzig.http.Headers.init(allocator, httpz_request.headers);
const host = headers.getLower("host") orelse "";
return .{ return .{
.allocator = allocator, .allocator = allocator,
.path = path, .path = path,
.method = method, .method = method,
.headers = jetzig.http.Headers.init(allocator, httpz_request.headers), .headers = headers,
.server = server, .host = host,
.response = response, .response = response,
.response_data = response_data, .response_data = response_data,
.httpz_request = httpz_request, .httpz_request = httpz_request,
.httpz_response = httpz_response, .httpz_response = httpz_response,
.start_time = start_time, .start_time = start_time,
.store = .{ .store = server.store, .allocator = allocator }, .store = .{ .store = store, .allocator = allocator },
.cache = .{ .store = server.cache, .allocator = allocator }, .cache = .{ .store = cache, .allocator = allocator },
.job_queue = .{ .store = job_queue, .allocator = allocator },
.job_definitions = job_definitions,
.mailer_definitions = mailer_definitions,
.env = env,
.routes = routes,
.logger = logger,
.repo = repo, .repo = repo,
.global = if (@hasField(jetzig.Global, "__jetzig_default")) .global = if (@hasField(jetzig.Global, "__jetzig_default"))
undefined undefined
else else
@ptrCast(@alignCast(server.global)), @ptrCast(@alignCast(global)),
}; };
} }
@ -217,6 +241,20 @@ pub fn render(self: *Request, status_code: jetzig.http.status_codes.StatusCode)
return self.rendered_view.?; return self.rendered_view.?;
} }
/// Render a response with pre-rendered content. This function can only be called once per
/// request (repeat calls will trigger an error).
pub fn renderContent(
self: *Request,
status_code: jetzig.http.status_codes.StatusCode,
content: []const u8,
) jetzig.views.View {
if (self.isRendered()) self.rendered_multiple = true;
self.rendered_view = .{ .data = self.response_data, .status_code = status_code, .content = content };
self.state = .rendered_content;
return self.rendered_view.?;
}
/// Render an error. This function can only be called once per request (repeat calls will /// Render an error. This function can only be called once per request (repeat calls will
/// trigger an error). /// trigger an error).
pub fn fail(self: *Request, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View { pub fn fail(self: *Request, status_code: jetzig.http.status_codes.StatusCode) jetzig.views.View {
@ -230,7 +268,7 @@ pub fn fail(self: *Request, status_code: jetzig.http.status_codes.StatusCode) je
pub inline fn isRendered(self: *const Request) bool { pub inline fn isRendered(self: *const Request) bool {
return switch (self.state) { return switch (self.state) {
.initial, .processed, .after_request, .before_response => false, .initial, .processed, .after_request, .before_response => false,
.rendered, .redirected, .failed, .finalized => true, .after_view, .rendered, .rendered_content, .redirected, .failed, .finalized => true,
}; };
} }
@ -298,6 +336,9 @@ pub fn renderRedirect(self: *Request, state: RedirectState) !void {
var root = try self.response_data.root(.object); var root = try self.response_data.root(.object);
try root.put("location", self.response_data.string(state.location)); 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()) { const content = switch (self.requestFormat()) {
.HTML, .UNKNOWN => if (maybe_template) |template| blk: { .HTML, .UNKNOWN => if (maybe_template) |template| blk: {
try view.data.addConst("jetzig_view", view.data.string("internal")); try view.data.addConst("jetzig_view", view.data.string("internal"));
@ -305,7 +346,8 @@ pub fn renderRedirect(self: *Request, state: RedirectState) !void {
break :blk try template.render( break :blk try template.render(
self.response_data, self.response_data,
jetzig.TemplateContext, jetzig.TemplateContext,
.{ .request = self }, template_context,
&.{},
.{}, .{},
); );
} else try std.fmt.allocPrint(self.allocator, "Redirecting to {s}", .{state.location}), } else try std.fmt.allocPrint(self.allocator, "Redirecting to {s}", .{state.location}),
@ -497,19 +539,19 @@ pub fn cookies(self: *Request) !*jetzig.http.Cookies {
/// `jetzig.http.Session`. /// `jetzig.http.Session`.
pub fn session(self: *Request) !*jetzig.http.Session { pub fn session(self: *Request) !*jetzig.http.Session {
if (self._session) |capture| return capture; if (self._session) |capture| return capture;
const cookie_name = self.server.env.vars.get("JETZIG_SESSION_COOKIE") orelse const cookie_name = self.env.vars.get("JETZIG_SESSION_COOKIE") orelse
jetzig.http.Session.default_cookie_name; jetzig.http.Session.default_cookie_name;
const local_session = try self.allocator.create(jetzig.http.Session); const local_session = try self.allocator.create(jetzig.http.Session);
local_session.* = jetzig.http.Session.init( local_session.* = jetzig.http.Session.init(
self.allocator, self.allocator,
try self.cookies(), try self.cookies(),
self.server.env.secret, self.env.secret,
.{ .cookie_name = cookie_name }, .{ .cookie_name = cookie_name },
); );
local_session.parse() catch |err| { local_session.parse() catch |err| {
switch (err) { switch (err) {
error.JetzigInvalidSessionCookie => { error.JetzigInvalidSessionCookie => {
try self.server.logger.DEBUG("Invalid session cookie detected. Resetting session.", .{}); try self.logger.DEBUG("Invalid session cookie detected. Resetting session.", .{});
try local_session.reset(); try local_session.reset();
}, },
else => return err, else => return err,
@ -561,11 +603,11 @@ pub fn job(self: *Request, job_name: []const u8) !*jetzig.Job {
const background_job = try self.allocator.create(jetzig.Job); const background_job = try self.allocator.create(jetzig.Job);
background_job.* = jetzig.Job.init( background_job.* = jetzig.Job.init(
self.allocator, self.allocator,
self.server.store, self.store.store,
self.server.job_queue, self.job_queue.store,
self.server.cache, self.cache.store,
self.server.logger, self.logger,
self.server.job_definitions, self.job_definitions,
job_name, job_name,
); );
return background_job; return background_job;
@ -607,14 +649,14 @@ const RequestMail = struct {
self.request.allocator, self.request.allocator,
mail_job.params, mail_job.params,
jetzig.jobs.JobEnv{ jetzig.jobs.JobEnv{
.vars = self.request.server.env.vars, .vars = self.request.env.vars,
.environment = self.request.server.env.environment, .environment = self.request.env.environment,
.logger = self.request.server.logger, .logger = self.request.logger,
.routes = self.request.server.routes, .routes = self.request.routes,
.mailers = self.request.server.mailer_definitions, .mailers = self.request.mailer_definitions,
.jobs = self.request.server.job_definitions, .jobs = self.request.job_definitions,
.store = self.request.server.store, .store = self.request.store.store,
.cache = self.request.server.cache, .cache = self.request.cache.store,
.mutex = undefined, .mutex = undefined,
.repo = self.request.repo, .repo = self.request.repo,
}, },

File diff suppressed because it is too large Load Diff

View File

@ -12,6 +12,7 @@ cookie_name: []const u8,
initialized: bool = false, initialized: bool = false,
data: jetzig.data.Data, data: jetzig.data.Data,
state: enum { parsed, pending } = .pending, state: enum { parsed, pending } = .pending,
id: [32]u8 = undefined,
const Self = @This(); const Self = @This();
@ -48,7 +49,11 @@ pub fn parse(self: *Self) !void {
/// Reset session to an empty state. /// Reset session to an empty state.
pub fn reset(self: *Self) !void { pub fn reset(self: *Self) !void {
self.data.reset(); self.data.reset();
_ = try self.data.object(); var object = try self.data.object();
_ = jetzig.util.generateRandomString(&self.id);
try object.put("_id", &self.id);
self.state = .parsed; self.state = .parsed;
try self.save(); try self.save();
} }
@ -70,7 +75,7 @@ pub fn get(self: *Self, key: []const u8) ?*jetzig.data.Value {
/// Get a typed value from the session. /// Get a typed value from the session.
pub fn getT( pub fn getT(
self: *Self, self: Self,
comptime T: jetzig.data.ValueType, comptime T: jetzig.data.ValueType,
key: []const u8, key: []const u8,
) @TypeOf(self.data.value.?.object.getT(T, key)) { ) @TypeOf(self.data.value.?.object.getT(T, key)) {

View File

@ -48,7 +48,7 @@ pub fn Type(comptime name: MiddlewareEnum()) type {
} }
} }
pub fn afterLaunch(server: *jetzig.http.Server) !void { pub fn afterLaunch(server: *jetzig.http.Server.RoutedServer(@import("root").routes)) !void {
inline for (middlewares) |middleware| { inline for (middlewares) |middleware| {
if (comptime @hasDecl(middleware, "afterLaunch")) { if (comptime @hasDecl(middleware, "afterLaunch")) {
try middleware.afterLaunch(server); try middleware.afterLaunch(server);
@ -94,6 +94,38 @@ pub fn afterRequest(request: *jetzig.http.Request) !MiddlewareData {
return middleware_data; return middleware_data;
} }
pub fn afterView(middleware_data: *MiddlewareData, request: *jetzig.http.Request, route: jetzig.views.Route) !void {
if (request.state != .failed) request.state = .after_view;
inline for (middlewares, 0..) |middleware, index| {
if (comptime !@hasDecl(middleware, "afterView")) continue;
if (request.state == .after_view) {
if (comptime @hasDecl(middleware, "init")) {
const data = middleware_data.get(index).?;
try @call(
.always_inline,
middleware.afterView,
.{ @as(*middleware, @ptrCast(@alignCast(data))), request, route },
);
} else {
try @call(
.always_inline,
middleware.afterView,
.{ request, route },
);
}
}
if (request.state != .after_view) {
request.middleware_rendered = .{
.name = @typeName(middleware),
.action = "afterView",
};
break;
}
}
}
pub fn beforeResponse( pub fn beforeResponse(
middleware_data: *MiddlewareData, middleware_data: *MiddlewareData,
request: *jetzig.http.Request, request: *jetzig.http.Request,

View File

@ -22,6 +22,9 @@ pub const Store = struct {
/// Store ephemeral data. /// Store ephemeral data.
pub const CacheStore = @import("kv/Store.zig").Store(config.get(Store.Options, "cache")); pub const CacheStore = @import("kv/Store.zig").Store(config.get(Store.Options, "cache"));
/// Store channel data.
pub const ChannelStore = @import("kv/Store.zig").Store(config.get(Store.Options, "channels"));
/// Background job storage. /// Background job storage.
pub const JobQueueStore = @import("kv/Store.zig").Store(config.get(Store.Options, "job_queue")); pub const JobQueueStore = @import("kv/Store.zig").Store(config.get(Store.Options, "job_queue"));

View File

@ -53,7 +53,7 @@ fn jetKVOptions(options: KVOptions) jetzig.jetkv.Options {
} }
/// Role a given store fills. Used in log outputs. /// Role a given store fills. Used in log outputs.
pub const Role = enum { jobs, cache, general, custom }; pub const Role = enum { jobs, cache, general, channels, custom };
pub fn Store(comptime options: KVOptions) type { pub fn Store(comptime options: KVOptions) type {
return struct { return struct {

View File

@ -148,7 +148,7 @@ fn defaultHtml(
try data.addConst("jetzig_view", data.string("")); try data.addConst("jetzig_view", data.string(""));
try data.addConst("jetzig_action", data.string("")); try data.addConst("jetzig_action", data.string(""));
return if (jetzig.zmpl.findPrefixed("mailers", mailer.html_template)) |template| return if (jetzig.zmpl.findPrefixed("mailers", mailer.html_template)) |template|
try template.render(&data, jetzig.TemplateContext, .{}, .{}) try template.render(&data, jetzig.TemplateContext, .{}, &.{}, .{})
else else
null; null;
} }
@ -166,7 +166,7 @@ fn defaultText(
try data.addConst("jetzig_view", data.string("")); try data.addConst("jetzig_view", data.string(""));
try data.addConst("jetzig_action", data.string("")); try data.addConst("jetzig_action", data.string(""));
return if (jetzig.zmpl.findPrefixed("mailers", mailer.text_template)) |template| return if (jetzig.zmpl.findPrefixed("mailers", mailer.text_template)) |template|
try template.render(&data, jetzig.TemplateContext, .{}, .{}) try template.render(&data, jetzig.TemplateContext, .{}, &.{}, .{})
else else
null; null;
} }

View File

@ -5,6 +5,8 @@ pub const HtmxMiddleware = @import("middleware/HtmxMiddleware.zig");
pub const CompressionMiddleware = @import("middleware/CompressionMiddleware.zig"); pub const CompressionMiddleware = @import("middleware/CompressionMiddleware.zig");
pub const AuthMiddleware = @import("middleware/AuthMiddleware.zig"); pub const AuthMiddleware = @import("middleware/AuthMiddleware.zig");
pub const AntiCsrfMiddleware = @import("middleware/AntiCsrfMiddleware.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 { const RouteOptions = struct {
content: ?[]const u8 = null, content: ?[]const u8 = null,

View File

@ -29,7 +29,7 @@ pub fn beforeRender(request: *jetzig.http.Request, route: jetzig.views.Route) !v
fn logFailure(request: *jetzig.http.Request) !void { fn logFailure(request: *jetzig.http.Request) !void {
_ = request.fail(.forbidden); _ = request.fail(.forbidden);
try request.server.logger.DEBUG("Anti-CSRF token validation failed. Request aborted.", .{}); try request.logger.DEBUG("Anti-CSRF token validation failed. Request aborted.", .{});
} }
fn verifyCsrfToken(request: *jetzig.http.Request) !void { fn verifyCsrfToken(request: *jetzig.http.Request) !void {

View File

@ -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(
\\<script src="/_channels.js"></script>
);
}
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(
\\<script>
\\ (() => {{
\\ window.addEventListener('DOMContentLoaded', () => {{
\\ jetzig.channel.init("{s}", "{s}");
\\ }});
\\ }})();
\\</script>
\\
, .{ host, request.path.base_path });
}
};
pub fn renderChannels(request: *jetzig.Request) !jetzig.View {
return request.renderContent(.ok, @embedFile("channels/channels.js"));
}

View File

@ -9,7 +9,7 @@ const HtmxMiddleware = @This();
/// content rendered directly by the view function. /// content rendered directly by the view function.
pub fn afterRequest(request: *jetzig.http.Request) !void { pub fn afterRequest(request: *jetzig.http.Request) !void {
if (request.headers.get("HX-Request")) |_| { if (request.headers.get("HX-Request")) |_| {
try request.server.logger.DEBUG( try request.logger.DEBUG(
"[middleware-htmx] HX-Request header, disabling layout.", "[middleware-htmx] HX-Request header, disabling layout.",
.{}, .{},
); );

View File

@ -0,0 +1,6 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
// WIP
const InertiaMiddleware = @This();

View File

@ -0,0 +1,176 @@
window.jetzig = window.jetzig ? window.jetzig : {}
jetzig = window.jetzig;
(() => {
const transform = (value, state, element) => {
const id = element.getAttribute('jetzig-id');
const transformer = id && jetzig.channel.transformers[id];
if (transformer) {
return transformer(value, state, element);
} else {
return value === undefined || value == null ? '' : `${value}`
}
};
jetzig.channel = {
websocket: null,
actions: {},
action_specs: {},
stateChangedCallbacks: [],
messageCallbacks: [],
invokeCallbacks: {},
elementMap: {},
transformers: {},
onStateChanged: function(callback) { this.stateChangedCallbacks.push(callback); },
onMessage: function(callback) { this.messageCallbacks.push(callback); },
init: function(host, path) {
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.action_specs[action.name] = {
callback: (...params) => {
if (action.params.length != params.length) {
throw new Error(`Invalid params for action '${action.name}'. Expected ${action.params.length} params, found ${params.length}`);
}
[...action.params].forEach((param, 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);
}
}
});
this.websocket.send(`_invoke:${action.name}:${JSON.stringify(params)}`);
},
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}'`);
}
});
} 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);
const transformer = element.getAttribute('jetzig-transform');
if (transformer) {
this.transformers[id] = new Function("value", "$", "element", `return ${transformer};`);
}
});
const styled_elements = document.querySelectorAll('[jetzig-style]');
this.onStateChanged(state => {
styled_elements.forEach(element => {
const func = new Function("$", `return ${element.getAttribute('jetzig-style')};`)
const styles = func(state);
Object.entries(styles).forEach(([key, value]) => {
element.style.setProperty(key, value);
});
});
});
// this.websocket.addEventListener("open", (event) => {
// // TODO
// this.publish("websockets", {});
// });
},
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);
}
},
};
})();

View File

@ -0,0 +1,3 @@
@block head {
<script>console.log("hello");</script>
}

View File

@ -0,0 +1,13 @@
<!DOCTYPE html>
<html>
<head>
@partial views:inertia/head
</head>
<body>
<div
id="app"
data-page='{"component":"{{jetzig_view}}","props":{{zmpl.toJson()}},"url":"{{context.path()}}","version":"c32b8e4965f418ad16eaebba1d4e960f"}'
>
</div>
</body>
</html>

View File

@ -11,10 +11,11 @@ routes: []const jetzig.views.Route,
arena: *std.heap.ArenaAllocator, arena: *std.heap.ArenaAllocator,
store: *MemoryStore, store: *MemoryStore,
cache: *MemoryStore, cache: *MemoryStore,
channels: *MemoryStore,
job_queue: *MemoryStore, job_queue: *MemoryStore,
multipart_boundary: ?[]const u8 = null, multipart_boundary: ?[]const u8 = null,
logger: jetzig.loggers.Logger, logger: jetzig.loggers.Logger,
server: Server, server: *jetzig.http.Server.RoutedServer(@import("root").routes),
repo: *jetzig.database.Repo, repo: *jetzig.database.Repo,
cookies: *jetzig.http.Cookies, cookies: *jetzig.http.Cookies,
session: *jetzig.http.Session, session: *jetzig.http.Session,
@ -57,15 +58,38 @@ pub fn init(allocator: std.mem.Allocator, routes_module: type) !App {
const session = try alloc.create(jetzig.http.Session); const session = try alloc.create(jetzig.http.Session);
session.* = jetzig.http.Session.init(alloc, cookies, jetzig.testing.secret, .{}); session.* = jetzig.http.Session.init(alloc, cookies, jetzig.testing.secret, .{});
const server = try alloc.create(jetzig.http.Server.RoutedServer(@import("root").routes));
// This server is only used by database logging - we create a more lifelike server when we
// process the actual test request.
server.* = .{
.logger = logger,
.allocator = undefined,
.env = undefined,
.routes = undefined,
.channel_routes = undefined,
.custom_routes = undefined,
.job_definitions = undefined,
.mailer_definitions = undefined,
.mime_map = undefined,
.initialized = undefined,
.store = undefined,
.job_queue = undefined,
.cache = undefined,
.channels = undefined,
.repo = undefined,
.global = undefined,
};
app.* = App{ app.* = App{
.arena = arena, .arena = arena,
.allocator = allocator, .allocator = allocator,
.routes = &routes_module.routes, .routes = &routes_module.routes,
.store = try createStore(arena.allocator(), logger, .general), .store = try createStore(arena.allocator(), logger, .general),
.cache = try createStore(arena.allocator(), logger, .cache), .cache = try createStore(arena.allocator(), logger, .cache),
.channels = try createStore(arena.allocator(), logger, .channels),
.job_queue = try createStore(arena.allocator(), logger, .jobs), .job_queue = try createStore(arena.allocator(), logger, .jobs),
.logger = logger, .logger = logger,
.server = .{ .logger = logger }, .server = server,
.repo = repo, .repo = repo,
.cookies = cookies, .cookies = cookies,
.session = session, .session = session,
@ -131,7 +155,7 @@ pub fn request(
.env_map = std.process.EnvMap.init(allocator), .env_map = std.process.EnvMap.init(allocator),
.env_file = null, .env_file = null,
}; };
var server = jetzig.http.Server{ var server = jetzig.http.Server.RoutedServer(@import("root").routes){
.allocator = allocator, .allocator = allocator,
.logger = self.logger, .logger = self.logger,
.env = .{ .env = .{
@ -148,12 +172,14 @@ pub fn request(
.secret = jetzig.testing.secret, .secret = jetzig.testing.secret,
}, },
.routes = routes, .routes = routes,
.channel_routes = std.StaticStringMap(jetzig.channels.Route).initComptime(.{}),
.custom_routes = &.{}, .custom_routes = &.{},
.mailer_definitions = &.{}, .mailer_definitions = &.{},
.job_definitions = &.{}, .job_definitions = &.{},
.mime_map = jetzig.testing.mime_map, .mime_map = jetzig.testing.mime_map,
.store = self.store, .store = self.store,
.cache = self.cache, .cache = self.cache,
.channels = self.channels,
.job_queue = self.job_queue, .job_queue = self.job_queue,
.global = undefined, .global = undefined,
.repo = self.repo, .repo = self.repo,

View File

@ -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 { pub fn match(self: Route, request: *const jetzig.http.Request) bool {
if (self.method != request.method) return false; 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 request_path_it = std.mem.splitScalar(u8, request.path.base_path, '/');
var uri_path_it = std.mem.splitScalar(u8, self.uri_path, '/'); var uri_path_it = std.mem.splitScalar(u8, self.uri_path, '/');

View File

@ -6,6 +6,7 @@ const jetzig = @import("../../jetzig.zig");
data: *jetzig.data.Data, data: *jetzig.data.Data,
status_code: jetzig.http.status_codes.StatusCode = .ok, status_code: jetzig.http.status_codes.StatusCode = .ok,
content: ?[]const u8 = null,
pub fn deinit(self: Self) void { pub fn deinit(self: Self) void {
_ = self; _ = self;

View File

@ -0,0 +1,3 @@
pub const RoutedWebsocket = @import("websockets/Websocket.zig").RoutedWebsocket;
pub const Websocket = RoutedWebsocket(@import("root").routes);
pub const Context = @import("websockets/Websocket.zig").Context;

View File

@ -0,0 +1,136 @@
const std = @import("std");
const jetzig = @import("../../jetzig.zig");
const httpz = @import("httpz");
pub const Context = struct {
allocator: std.mem.Allocator,
route: jetzig.channels.Route,
session_id: []const u8,
channels: *jetzig.kv.Store.ChannelStore,
logger: jetzig.loggers.Logger,
};
pub fn RoutedWebsocket(Routes: type) type {
return struct {
allocator: std.mem.Allocator,
connection: *httpz.websocket.Conn,
channels: *jetzig.kv.Store.ChannelStore,
route: jetzig.channels.Route,
data: *jetzig.Data,
session_id: []const u8,
logger: jetzig.loggers.Logger,
const Websocket = @This();
const router = jetzig.channels.ActionRouter.initComptime(Routes);
pub fn init(connection: *httpz.websocket.Conn, context: Context) !Websocket {
const data = try context.allocator.create(jetzig.Data);
data.* = jetzig.Data.init(context.allocator);
return Websocket{
.allocator = context.allocator,
.connection = connection,
.route = context.route,
.session_id = context.session_id,
.channels = context.channels,
.logger = context.logger,
.data = data,
};
}
pub fn afterInit(websocket: *Websocket, context: Context) !void {
_ = context;
if (router.encoded_params.get(websocket.route.path)) |params| {
var stack_fallback = std.heap.stackFallback(4096, websocket.allocator);
const allocator = stack_fallback.get();
var write_buffer = websocket.connection.writeBuffer(allocator, .text);
defer write_buffer.deinit();
const writer = write_buffer.writer();
try writer.print("__jetzig_actions__:{s}", .{params});
try write_buffer.flush();
}
const func = websocket.route.openConnectionFn orelse return;
const channel = jetzig.channels.Channel{
.allocator = websocket.allocator,
.websocket = websocket,
.state = try websocket.getState(),
.data = websocket.data,
};
try func(channel);
}
pub fn clientMessage(websocket: *Websocket, allocator: std.mem.Allocator, data: []const u8) !void {
const channel = jetzig.channels.RoutedChannel(Routes){
.allocator = allocator,
.websocket = websocket,
.state = try websocket.getState(),
.data = websocket.data,
};
if (websocket.invoke(channel, data)) |maybe_action| {
if (maybe_action) |action| {
websocket.logger.DEBUG(
"Invoked Channel Action `{s}:{?s}`",
.{ websocket.route.path, action },
) catch {};
return;
}
} else |err| {
websocket.logger.logError(@errorReturnTrace(), err) catch {};
return;
}
const message = jetzig.channels.Message.init(allocator, channel, data);
websocket.route.receiveMessage(message) catch |err| {
websocket.logger.logError(@errorReturnTrace(), err) catch {};
};
websocket.logger.DEBUG("Routed Channel message for `{s}`", .{websocket.route.path}) catch {};
}
pub fn syncState(websocket: *Websocket, channel: jetzig.channels.RoutedChannel(Routes)) !void {
var stack_fallback = std.heap.stackFallback(4096, channel.allocator);
const allocator = stack_fallback.get();
var write_buffer = channel.websocket.connection.writeBuffer(allocator, .text);
defer write_buffer.deinit();
const writer = write_buffer.writer();
// TODO: Make this really fast.
try websocket.channels.put(websocket.session_id, channel.state);
try writer.print("__jetzig_channel_state__:{s}", .{try websocket.data.toJson()});
try write_buffer.flush();
websocket.logger.DEBUG("Synchronized Channel state for `{s}`", .{websocket.route.path}) catch {};
}
pub fn getState(websocket: *Websocket) !*jetzig.data.Value {
return try websocket.channels.get(websocket.data, websocket.session_id) orelse blk: {
const root = try websocket.data.root(.object);
try websocket.channels.put(websocket.session_id, root);
break :blk try websocket.channels.get(websocket.data, websocket.session_id) orelse error.JetzigInvalidChannel;
};
}
fn invoke(
websocket: *Websocket,
channel: jetzig.channels.RoutedChannel(Routes),
data: []const u8,
) !?[]const u8 {
return router.invoke(
websocket.allocator,
websocket.route.path,
data,
@TypeOf(channel),
channel,
);
}
};
}

View File

@ -8,6 +8,7 @@ pub const std_options = std.Options{
}; };
pub const jetzig_options = @import("main").jetzig_options; pub const jetzig_options = @import("main").jetzig_options;
pub const routes = @import("main").routes;
pub fn log( pub fn log(
comptime message_level: std.log.Level, comptime message_level: std.log.Level,

View File

@ -11,5 +11,7 @@ test {
_ = @import("jetzig/http/Path.zig"); _ = @import("jetzig/http/Path.zig");
_ = @import("jetzig/jobs/Job.zig"); _ = @import("jetzig/jobs/Job.zig");
_ = @import("jetzig/mail/Mail.zig"); _ = @import("jetzig/mail/Mail.zig");
_ = @import("jetzig/channels/Channel.zig");
_ = @import("jetzig/channels/Message.zig");
_ = @import("jetzig/loggers/LogQueue.zig"); _ = @import("jetzig/loggers/LogQueue.zig");
} }