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 {
const target = b.standardTargetOptions(.{});
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(
b.allocator,
&.{
.{ .prefix = "views", .path = &.{ "src", "app", "views" } },
.{ .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",
},
.zmpl = .{
.url = "https://github.com/jetzig-framework/zmpl/archive/c57fc9b83027e8c1459d9625c3509f59f0fb89f3.tar.gz",
.hash = "zmpl-0.0.1-SYFGBgdqAwDeA6xm4KAhpKoNrWs5CMQK6x447zhWclCs",
// .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",

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">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="/prism.css" />
{{context.middleware.renderHeader()}}
</head>
<body>
<main>{{zmpl.content}}</main>
<script src="/prism.js"></script>
{{context.middleware.renderFooter()}}
</body>
</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 {
try request.server.logger.INFO("id: {s}", .{id});
try request.logger.INFO("id: {s}", .{id});
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 static = @import("static");
pub const std_options = jetzig.std_options;
// Override default settings in `jetzig.config` here:
pub const jetzig_options = struct {
/// 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 = &.{
// jetzig.middleware.AuthMiddleware,
// jetzig.middleware.AntiCsrfMiddleware,
// jetzig.middleware.HtmxMiddleware,
// jetzig.middleware.CompressionMiddleware,
// @import("app/middleware/DemoMiddleware.zig"),
jetzig.middleware.HtmxMiddleware,
jetzig.middleware.ChannelsMiddleware,
// jetzig.middleware.InertiaMiddleware,
// jetzig.middleware.CompressionMiddleware,
// @import("app/middleware/DemoMiddleware.zig"),
};
// Maximum bytes to allow in request body.

View File

@ -11,9 +11,13 @@ mailers_path: []const u8,
buffer: std.ArrayList(u8),
dynamic_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,
const receive_message = "receiveMessage";
const Routes = @This();
const Function = struct {
@ -120,7 +124,9 @@ pub fn init(
.buffer = std.ArrayList(u8).init(allocator),
.static_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,
};
}
@ -130,6 +136,7 @@ pub fn deinit(self: *Routes) void {
self.buffer.deinit();
self.static_routes.deinit();
self.dynamic_routes.deinit();
self.channel_routes.deinit();
}
/// Generates the complete route set for the application
@ -137,6 +144,7 @@ pub fn generateRoutes(self: *Routes) ![]const u8 {
const writer = self.buffer.writer();
try writer.writeAll(
\\const std = @import("std");
\\const jetzig = @import("jetzig");
\\
\\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(
\\
\\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(
\\test {
\\
);
for (self.module_paths.items) |module_path| {
var it = self.module_paths.keyIterator();
while (it.next()) |module_path| {
try writer.print(
\\ _ = @import("{s}");
\\
, .{module_path});
, .{module_path.*});
}
try writer.writeAll(
@ -254,6 +285,10 @@ fn writeRoutes(self: *Routes, writer: anytype) !void {
for (view_routes.dynamic) |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);
@ -343,7 +378,7 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
unreachable;
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;
const id = jetzig.util.generateVariableName(&buf);
@ -367,8 +402,49 @@ fn writeRoute(self: *Routes, writer: std.ArrayList(u8).Writer, route: Function)
const RouteSet = struct {
dynamic: []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 {
const stat = try dir.statFile(path);
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 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;
for (self.ast.nodes.items(.tag), 0..) |tag, index| {
switch (tag) {
.fn_proto_multi, .fn_proto_one, .fn_proto_simple => |function_tag| {
var function = try self.parseFunction(function_tag, @enumFromInt(index), path, source);
if (function) |*capture| {
if (capture.args.len == 0) {
var maybe_function = try self.parseFunction(
function_tag,
@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(
"Expected at least 1 argument for view function `{s}` in `{s}`",
.{ capture.name, path },
.{ function.name, path },
);
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")) {
capture.static = jetzig.build_options.build_static;
capture.legacy = arg_index + 1 < capture.args.len;
try static_routes.append(capture.*);
function.static = jetzig.build_options.build_static;
function.legacy = arg_index + 1 < function.args.len;
try static_routes.append(function.*);
} else if (std.mem.eql(u8, try arg.typeBasename(), "Request")) {
capture.static = false;
capture.legacy = arg_index + 1 < capture.args.len;
try dynamic_routes.append(capture.*);
function.static = false;
function.legacy = arg_index + 1 < function.args.len;
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;
}
},
.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 => {},
}
}
@ -447,9 +552,87 @@ fn generateRoutesForView(self: *Routes, dir: std.fs.Dir, path: []const u8) !Rout
return .{
.dynamic = dynamic_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`.
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;
@ -618,6 +801,10 @@ fn parseFunction(
var it = fn_proto.iterate(&self.ast);
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| {
const arg_name = self.ast.tokenSlice(arg_token);
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 {
switch (node.tag) {
// 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 => {
var buf = std.ArrayList([]const u8).init(self.allocator);
defer buf.deinit();
@ -663,6 +850,14 @@ fn parseTypeExpr(self: *Routes, node: std.zig.Ast.Node) ![]const u8 {
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;
}
@ -671,7 +866,7 @@ fn isActionFunctionName(name: []const u8) bool {
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 {
@ -803,3 +998,18 @@ fn writeJobs(self: Routes, writer: anytype) !void {
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| {
view.data.content = .{ .data = content };
return try layout.render(view.data, jetzig.TemplateContext, .{}, .{});
return try layout.render(view.data, jetzig.TemplateContext, .{}, &.{}, .{});
} else {
std.debug.print("Unknown layout: {s}\n", .{layout_name});
return content;
@ -174,6 +174,7 @@ fn renderZmplTemplate(
view.data,
jetzig.TemplateContext,
.{},
&.{},
.{ .layout = layout },
);
} else {
@ -181,7 +182,7 @@ fn renderZmplTemplate(
return try allocator.dupe(u8, "");
}
} else {
return try template.render(view.data, jetzig.TemplateContext, .{}, .{});
return try template.render(view.data, jetzig.TemplateContext, .{}, &.{}, .{});
}
} 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 debug = @import("jetzig/debug.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 Time = jetcommon.types.Time;
@ -38,6 +40,20 @@ pub const environment = @field(
@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
/// `src/main.zig` and call `start` to launch the application.
pub const App = @import("jetzig/App.zig");

View File

@ -11,7 +11,7 @@ env: jetzig.Environment,
allocator: std.mem.Allocator,
custom_routes: std.ArrayList(jetzig.views.Route),
initHook: ?*const fn (*App) anyerror!void,
server: *jetzig.http.Server = undefined,
server: *anyopaque = undefined,
pub fn deinit(self: *const App) void {
@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();
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 {
for (routes) |var_route| {
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);
};
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);
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);
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);
defer repo.deinit();
@ -81,10 +97,11 @@ pub fn start(self: *const App, routes_module: type, options: AppOptions) !void {
std.process.exit(0);
}
var server = jetzig.http.Server.init(
var server = jetzig.http.Server.RoutedServer(routes_module).init(
self.allocator,
self.env,
routes,
channel_routes,
self.custom_routes.items,
if (@hasDecl(routes_module, "jobs")) &routes_module.jobs 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,
&job_queue,
&cache,
&channels,
&repo,
options.global,
);

View File

@ -1,13 +1,43 @@
const std = @import("std");
pub const http = @import("http.zig");
pub const views = @import("views.zig");
pub const config = @import("config.zig");
/// Context available in every Zmpl template as `context`.
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.
/// Use to create a form element which can be verified by `AntiCsrfMiddleware`.
pub fn authenticityToken(self: TemplateContext) !?[]const u8 {
return if (self.request) |request|
try request.authenticityToken()
@ -15,6 +45,8 @@ pub fn authenticityToken(self: TemplateContext) !?[]const u8 {
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 {
return if (self.request) |request| blk: {
const token = try request.authenticityToken();
@ -23,3 +55,10 @@ pub fn authenticityFormElement(self: TemplateContext) !?[]const u8 {
, .{ config.get([]const u8, "authenticity_token_name"), token });
} 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.
pub const smtp: mail.SMTPConfig = .{
.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 {
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| {
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);
}
/// 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.
pub fn getAll(self: Headers, name: []const u8) []const []const u8 {
var headers = std.ArrayList([]const u8).init(self.allocator);

View File

@ -13,6 +13,7 @@ path: []const u8,
base_path: []const u8,
directory: []const u8,
file_path: []const u8,
view_name: []const u8,
resource_id: []const u8,
extension: ?[]const u8,
query: ?[]const u8,
@ -29,6 +30,7 @@ pub fn init(path: []const u8) Path {
.base_path = base_path,
.directory = getDirectory(base_path),
.file_path = getFilePath(path),
.view_name = std.mem.trimLeft(u8, base_path, "/"),
.resource_id = getResourceId(base_path),
.extension = getExtension(path),
.query = getQuery(path),
@ -414,3 +416,8 @@ test ".method (/foo/bar/1/_PATCH" {
const path = Path.init("/foo/bar/1/_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
processed, // Request headers have been processed
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
@ -26,7 +28,7 @@ allocator: std.mem.Allocator,
path: jetzig.http.Path,
method: Method,
headers: jetzig.http.Headers,
server: *jetzig.http.Server,
host: []const u8,
httpz_request: *httpz.Request,
httpz_response: *httpz.Response,
response: *jetzig.http.Response,
@ -52,8 +54,14 @@ rendered_view: ?jetzig.views.View = null,
start_time: i128,
store: RequestStore(jetzig.kv.Store.GeneralStore),
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,
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.
pub fn RequestStore(T: type) type {
@ -122,12 +130,20 @@ pub fn RequestStore(T: type) type {
pub fn init(
allocator: std.mem.Allocator,
server: *jetzig.http.Server,
start_time: i128,
httpz_request: *httpz.Request,
httpz_response: *httpz.Response,
response: *jetzig.http.Response,
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 {
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);
response_data.* = jetzig.data.Data.init(allocator);
const headers = jetzig.http.Headers.init(allocator, httpz_request.headers);
const host = headers.getLower("host") orelse "";
return .{
.allocator = allocator,
.path = path,
.method = method,
.headers = jetzig.http.Headers.init(allocator, httpz_request.headers),
.server = server,
.headers = headers,
.host = host,
.response = response,
.response_data = response_data,
.httpz_request = httpz_request,
.httpz_response = httpz_response,
.start_time = start_time,
.store = .{ .store = server.store, .allocator = allocator },
.cache = .{ .store = server.cache, .allocator = allocator },
.store = .{ .store = store, .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,
.global = if (@hasField(jetzig.Global, "__jetzig_default"))
undefined
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.?;
}
/// 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
/// trigger an error).
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 {
return switch (self.state) {
.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);
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"));
@ -305,7 +346,8 @@ pub fn renderRedirect(self: *Request, state: RedirectState) !void {
break :blk try template.render(
self.response_data,
jetzig.TemplateContext,
.{ .request = self },
template_context,
&.{},
.{},
);
} 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`.
pub fn session(self: *Request) !*jetzig.http.Session {
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;
const local_session = try self.allocator.create(jetzig.http.Session);
local_session.* = jetzig.http.Session.init(
self.allocator,
try self.cookies(),
self.server.env.secret,
self.env.secret,
.{ .cookie_name = cookie_name },
);
local_session.parse() catch |err| {
switch (err) {
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();
},
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);
background_job.* = jetzig.Job.init(
self.allocator,
self.server.store,
self.server.job_queue,
self.server.cache,
self.server.logger,
self.server.job_definitions,
self.store.store,
self.job_queue.store,
self.cache.store,
self.logger,
self.job_definitions,
job_name,
);
return background_job;
@ -607,14 +649,14 @@ const RequestMail = struct {
self.request.allocator,
mail_job.params,
jetzig.jobs.JobEnv{
.vars = self.request.server.env.vars,
.environment = self.request.server.env.environment,
.logger = self.request.server.logger,
.routes = self.request.server.routes,
.mailers = self.request.server.mailer_definitions,
.jobs = self.request.server.job_definitions,
.store = self.request.server.store,
.cache = self.request.server.cache,
.vars = self.request.env.vars,
.environment = self.request.env.environment,
.logger = self.request.logger,
.routes = self.request.routes,
.mailers = self.request.mailer_definitions,
.jobs = self.request.job_definitions,
.store = self.request.store.store,
.cache = self.request.cache.store,
.mutex = undefined,
.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,
data: jetzig.data.Data,
state: enum { parsed, pending } = .pending,
id: [32]u8 = undefined,
const Self = @This();
@ -48,7 +49,11 @@ pub fn parse(self: *Self) !void {
/// Reset session to an empty state.
pub fn reset(self: *Self) !void {
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;
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.
pub fn getT(
self: *Self,
self: Self,
comptime T: jetzig.data.ValueType,
key: []const u8,
) @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| {
if (comptime @hasDecl(middleware, "afterLaunch")) {
try middleware.afterLaunch(server);
@ -94,6 +94,38 @@ pub fn afterRequest(request: *jetzig.http.Request) !MiddlewareData {
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(
middleware_data: *MiddlewareData,
request: *jetzig.http.Request,

View File

@ -22,6 +22,9 @@ pub const Store = struct {
/// Store ephemeral data.
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.
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.
pub const Role = enum { jobs, cache, general, custom };
pub const Role = enum { jobs, cache, general, channels, custom };
pub fn Store(comptime options: KVOptions) type {
return struct {

View File

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

View File

@ -5,6 +5,8 @@ pub const HtmxMiddleware = @import("middleware/HtmxMiddleware.zig");
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,

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 {
_ = 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 {

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.
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.",
.{},
);

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,
store: *MemoryStore,
cache: *MemoryStore,
channels: *MemoryStore,
job_queue: *MemoryStore,
multipart_boundary: ?[]const u8 = null,
logger: jetzig.loggers.Logger,
server: Server,
server: *jetzig.http.Server.RoutedServer(@import("root").routes),
repo: *jetzig.database.Repo,
cookies: *jetzig.http.Cookies,
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);
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{
.arena = arena,
.allocator = allocator,
.routes = &routes_module.routes,
.store = try createStore(arena.allocator(), logger, .general),
.cache = try createStore(arena.allocator(), logger, .cache),
.channels = try createStore(arena.allocator(), logger, .channels),
.job_queue = try createStore(arena.allocator(), logger, .jobs),
.logger = logger,
.server = .{ .logger = logger },
.server = server,
.repo = repo,
.cookies = cookies,
.session = session,
@ -131,7 +155,7 @@ pub fn request(
.env_map = std.process.EnvMap.init(allocator),
.env_file = null,
};
var server = jetzig.http.Server{
var server = jetzig.http.Server.RoutedServer(@import("root").routes){
.allocator = allocator,
.logger = self.logger,
.env = .{
@ -148,12 +172,14 @@ pub fn request(
.secret = jetzig.testing.secret,
},
.routes = routes,
.channel_routes = std.StaticStringMap(jetzig.channels.Route).initComptime(.{}),
.custom_routes = &.{},
.mailer_definitions = &.{},
.job_definitions = &.{},
.mime_map = jetzig.testing.mime_map,
.store = self.store,
.cache = self.cache,
.channels = self.channels,
.job_queue = self.job_queue,
.global = undefined,
.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 {
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, '/');

View File

@ -6,6 +6,7 @@ const jetzig = @import("../../jetzig.zig");
data: *jetzig.data.Data,
status_code: jetzig.http.status_codes.StatusCode = .ok,
content: ?[]const u8 = null,
pub fn deinit(self: Self) void {
_ = 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 routes = @import("main").routes;
pub fn log(
comptime message_level: std.log.Level,

View File

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