diff options
-rw-r--r-- | .gitignore | 3 | ||||
-rw-r--r-- | Makefile | 27 | ||||
-rw-r--r-- | build.zig | 71 | ||||
-rw-r--r-- | src/main.zig | 194 |
4 files changed, 295 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6357e7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Ignore zig compiler generated dirs +zig-cache/ +zig-out/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..bcb7224 --- /dev/null +++ b/Makefile @@ -0,0 +1,27 @@ +ZIG_DOPTIMIZE := -Doptimize=ReleaseSafe +ZIG_DTARGET := -Dtarget=native +ZIG_DCPU := -Dcpu=native + +all: + zig build ${ZIG_DOPTIMIZE} ${ZIG_DTARGET} ${ZIG_DCPU} + +debug: + zig build -Doptimize=Debug ${ZIG_DTARGET} ${ZIG_DCPU} + +fast: + zig build -Doptimize=ReleaseFast ${ZIG_DTARGET} ${ZIG_DCPU} + +safe: + zig build -Doptimize=ReleaseSafe ${ZIG_DTARGET} ${ZIG_DCPU} + +small: + zig build -Doptimize=ReleaseSmall ${ZIG_DTARGET} ${ZIG_DCPU} + +clean: + rm -rf zig-cache/ zig-out/ + +run: + zig build run + +install: zig-out/bin/zhttpd + install -Dm755 zig-out/bin/zhttpd ~/.local/bin/zhttpd diff --git a/build.zig b/build.zig new file mode 100644 index 0000000..a02d270 --- /dev/null +++ b/build.zig @@ -0,0 +1,71 @@ +const std = @import("std"); + +// Although this function looks imperative, note that its job is to +// declaratively construct a build graph that will be executed by an external +// runner. +pub fn build(b: *std.Build) void { + // Standard target options allows the person running `zig build` to choose + // what target to build for. Here we do not override the defaults, which + // means any target is allowed, and the default is native. Other options + // for restricting supported target set are available. + const target = b.standardTargetOptions(.{}); + + // Standard optimization options allow the person running `zig build` to select + // between Debug, ReleaseSafe, ReleaseFast, and ReleaseSmall. Here we do not + // set a preferred release mode, allowing the user to decide how to optimize. + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "zhttpd", + // In this case the main source file is merely a path, however, in more + // complicated build scripts, this could be a generated file. + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .link_libc = true, + .optimize = optimize, + }); + + // This declares intent for the executable to be installed into the + // standard location when the user invokes the "install" step (the default + // step when running `zig build`). + b.installArtifact(exe); + + // This *creates* a Run step in the build graph, to be executed when another + // step is evaluated that depends on it. The next line below will establish + // such a dependency. + const run_cmd = b.addRunArtifact(exe); + + // By making the run step depend on the install step, it will be run from the + // installation directory rather than directly from within the cache directory. + // This is not necessary, however, if the application depends on other installed + // files, this ensures they will be present and in the expected location. + run_cmd.step.dependOn(b.getInstallStep()); + + // This allows the user to pass arguments to the application in the build + // command itself, like this: `zig build run -- arg1 arg2 etc` + if (b.args) |args| { + run_cmd.addArgs(args); + } + + // This creates a build step. It will be visible in the `zig build --help` menu, + // and can be selected like this: `zig build run` + // This will evaluate the `run` step rather than the default, which is "install". + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + // Creates a step for unit testing. This only builds the test executable + // but does not run it. + const unit_tests = b.addTest(.{ + .root_source_file = .{ .path = "src/main.zig" }, + .target = target, + .optimize = optimize, + }); + + const run_unit_tests = b.addRunArtifact(unit_tests); + + // Similar to creating the run step earlier, this exposes a `test` step to + // the `zig build --help` menu, providing a way for the user to request + // running the unit tests. + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_unit_tests.step); +} diff --git a/src/main.zig b/src/main.zig new file mode 100644 index 0000000..dc762d1 --- /dev/null +++ b/src/main.zig @@ -0,0 +1,194 @@ +const std = @import("std"); +const mime = @import("mime.zig"); + +const Address = std.net.Address; +const ArrayList = std.ArrayList; +const debug = std.debug; +const fmt = std.fmt; +const heap = std.heap; +const http = std.http; +const fs = std.fs; +const log = std.log.scoped(.server); +const mem = std.mem; +const bufferedReader = std.io.bufferedReader; +const getcwd = std.os.getcwd; + +const MAX_PATH_BYTES = fs.MAX_PATH_BYTES; + +const server_addr = "127.0.0.1"; +const server_port = 8080; + +const BUFFER_LIMIT = 1 << 21; + +const resp = "HTTP/1.0 200 OK\r\nServer: zhttpd\r\nContent-type: text/html\r\n\r\n"; +const def = "<html>Hello, World</html>"; + +fn read_files(target: []const u8, buffer: []u8, allocator: mem.Allocator) !usize { + var file_path = try allocator.alloc(u8, MAX_PATH_BYTES); + defer allocator.free(file_path); + var cwd_buffer = [_]u8{0} ** MAX_PATH_BYTES; + const cwd = try getcwd(&cwd_buffer); + file_path = try fmt.allocPrint(allocator, "{s}{s}", .{ cwd, target }); + log.info("Loading file {s}...", .{file_path}); + // Determine if the requested file exists + if (fs.cwd().openFile(file_path, .{})) |file| { + defer file.close(); + var stat = try file.stat(); + switch (stat.kind) { + .directory => { + if (mem.endsWith(u8, target, "/")) { + file_path = try fmt.allocPrint(allocator, "{s}index.html", .{target}); + } else { + file_path = try fmt.allocPrint(allocator, "{s}/index.html", .{target}); + } + return read_files(file_path, buffer, allocator); + }, + .file => { + return try file.readAll(buffer); + }, + else => { + return 0; + }, + } + } else |err| { + switch (err) { + error.FileNotFound => { + return 0; + }, + else => { + return err; + }, + } + } +} + +fn handle_request(response: *http.Server.Response, allocator: mem.Allocator) !void { + // Log the request details + log.info("{s} {s} {s}", .{ @tagName(response.request.method), @tagName(response.request.version), response.request.target }); + + // Create a []u8 to read up to BUFFER_LIMIT characters + var read = [_]u8{0} ** BUFFER_LIMIT; + + // Set "connection" header to "keep-alive" if present in request headers + if (response.request.headers.contains("connection")) { + try response.headers.append("connection", "keep-alive"); + } + + const size = try read_files(response.request.target, &read, allocator); + if (size > 0) { + // Get the file extension, and set the content-type header if it exists + // (using mime.zig) + // To do this, we iterate through response.request.target in reverse + // looking for a '/' or a '.' + // a '/' indicates a directory, so there is no extension + // a '.' indicates a file extension + var i: usize = response.request.target.len; + var mime_type: ?mime.Type = undefined; + if (mem.endsWith(u8, response.request.target, "/")) { + log.warn("Dir requested, returning index.html!", .{}); + try response.headers.append("content-type", "text/html"); + } else { + while (i > 0) { + i -= 1; + switch (response.request.target[i]) { + '/' => { + i = 0; + break; + }, + '.' => { + break; + }, + else => { + continue; + }, + } + } + if (i <= 0) { + log.warn("No extension detected!", .{}); + if (mem.indexOf(u8, &read, "<html")) |_| { + try response.headers.append("content-type", "text/html"); + } else { + try response.headers.append("content-type", "text/plain"); + } + } else { + log.info("Extension {s} detected!", .{response.request.target[i..]}); + mime_type = mime.extension_map.get(response.request.target[i..]); + if (mime_type) |mime_val| { + try response.headers.append("content-type", @tagName(mime_val)); + } else { + try response.headers.append("content-type", "text/plain"); + } + } + } + // Create a []u8 that is exactly the intended size and no larger + const body = read[0..size]; + log.info("{}", .{body.len}); + // Check if the request target contains "?chunked" + if (mem.indexOf(u8, response.request.target, "?chunked") != null) { + response.transfer_encoding = .chunked; + } else { + response.transfer_encoding = .{ .content_length = size }; + } + // Transmit the response + try response.do(); + if (response.request.method != .HEAD) { + try response.writeAll(body); + try response.finish(); + } + } else { + // If the file was not found, return error 404 + response.status = .not_found; + response.transfer_encoding = .{ .content_length = 0 }; + try response.do(); + } +} + +fn run_server(server: *http.Server, allocator: mem.Allocator) !void { + outer: while (true) { + // Accept incoming connection + var response = try server.accept(.{ + .allocator = allocator, + }); + defer response.deinit(); + + while (response.reset() != .closing) { + // Handle errors during request processing + response.wait() catch |err| switch (err) { + error.HttpHeadersInvalid => continue :outer, + error.EndOfStream => continue, + else => return err, + }; + + // Process the request + try handle_request(&response, allocator); + } + } +} + +pub fn main() !void { + // Define allocator + var gpa = heap.GeneralPurposeAllocator(.{}){}; + defer debug.assert(gpa.deinit() == .ok); + const allocator = gpa.allocator(); + + // Initialize the server + var server = http.Server.init(allocator, .{ .reuse_address = true }); + defer server.deinit(); + + // Log the server address and port + log.info("Server is running at {s}:{d}", .{ server_addr, server_port }); + + // Parse the server address + const address = Address.parseIp(server_addr, server_port) catch unreachable; + try server.listen(address); + + // Run the server + run_server(&server, allocator) catch |err| { + // Handle server errors + log.err("server error: {}\n", .{err}); + if (@errorReturnTrace()) |trace| { + debug.dumpStackTrace(trace.*); + } + std.os.exit(1); + }; +} |