forked from jetzig-mirror/jetzig
Compare commits
13 Commits
main
...
websockets
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74bb659528 | ||
|
|
94f67dd4a2 | ||
|
|
3bd296821c | ||
|
|
ff6d8cf942 | ||
|
|
6a4f99ca14 | ||
|
|
fa76b75c12 | ||
|
|
58403986d7 | ||
|
|
e9802bf546 | ||
|
|
8c2d6806b5 | ||
|
|
d3b3ae63cf | ||
|
|
9847efdf4a | ||
|
|
cd5a00d85f | ||
|
|
2072b59937 |
11
build.zig
11
build.zig
@ -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 },
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@ -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
139
demo/public/party.css
Normal 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
47
demo/public/party.js
Normal 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 = '🎉';
|
||||
// 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
83
demo/src/app/lib/Game.zig
Normal 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;
|
||||
}
|
||||
}
|
||||
1
demo/src/app/views/inertia/_head.zmpl
Normal file
1
demo/src/app/views/inertia/_head.zmpl
Normal file
@ -0,0 +1 @@
|
||||
<title>My Inertia App</title>
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
77
demo/src/app/views/websockets.zig
Normal file
77
demo/src/app/views/websockets.zig
Normal 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) });
|
||||
}
|
||||
};
|
||||
64
demo/src/app/views/websockets/index.zmpl
Normal file
64
demo/src/app/views/websockets/index.zmpl
Normal file
@ -0,0 +1,64 @@
|
||||
<link rel="stylesheet" href="/party.css" />
|
||||
|
||||
<div id="party-container"></div>
|
||||
|
||||
<div id="results-wrapper">
|
||||
<span class="trophy">🏆</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">🏆</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="board" id="board">
|
||||
@for (0..9) |index| {
|
||||
<div
|
||||
class="cell"
|
||||
jetzig-connect="$.cells.{{index}}"
|
||||
jetzig-transform="{ player: '✈️', cpu: '🦎', tie: '🤝' }[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>🏆</span>
|
||||
<span
|
||||
jetzig-connect="$.victor"
|
||||
jetzig-transform="{ player: '✈️', cpu: '🦎', tie: '🤝' }[value] || ''"
|
||||
></span>
|
||||
<span>🏆</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>
|
||||
@ -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,7 +16,9 @@ pub const jetzig_options = struct {
|
||||
pub const middleware: []const type = &.{
|
||||
// jetzig.middleware.AuthMiddleware,
|
||||
// jetzig.middleware.AntiCsrfMiddleware,
|
||||
// jetzig.middleware.HtmxMiddleware,
|
||||
jetzig.middleware.HtmxMiddleware,
|
||||
jetzig.middleware.ChannelsMiddleware,
|
||||
// jetzig.middleware.InertiaMiddleware,
|
||||
// jetzig.middleware.CompressionMiddleware,
|
||||
// @import("app/middleware/DemoMiddleware.zig"),
|
||||
};
|
||||
|
||||
246
src/Routes.zig
246
src/Routes.zig
@ -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.*,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -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,
|
||||
);
|
||||
|
||||
@ -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
7
src/jetzig/channels.zig
Normal 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);
|
||||
235
src/jetzig/channels/ActionRouter.zig
Normal file
235
src/jetzig/channels/ActionRouter.zig
Normal 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 = ¶ms };
|
||||
} 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)),
|
||||
},
|
||||
};
|
||||
}
|
||||
82
src/jetzig/channels/Channel.zig
Normal file
82
src/jetzig/channels/Channel.zig
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
54
src/jetzig/channels/Message.zig
Normal file
54
src/jetzig/channels/Message.zig
Normal 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");
|
||||
}
|
||||
36
src/jetzig/channels/Route.zig
Normal file
36
src/jetzig/channels/Route.zig
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
},
|
||||
|
||||
@ -6,10 +6,15 @@ const zmpl = @import("zmpl");
|
||||
const zmd = @import("zmd");
|
||||
const httpz = @import("httpz");
|
||||
|
||||
pub const RenderedView = struct { view: jetzig.views.View, content: []const u8 };
|
||||
|
||||
pub fn RoutedServer(Routes: type) type {
|
||||
return struct {
|
||||
allocator: std.mem.Allocator,
|
||||
logger: jetzig.loggers.Logger,
|
||||
env: jetzig.Environment,
|
||||
routes: []const *const jetzig.views.Route,
|
||||
channel_routes: std.StaticStringMap(jetzig.channels.Route),
|
||||
custom_routes: []const jetzig.views.Route,
|
||||
job_definitions: []const jetzig.JobDefinition,
|
||||
mailer_definitions: []const jetzig.MailerDefinition,
|
||||
@ -18,6 +23,7 @@ initialized: bool = false,
|
||||
store: *jetzig.kv.Store.GeneralStore,
|
||||
job_queue: *jetzig.kv.Store.JobQueueStore,
|
||||
cache: *jetzig.kv.Store.CacheStore,
|
||||
channels: *jetzig.kv.Store.ChannelStore,
|
||||
repo: *jetzig.database.Repo,
|
||||
global: *anyopaque,
|
||||
decoded_static_route_params: []const *jetzig.data.Value = &.{},
|
||||
@ -29,6 +35,7 @@ pub fn init(
|
||||
allocator: std.mem.Allocator,
|
||||
env: jetzig.Environment,
|
||||
routes: []const *const jetzig.views.Route,
|
||||
channel_routes: std.StaticStringMap(jetzig.channels.Route),
|
||||
custom_routes: []const jetzig.views.Route,
|
||||
job_definitions: []const jetzig.JobDefinition,
|
||||
mailer_definitions: []const jetzig.MailerDefinition,
|
||||
@ -36,6 +43,7 @@ pub fn init(
|
||||
store: *jetzig.kv.Store.GeneralStore,
|
||||
job_queue: *jetzig.kv.Store.JobQueueStore,
|
||||
cache: *jetzig.kv.Store.CacheStore,
|
||||
channels: *jetzig.kv.Store.ChannelStore,
|
||||
repo: *jetzig.database.Repo,
|
||||
global: *anyopaque,
|
||||
) Server {
|
||||
@ -44,6 +52,7 @@ pub fn init(
|
||||
.logger = env.logger,
|
||||
.env = env,
|
||||
.routes = routes,
|
||||
.channel_routes = channel_routes,
|
||||
.custom_routes = custom_routes,
|
||||
.job_definitions = job_definitions,
|
||||
.mailer_definitions = mailer_definitions,
|
||||
@ -51,6 +60,7 @@ pub fn init(
|
||||
.store = store,
|
||||
.job_queue = job_queue,
|
||||
.cache = cache,
|
||||
.channels = channels,
|
||||
.repo = repo,
|
||||
.global = global,
|
||||
};
|
||||
@ -61,10 +71,12 @@ pub fn deinit(self: *Server) void {
|
||||
self.allocator.free(self.env.bind);
|
||||
}
|
||||
|
||||
const Dispatcher = struct {
|
||||
const HttpzHandler = struct {
|
||||
server: *Server,
|
||||
|
||||
pub fn handle(self: Dispatcher, request: *httpz.Request, response: *httpz.Response) void {
|
||||
pub const WebsocketHandler = jetzig.websockets.RoutedWebsocket(Routes);
|
||||
|
||||
pub fn handle(self: HttpzHandler, request: *httpz.Request, response: *httpz.Response) void {
|
||||
self.server.processNextRequest(request, response) catch |err| {
|
||||
self.server.errorHandlerFn(request, response, err) catch {};
|
||||
};
|
||||
@ -77,7 +89,7 @@ pub fn listen(self: *Server) !void {
|
||||
const worker_count = jetzig.config.get(u16, "worker_count");
|
||||
const thread_count: u16 = jetzig.config.get(?u16, "thread_count") orelse @intCast(try std.Thread.getCpuCount());
|
||||
|
||||
var httpz_server = try httpz.Server(Dispatcher).init(
|
||||
var httpz_server = try httpz.Server(HttpzHandler).init(
|
||||
self.allocator,
|
||||
.{
|
||||
.port = self.env.port,
|
||||
@ -96,7 +108,7 @@ pub fn listen(self: *Server) !void {
|
||||
.max_body_size = jetzig.config.get(usize, "max_bytes_request_body"),
|
||||
},
|
||||
},
|
||||
Dispatcher{ .server = self },
|
||||
HttpzHandler{ .server = self },
|
||||
);
|
||||
defer httpz_server.deinit();
|
||||
|
||||
@ -142,14 +154,27 @@ pub fn processNextRequest(
|
||||
var response = try jetzig.http.Response.init(httpz_response.arena, httpz_response);
|
||||
var request = try jetzig.http.Request.init(
|
||||
httpz_response.arena,
|
||||
self,
|
||||
start_time,
|
||||
httpz_request,
|
||||
httpz_response,
|
||||
&response,
|
||||
&repo,
|
||||
self.env,
|
||||
self.routes,
|
||||
self.logger,
|
||||
self.store,
|
||||
self.cache,
|
||||
self.job_queue,
|
||||
self.job_definitions,
|
||||
self.mailer_definitions,
|
||||
self.global,
|
||||
);
|
||||
|
||||
if (try self.upgradeWebsocket(httpz_request, httpz_response, &request)) {
|
||||
try self.logger.DEBUG("Websocket upgrade request successful.", .{});
|
||||
return;
|
||||
}
|
||||
|
||||
try request.process();
|
||||
|
||||
var middleware_data = try jetzig.http.middleware.afterRequest(&request);
|
||||
@ -169,6 +194,42 @@ pub fn processNextRequest(
|
||||
try self.logger.logRequest(&request);
|
||||
}
|
||||
|
||||
/// Attempt to match a channel name to a view with a Channel implementation.
|
||||
pub fn matchChannelRoute(self: *const Server, channel_name: []const u8) ?jetzig.channels.Route {
|
||||
// TODO: Detect root path correctly.
|
||||
return self.channel_routes.get(channel_name);
|
||||
}
|
||||
|
||||
fn upgradeWebsocket(
|
||||
self: *const Server,
|
||||
httpz_request: *httpz.Request,
|
||||
httpz_response: *httpz.Response,
|
||||
request: *jetzig.http.Request,
|
||||
) !bool {
|
||||
const route = self.matchChannelRoute(request.path.view_name) orelse return false;
|
||||
const session = try request.session();
|
||||
const session_id = session.getT(.string, "_id") orelse blk: {
|
||||
try session.reset();
|
||||
break :blk session.getT(.string, "_id") orelse {
|
||||
try self.logger.ERROR("Error fetching session ID for websocket, aborting.", .{});
|
||||
return false;
|
||||
};
|
||||
};
|
||||
|
||||
return try httpz.upgradeWebsocket(
|
||||
jetzig.websockets.RoutedWebsocket(Routes),
|
||||
httpz_request,
|
||||
httpz_response,
|
||||
jetzig.websockets.Context{
|
||||
.allocator = self.allocator,
|
||||
.route = route,
|
||||
.session_id = session_id,
|
||||
.channels = self.channels,
|
||||
.logger = self.logger,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn maybeMiddlewareRender(request: *jetzig.http.Request, response: *const jetzig.http.Response) !bool {
|
||||
if (request.middleware_rendered) |_| {
|
||||
// Request processing ends when a middleware renders or redirects.
|
||||
@ -286,9 +347,7 @@ fn renderHTML(
|
||||
return request.setResponse(rendered_error, .{});
|
||||
};
|
||||
|
||||
return if (request.state == .redirected or
|
||||
request.state == .failed or
|
||||
request.dynamic_assigned_template != null)
|
||||
return if (request.isRendered() or request.dynamic_assigned_template != null)
|
||||
request.setResponse(rendered, .{})
|
||||
else
|
||||
request.setResponse(try self.renderNotFound(request), .{});
|
||||
@ -339,8 +398,6 @@ fn renderMarkdown(self: *Server, request: *jetzig.http.Request) !?RenderedView {
|
||||
}
|
||||
}
|
||||
|
||||
pub const RenderedView = struct { view: jetzig.views.View, content: []const u8 };
|
||||
|
||||
fn renderView(
|
||||
self: *Server,
|
||||
route: jetzig.views.Route,
|
||||
@ -373,6 +430,10 @@ fn renderView(
|
||||
|
||||
if (request.rendered_view) |rendered_view| {
|
||||
if (request.state == .redirected) return .{ .view = rendered_view, .content = "" };
|
||||
if (request.state == .rendered_content) return .{
|
||||
.view = rendered_view,
|
||||
.content = rendered_view.content.?,
|
||||
};
|
||||
|
||||
if (template) |capture| {
|
||||
return .{
|
||||
@ -415,7 +476,8 @@ fn renderTemplateWithLayout(
|
||||
) ![]const u8 {
|
||||
try addTemplateConstants(view, route);
|
||||
|
||||
const template_context = jetzig.TemplateContext{ .request = request };
|
||||
var template_context = jetzig.TemplateContext{ .request = request };
|
||||
template_context.middleware.context = &template_context;
|
||||
|
||||
if (request.getLayout(route)) |layout_name| {
|
||||
// TODO: Allow user to configure layouts directory other than src/app/views/layouts/
|
||||
@ -431,6 +493,7 @@ fn renderTemplateWithLayout(
|
||||
view.data,
|
||||
jetzig.TemplateContext,
|
||||
template_context,
|
||||
&.{},
|
||||
.{ .layout = layout },
|
||||
);
|
||||
} else {
|
||||
@ -439,6 +502,7 @@ fn renderTemplateWithLayout(
|
||||
view.data,
|
||||
jetzig.TemplateContext,
|
||||
template_context,
|
||||
&.{},
|
||||
.{},
|
||||
);
|
||||
}
|
||||
@ -446,6 +510,7 @@ fn renderTemplateWithLayout(
|
||||
view.data,
|
||||
jetzig.TemplateContext,
|
||||
template_context,
|
||||
&.{},
|
||||
.{},
|
||||
);
|
||||
}
|
||||
@ -609,6 +674,7 @@ fn renderErrorView(
|
||||
request.response_data,
|
||||
jetzig.TemplateContext,
|
||||
.{ .request = request },
|
||||
&.{},
|
||||
.{},
|
||||
),
|
||||
};
|
||||
@ -861,3 +927,5 @@ fn matchStaticOutput(
|
||||
else
|
||||
true; // We reached a params filter (possibly the default catch-all) with no params set.
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"));
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 {
|
||||
|
||||
35
src/jetzig/middleware/ChannelsMiddleware.zig
Normal file
35
src/jetzig/middleware/ChannelsMiddleware.zig
Normal 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"));
|
||||
}
|
||||
@ -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.",
|
||||
.{},
|
||||
);
|
||||
|
||||
6
src/jetzig/middleware/InertiaMiddleware.zig
Normal file
6
src/jetzig/middleware/InertiaMiddleware.zig
Normal file
@ -0,0 +1,6 @@
|
||||
const std = @import("std");
|
||||
const jetzig = @import("../../jetzig.zig");
|
||||
|
||||
// WIP
|
||||
|
||||
const InertiaMiddleware = @This();
|
||||
176
src/jetzig/middleware/channels/channels.js
Normal file
176
src/jetzig/middleware/channels/channels.js
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
})();
|
||||
3
src/jetzig/templates/channels.zmpl
Normal file
3
src/jetzig/templates/channels.zmpl
Normal file
@ -0,0 +1,3 @@
|
||||
@block head {
|
||||
<script>console.log("hello");</script>
|
||||
}
|
||||
13
src/jetzig/templates/inertia.zmpl
Normal file
13
src/jetzig/templates/inertia.zmpl
Normal 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>
|
||||
@ -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,
|
||||
|
||||
@ -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, '/');
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
3
src/jetzig/websockets.zig
Normal file
3
src/jetzig/websockets.zig
Normal 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;
|
||||
136
src/jetzig/websockets/Websocket.zig
Normal file
136
src/jetzig/websockets/Websocket.zig
Normal 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,
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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");
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user