╭ web.zig ──────────────────────────────────────────────────────╮ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │ ╰───────────────────────────────────────────────────────────────╯
╭────╮ ╭ clone ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╮ │http│ ssh │ │ ╰────╯ ╰ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ╯
1 const std = @import("std"); 2 const xit = @import("xit"); 3 const rp = xit.repo; 4 const ui = @import("./ui.zig"); 5 const xitui = xit.xitui; 6 const Focus = xitui.focus.Focus; 7 8 const Embed = struct { 9 path: []const u8, 10 content_type: []const u8, 11 body: []const u8, 12 }; 13 14 const embeds = [_]Embed{ 15 .{ .path = "index.html", .content_type = "text/html; charset=utf-8", .body = @embedFile("embed/index.html") }, 16 .{ .path = "script.js", .content_type = "text/javascript; charset=utf-8", .body = @embedFile("embed/script.js") }, 17 .{ .path = "term.ttf", .content_type = "font/ttf", .body = @embedFile("embed/term.ttf") }, 18 .{ .path = "haxy.wasm", .content_type = "application/wasm", .body = @embedFile("embed/haxy.wasm") }, 19 }; 20 21 pub fn run( 22 io: std.Io, 23 allocator: std.mem.Allocator, 24 net_server: *std.Io.net.Server, 25 tasks: *std.Io.Group, 26 admin_repo_path: []const u8, 27 err: *std.Io.Writer, 28 ) void { 29 const Listener = struct { 30 io: std.Io, 31 allocator: std.mem.Allocator, 32 net_server: *std.Io.net.Server, 33 tasks: *std.Io.Group, 34 admin_repo_path: []const u8, 35 err: *std.Io.Writer, 36 37 fn run(ctx: @This()) void { 38 while (true) { 39 const stream = ctx.net_server.accept(ctx.io) catch |accept_err| { 40 if (accept_err == error.Canceled) return; 41 logError(ctx.err, "web ui accept failed: {s}\n", .{@errorName(accept_err)}); 42 continue; 43 }; 44 45 const Connection = struct { 46 io: std.Io, 47 allocator: std.mem.Allocator, 48 stream: std.Io.net.Stream, 49 admin_repo_path: []const u8, 50 err: *std.Io.Writer, 51 52 fn run(conn: @This()) void { 53 defer conn.stream.close(conn.io); 54 handleConnection(conn.io, conn.allocator, conn.stream, conn.admin_repo_path, conn.err) catch |request_err| { 55 logError(conn.err, "web ui request failed: {s}\n", .{@errorName(request_err)}); 56 }; 57 } 58 }; 59 60 ctx.tasks.async(ctx.io, Connection.run, .{Connection{ 61 .io = ctx.io, 62 .allocator = ctx.allocator, 63 .stream = stream, 64 .admin_repo_path = ctx.admin_repo_path, 65 .err = ctx.err, 66 }}); 67 } 68 } 69 }; 70 71 tasks.async(io, Listener.run, .{Listener{ 72 .io = io, 73 .allocator = allocator, 74 .net_server = net_server, 75 .tasks = tasks, 76 .admin_repo_path = admin_repo_path, 77 .err = err, 78 }}); 79 } 80 81 fn renderIndexHtml(io: std.Io, allocator: std.mem.Allocator, admin_repo_path: []const u8) ![]const u8 { 82 const template = (findEmbed("/index.html") orelse return error.MissingIndexAsset).body; 83 84 // open the admin repo to read live user/repo data. 85 const repo_opts: rp.RepoOpts(.xit) = .{}; 86 const Repo = rp.Repo(.xit, repo_opts); 87 var repo = try Repo.open(io, allocator, .{ .path = admin_repo_path }); 88 defer repo.deinit(io, allocator); 89 var page_arena = std.heap.ArenaAllocator.init(allocator); 90 defer page_arena.deinit(); 91 const page: ui.Page = .{ .home = try .init(repo_opts, &page_arena, &repo) }; 92 93 var root = try ui.initRoot(allocator, &page); 94 defer root.deinit(); 95 96 const content = try generateHtml(allocator, &root); 97 defer allocator.free(content); 98 99 // serialize the page so the wasm side can parse it back without making 100 // a second request. base64-encoded so the json can be embedded in html. 101 var json: std.Io.Writer.Allocating = .init(allocator); 102 defer json.deinit(); 103 try std.json.Stringify.value(page, .{}, &json.writer); 104 105 const b64 = std.base64.standard.Encoder; 106 const json_b64 = try allocator.alloc(u8, b64.calcSize(json.written().len)); 107 defer allocator.free(json_b64); 108 _ = b64.encode(json_b64, json.written()); 109 110 var out: std.ArrayList(u8) = .empty; 111 errdefer out.deinit(allocator); 112 { 113 var cursor: usize = 0; 114 for (&[_]struct { needle: []const u8, replacement: []const u8 }{ 115 .{ .needle = "{{{ HAXY_HTML }}}", .replacement = content }, 116 .{ .needle = "{{{ HAXY_JSON }}}", .replacement = json_b64 }, 117 }) |sub| { 118 const idx = std.mem.indexOfPos(u8, template, cursor, sub.needle) orelse return error.MissingTemplateToken; 119 try out.appendSlice(allocator, template[cursor..idx]); 120 try out.appendSlice(allocator, sub.replacement); 121 cursor = idx + sub.needle.len; 122 } 123 try out.appendSlice(allocator, template[cursor..]); 124 } 125 return try out.toOwnedSlice(allocator); 126 } 127 128 fn handleConnection( 129 io: std.Io, 130 allocator: std.mem.Allocator, 131 stream: std.Io.net.Stream, 132 admin_repo_path: []const u8, 133 err: *std.Io.Writer, 134 ) !void { 135 var send_buffer = [_]u8{0} ** 4096; 136 var recv_buffer = [_]u8{0} ** 4096; 137 var conn_br = stream.reader(io, &recv_buffer); 138 var conn_bw = stream.writer(io, &send_buffer); 139 var http_server = std.http.Server.init(&conn_br.interface, &conn_bw.interface); 140 141 while (http_server.reader.state == .ready) { 142 var request = http_server.receiveHead() catch |receive_err| switch (receive_err) { 143 error.HttpConnectionClosing => break, 144 error.ReadFailed => break, 145 else => |e| return e, 146 }; 147 148 handleRequest(io, &http_server, &request, allocator, admin_repo_path) catch |request_err| { 149 try err.print("web ui request failed: {s}\n", .{@errorName(request_err)}); 150 try err.flush(); 151 if (http_server.reader.state == .received_head) { 152 http_server.reader.state = .ready; 153 } 154 try writeSimpleResponse(&http_server, 500, "Internal Server Error", "text/plain", @errorName(request_err)); 155 }; 156 try http_server.out.flush(); 157 break; 158 } 159 } 160 161 fn handleRequest( 162 io: std.Io, 163 http_server: *std.http.Server, 164 request: *std.http.Server.Request, 165 allocator: std.mem.Allocator, 166 admin_repo_path: []const u8, 167 ) !void { 168 const method = if (request.head.method == .HEAD) .GET else request.head.method; 169 if (method != .GET) { 170 if (http_server.reader.state == .received_head) { 171 http_server.reader.state = .ready; 172 } 173 try writeSimpleResponse(http_server, 405, "Method Not Allowed", "text/plain", "method not allowed"); 174 return; 175 } 176 177 const uri = try std.Uri.parseAfterScheme("", request.head.target); 178 const path = uri.path.percent_encoded; 179 180 if (std.mem.eql(u8, path, "/favicon.ico")) { 181 if (http_server.reader.state == .received_head) { 182 http_server.reader.state = .ready; 183 } 184 try writeStaticResponse(http_server, 204, "No Content", "image/x-icon", "", true); 185 return; 186 } 187 188 const embed = findEmbed(path) orelse { 189 if (http_server.reader.state == .received_head) { 190 http_server.reader.state = .ready; 191 } 192 try writeSimpleResponse(http_server, 404, "Not Found", "text/plain", "not found"); 193 return; 194 }; 195 196 if (http_server.reader.state == .received_head) { 197 http_server.reader.state = .ready; 198 } 199 200 if (std.mem.eql(u8, embed.path, "index.html")) { 201 const index_html = try renderIndexHtml(io, allocator, admin_repo_path); 202 defer allocator.free(index_html); 203 try writeStaticResponse(http_server, 200, "OK", embed.content_type, index_html, request.head.method == .HEAD); 204 } else { 205 try writeStaticResponse(http_server, 200, "OK", embed.content_type, embed.body, request.head.method == .HEAD); 206 } 207 } 208 209 fn findEmbed(request_path: []const u8) ?Embed { 210 const path = if (std.mem.eql(u8, request_path, "/")) 211 "index.html" 212 else if (request_path.len > 1 and request_path[0] == '/') 213 request_path[1..] 214 else 215 return null; 216 217 for (embeds) |embed| { 218 if (std.mem.eql(u8, path, embed.path)) return embed; 219 } 220 return null; 221 } 222 223 fn writeSimpleResponse( 224 http_server: *std.http.Server, 225 code: u16, 226 message: []const u8, 227 content_type: []const u8, 228 body: []const u8, 229 ) !void { 230 try http_server.out.print( 231 "HTTP/1.1 {d} {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n{s}", 232 .{ code, message, content_type, body.len, body }, 233 ); 234 } 235 236 fn writeStaticResponse( 237 http_server: *std.http.Server, 238 code: u16, 239 message: []const u8, 240 content_type: []const u8, 241 body: []const u8, 242 head_only: bool, 243 ) !void { 244 try http_server.out.print( 245 "HTTP/1.1 {d} {s}\r\nContent-Type: {s}\r\nContent-Length: {d}\r\n\r\n", 246 .{ code, message, content_type, body.len }, 247 ); 248 if (!head_only) { 249 try http_server.out.writeAll(body); 250 } 251 } 252 253 fn logError(err: *std.Io.Writer, comptime fmt: []const u8, args: anytype) void { 254 err.print(fmt, args) catch return; 255 err.flush() catch {}; 256 } 257 258 pub fn generateHtml(allocator: std.mem.Allocator, root: *ui.Widget) ![]const u8 { 259 const grid = root.getGrid() orelse return error.MissingGrid; 260 const root_focus = root.getFocus(); 261 262 var out: std.ArrayList(u8) = .empty; 263 errdefer out.deinit(allocator); 264 265 for (0..grid.size.height) |y| { 266 // wrap runs of cells belonging to a focusable widget in a span tagged 267 // with that widget's focus id. CSS paints them with a pointer cursor; 268 // JS reads the id on click and dispatches focus directly. 269 var current_id: ?usize = null; 270 for (0..grid.size.width) |x| { 271 const cell_id = cellFocusId(root_focus, x, y); 272 if (cell_id != current_id) { 273 if (current_id != null) try out.appendSlice(allocator, "</span>"); 274 if (cell_id) |id| { 275 var buf: [64]u8 = undefined; 276 const tag = try std.fmt.bufPrint(&buf, "<span class=\"clickable\" data-focus-id=\"{}\">", .{id}); 277 try out.appendSlice(allocator, tag); 278 } 279 current_id = cell_id; 280 } 281 const rune = grid.cells.items[try grid.cells.at(.{ y, x })].rune orelse " "; 282 try appendEscapedHtml(allocator, &out, rune); 283 } 284 if (current_id != null) try out.appendSlice(allocator, "</span>"); 285 try out.append(allocator, '\n'); 286 } 287 288 return try out.toOwnedSlice(allocator); 289 } 290 291 fn cellFocusId(focus: *Focus, x: usize, y: usize) ?usize { 292 var iter = focus.children.iterator(); 293 while (iter.next()) |entry| { 294 const child = entry.value_ptr.*; 295 if (!child.focus.focusable) continue; 296 const r = child.rect; 297 if (x >= r.x and y >= r.y and x < r.x + r.size.width and y < r.y + r.size.height) { 298 return entry.key_ptr.*; 299 } 300 } 301 return null; 302 } 303 304 fn appendEscapedHtml(allocator: std.mem.Allocator, out: *std.ArrayList(u8), input: []const u8) !void { 305 for (input) |ch| { 306 switch (ch) { 307 '&' => try out.appendSlice(allocator, "&amp;"), 308 '<' => try out.appendSlice(allocator, "&lt;"), 309 '>' => try out.appendSlice(allocator, "&gt;"), 310 '"' => try out.appendSlice(allocator, "&quot;"), 311 '\'' => try out.appendSlice(allocator, "&#39;"), 312 else => try out.append(allocator, ch), 313 } 314 } 315 } 316