From 402888423de9764c22175df4cc41d79157895f3d Mon Sep 17 00:00:00 2001 From: Nathan Reiner Date: Tue, 11 Nov 2025 10:34:59 +0100 Subject: add basic server --- .gitmodules | 3 ++ flake.nix | 5 ++- src/ansi.zig | 94 ++++++++++++++++++++++++++++++++++++++++++++ src/http.zig | 101 ++++++++++++++++++++++++++++++++++++++++++++++++ src/log.zig | 56 +++++++++++++++++++++++++++ src/main.zig | 57 ++++++++++++++++++--------- src/root.zig | 22 +---------- src/routes/fallback.zig | 32 +++++++++++++++ src/routes/root.zig | 15 +++++++ static/index.html | 9 +++++ static/index.js | 1 + static/sfw | 1 + 12 files changed, 356 insertions(+), 40 deletions(-) create mode 100644 .gitmodules create mode 100644 src/ansi.zig create mode 100644 src/http.zig create mode 100644 src/log.zig create mode 100644 src/routes/fallback.zig create mode 100644 src/routes/root.zig create mode 100644 static/index.html create mode 100644 static/index.js create mode 160000 static/sfw diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..96f580f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "static/sfw"] + path = static/sfw + url = https://git.nathanreiner.xyz/sfw diff --git a/flake.nix b/flake.nix index 8809297..bcf3861 100644 --- a/flake.nix +++ b/flake.nix @@ -11,7 +11,10 @@ in { devShells.x86_64-linux.default = pkgs.mkShell { - packages = [ pkgs.zig ]; + packages = [ + pkgs.zig + pkgs.psmisc + ]; }; }; } diff --git a/src/ansi.zig b/src/ansi.zig new file mode 100644 index 0000000..44f5de6 --- /dev/null +++ b/src/ansi.zig @@ -0,0 +1,94 @@ +const std = @import("std"); + +fn write_csi(writer: anytype, args: anytype, command: u8) !void { + try writer.writeAll("\x1b["); + + inline for (args, 0..) |code, index| { + try writer.print("{}", .{code}); + + if (index < args.len - 1) { + try writer.writeByte(';'); + } + } + + try writer.writeByte(command); +} + +pub const Color = enum(u8) { + const Self = @This(); + + black = 0, + red = 1, + green = 2, + yellow = 3, + blue = 4, + magenta = 5, + cyan = 6, + white = 7, + gray = 60, + bright_red = 61, + bright_green = 62, + bright_yellow = 63, + bright_blue = 64, + bright_magenta = 65, + bright_cyan = 66, + bright_white = 67, + + const foreground_offset = 30; + const background_offset = 40; + + pub fn to_fg(self: Self) u8 { + return @intFromEnum(self) + foreground_offset; + } + + pub fn to_bg(self: Self) u8 { + return @intFromEnum(self) + background_offset; + } +}; + +pub const Styled = struct { + const Self = @This(); + + text: []const u8, + fg: Color = .white, + bg: Color = .black, + bold: bool = false, + dim: bool = false, + italic: bool = false, + underline: bool = false, + + const Codes = struct { + const reset = 0; + const bold = 1; + const dim = 2; + const italic = 3; + const underline = 4; + }; + + pub fn format( + self: *const Self, + writer: *std.Io.Writer, + ) !void { + try write_csi(writer, .{self.fg.to_fg()}, 'm'); + try write_csi(writer, .{self.bg.to_bg()}, 'm'); + + if (self.bold) try write_csi(writer, .{Codes.bold}, 'm'); + if (self.dim) try write_csi(writer, .{Codes.dim}, 'm'); + if (self.italic) try write_csi(writer, .{Codes.italic}, 'm'); + if (self.underline) try write_csi(writer, .{Codes.underline}, 'm'); + + try writer.writeAll(self.text); + + try write_csi(writer, .{Codes.reset}, 'm'); + } +}; + +pub const ClearLine = struct { + pub fn format( + self: *const @This(), + writer: *std.Io.Writer, + ) !void { + _ = self; + try writer.writeAll("\x1b[2K\r"); + } +}; diff --git a/src/http.zig b/src/http.zig new file mode 100644 index 0000000..e8ecfcf --- /dev/null +++ b/src/http.zig @@ -0,0 +1,101 @@ +const std = @import("std"); + +pub fn respond_file( + request: *std.http.Server.Request, + file: std.fs.File, + file_name: []const u8, + allocator: std.mem.Allocator, +) !void { + const buffer = try file.readToEndAlloc(allocator, std.math.maxInt(usize)); + defer allocator.free(buffer); + + const suffix_pos = std.mem.lastIndexOfScalar(u8, file_name, '.') orelse file_name.len; + const suffix = file_name[suffix_pos..]; + std.debug.print("{s}, {}, suffix: {s}\n", .{file_name, suffix_pos, suffix}); + + try request.respond(buffer, .{ + .extra_headers = &.{ + .{ .name = "Content-Type", .value = content_types.get(suffix) orelse "text/plain" }, + } + }); +} + +pub const content_types = std.StaticStringMap([]const u8).initComptime(.{ + .{ ".aac", "audio/aac" }, + .{ ".abw", "application/x-abiword" }, + .{ ".apng", "image/apng" }, + .{ ".arc", "application/x-freearc" }, + .{ ".avif", "image/avif" }, + .{ ".avi", "video/x-msvideo" }, + .{ ".azw", "application/vnd.amazon.ebook" }, + .{ ".bin", "application/octet-stream" }, + .{ ".bmp", "image/bmp" }, + .{ ".bz", "application/x-bzip" }, + .{ ".bz2", "application/x-bzip2" }, + .{ ".cda", "application/x-cdf" }, + .{ ".csh", "application/x-csh" }, + .{ ".css", "text/css" }, + .{ ".csv", "text/csv" }, + .{ ".doc", "application/msword" }, + .{ ".docx", "application/vnd.openxmlformats-officedocument.wordprocessingml.document" }, + .{ ".eot", "application/vnd.ms-fontobject" }, + .{ ".epub", "application/epub+zip" }, + .{ ".gz", "application/gzip." }, + .{ ".gif", "image/gif" }, + .{ ".htm", "text/html" }, + .{ ".html", "text/html" }, + .{ ".ico", "image/vnd.microsoft.icon" }, + .{ ".ics", "text/calendar" }, + .{ ".jar", "application/java-archive" }, + .{ ".jpeg, .jpg", "image/jpeg" }, + .{ ".js", "text/javascript" }, + .{ ".json", "application/json" }, + .{ ".jsonld", "application/ld+json" }, + .{ ".md", "text/markdown" }, + .{ ".mid, .midi", "audio/midi," }, + .{ ".mjs", "text/javascript" }, + .{ ".mp3", "audio/mpeg" }, + .{ ".mp4", "video/mp4" }, + .{ ".mpeg", "video/mpeg" }, + .{ ".mpkg", "application/vnd.apple.installer+xml" }, + .{ ".odp", "application/vnd.oasis.opendocument.presentation" }, + .{ ".ods", "application/vnd.oasis.opendocument.spreadsheet" }, + .{ ".odt", "application/vnd.oasis.opendocument.text" }, + .{ ".oga", "audio/ogg" }, + .{ ".ogv", "video/ogg" }, + .{ ".ogx", "application/ogg" }, + .{ ".opus", "audio/ogg" }, + .{ ".otf", "font/otf" }, + .{ ".png", "image/png" }, + .{ ".pdf", "application/pdf" }, + .{ ".php", "application/x-httpd-php" }, + .{ ".ppt", "application/vnd.ms-powerpoint" }, + .{ ".pptx", "application/vnd.openxmlformats-officedocument.presentationml.presentation" }, + .{ ".rar", "application/vnd.rar" }, + .{ ".rtf", "application/rtf" }, + .{ ".sh", "application/x-sh" }, + .{ ".svg", "image/svg+xml" }, + .{ ".tar", "application/x-tar" }, + .{ ".tif, .tiff", "image/tiff" }, + .{ ".ts", "video/mp2t" }, + .{ ".ttf", "font/ttf" }, + .{ ".txt", "text/plain" }, + .{ ".vsd", "application/vnd.visio" }, + .{ ".wav", "audio/wav" }, + .{ ".weba", "audio/webm" }, + .{ ".webm", "video/webm" }, + .{ ".webmanifest", "application/manifest+json" }, + .{ ".webp", "image/webp" }, + .{ ".woff", "font/woff" }, + .{ ".woff2", "font/woff2" }, + .{ ".xhtml", "application/xhtml+xml" }, + .{ ".xls", "application/vnd.ms-excel" }, + .{ ".xlsx", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" }, + .{ ".xml", "application/xml" }, + .{ ".xul", "application/vnd.mozilla.xul+xml" }, + .{ ".zip", "application/zip." }, + .{ ".3gp", "video/3gpp;" }, + .{ ".3g2", "video/3gpp2;" }, + .{ ".7z", "application/x-7z-compressed" }, +}); + diff --git a/src/log.zig b/src/log.zig new file mode 100644 index 0000000..9d2b444 --- /dev/null +++ b/src/log.zig @@ -0,0 +1,56 @@ +const std = @import("std"); + +const ansi = @import("ansi.zig"); + +var log_buffer: [1024]u8 = undefined; +var writer_stderr = std.fs.File.stderr().writer(&log_buffer); +const stderr = &writer_stderr.interface; +var log_lock = std.Thread.Mutex{}; + +pub fn handler( + comptime message_level: std.log.Level, + comptime scope: @TypeOf(.enum_literal), + comptime format: []const u8, + args: anytype, +) void { + log_lock.lock(); + defer log_lock.unlock(); + defer stderr.flush() catch unreachable; + + const color = switch (message_level) { + .err => .red, + .warn => .yellow, + .info => .blue, + .debug => .magenta, + }; + + stderr.print( + "{f}{f}", + .{ + ansi.ClearLine {}, + ansi.Styled { + .text = " " ++ (comptime message_level.asText()) ++ " ", + .bg = color, + .bold = true, + }, + }, + ) catch unreachable; + + if (scope != .default) { + stderr.print( + "{f}", + .{ + ansi.Styled { + .text = " " ++ @tagName(scope) ++ " ", + .bg = .gray, + .fg = .black, + .italic = true, + }, + }, + ) catch unreachable; + } + + stderr.writeByte(' ') catch unreachable; + stderr.print(format, args) catch unreachable; + stderr.writeAll("\n") catch unreachable; +} diff --git a/src/main.zig b/src/main.zig index b5260bc..b98b112 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,27 +1,46 @@ const std = @import("std"); const storyboard = @import("storyboard"); +pub const std_options = std.Options { + .logFn = storyboard.log.handler, +}; + +const log = std.log.scoped(.main); + pub fn main() !void { - // Prints to stderr, ignoring potential errors. - std.debug.print("All your {s} are belong to us.\n", .{"codebase"}); - try storyboard.bufferedPrint(); -} + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer std.debug.assert(gpa.deinit() == .ok); -test "simple test" { - const gpa = std.testing.allocator; - var list: std.ArrayList(i32) = .empty; - defer list.deinit(gpa); // Try commenting this out and see if zig detects the memory leak! - try list.append(gpa, 42); - try std.testing.expectEqual(@as(i32, 42), list.pop()); -} + const allocator = gpa.allocator(); + + const address = try std.net.Address.parseIpAndPort("127.0.0.1:8080"); + var net_server = try address.listen(.{}); + defer net_server.deinit(); + + log.info("listening on {f}", .{address}); + + while (true) { + const connection = net_server.accept() catch |err| { + log.err("error: {}", .{err}); + continue; + }; + defer connection.stream.close(); + + var read_buf: [1024 * 8]u8 = undefined; + var write_buf: [1024 * 8]u8 = undefined; + var reader = connection.stream.reader(&read_buf); + var writer = connection.stream.writer(&write_buf); + var http_server = std.http.Server.init(reader.interface(), &writer.interface); + + while (true) { + var request = http_server.receiveHead() catch break; + log.info("{} {s}", .{request.head.method, request.head.target}); + + const handler = storyboard.routes.get(request.head.target); -test "fuzz example" { - const Context = struct { - fn testOne(context: @This(), input: []const u8) anyerror!void { - _ = context; - // Try passing `--fuzz` to `zig build test` and see if it manages to fail this test case! - try std.testing.expect(!std.mem.eql(u8, "canyoufindme", input)); + handler(&request, allocator) catch |err| { + log.err("handler({s}): {}", .{request.head.target, err}); + }; } - }; - try std.testing.fuzz(Context{}, Context.testOne, .{}); + } } diff --git a/src/root.zig b/src/root.zig index 94c7cd0..87f1892 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,23 +1,5 @@ //! By convention, root.zig is the root source file when making a library. const std = @import("std"); +pub const log = @import("log.zig"); +pub const routes = @import("routes/root.zig"); -pub fn bufferedPrint() !void { - // Stdout is for the actual output of your application, for example if you - // are implementing gzip, then only the compressed bytes should be sent to - // stdout, not any debugging messages. - var stdout_buffer: [1024]u8 = undefined; - var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); - const stdout = &stdout_writer.interface; - - try stdout.print("Run `zig build test` to run the tests.\n", .{}); - - try stdout.flush(); // Don't forget to flush! -} - -pub fn add(a: i32, b: i32) i32 { - return a + b; -} - -test "basic add functionality" { - try std.testing.expect(add(3, 7) == 10); -} diff --git a/src/routes/fallback.zig b/src/routes/fallback.zig new file mode 100644 index 0000000..78d778b --- /dev/null +++ b/src/routes/fallback.zig @@ -0,0 +1,32 @@ +const std = @import("std"); +const http = @import("../http.zig"); + +const log = std.log.scoped(.fallback); + +pub fn handler( + request: *std.http.Server.Request, + allocator: std.mem.Allocator, +) anyerror!void { + var static = try std.fs.cwd().openDir("static", .{}); + defer static.close(); + + if (static.openFile(request.head.target[1..], .{})) |file| { + defer file.close(); + try http.respond_file(request, file, request.head.target[1..], allocator); + } else |_| { + var subdir = if (request.head.target.len == 1) static + else (static.openDir(request.head.target[1..], .{}) catch { + try request.respond("Not Found", .{ .status = .not_found }); + return; + }); + defer if (request.head.target.len > 1) subdir.close(); + + if (subdir.openFile("index.html", .{})) |file| { + defer file.close(); + try http.respond_file(request, file, "index.html", allocator); + } else |_| { + log.warn("File '{s}' Not Found", .{ request.head.target }); + try request.respond("Not Found", .{ .status = .not_found }); + } + } +} diff --git a/src/routes/root.zig b/src/routes/root.zig new file mode 100644 index 0000000..0330404 --- /dev/null +++ b/src/routes/root.zig @@ -0,0 +1,15 @@ +const std = @import("std"); + +pub const fallback = @import("fallback.zig"); + +pub const Handler = *const fn ( + request: *std.http.Server.Request, + allocator: std.mem.Allocator, +) anyerror!void; + +pub const handlers = std.StaticStringMap(Handler).initComptime(.{ +}); + +pub fn get(path: []const u8) Handler { + return handlers.get(path) orelse fallback.handler; +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..cd0d6de --- /dev/null +++ b/static/index.html @@ -0,0 +1,9 @@ + + + + Storyboard + + + + + diff --git a/static/index.js b/static/index.js new file mode 100644 index 0000000..8764baf --- /dev/null +++ b/static/index.js @@ -0,0 +1 @@ +console.log('hello, world!'); diff --git a/static/sfw b/static/sfw new file mode 160000 index 0000000..bf1fef8 --- /dev/null +++ b/static/sfw @@ -0,0 +1 @@ +Subproject commit bf1fef8933e090ec92dbb04c66f9c868044c242f -- cgit v1.2.3-70-g09d2