diff --git a/build.zig b/build.zig index 48a40c5..5498d42 100644 --- a/build.zig +++ b/build.zig @@ -39,7 +39,7 @@ pub fn build(b: *std.Build) !void { lib.root_module.addImport("zmpl", zmpl_dep.module("zmpl")); jetzig_module.addImport("zmpl", zmpl_dep.module("zmpl")); - lib.root_module.addImport("args", zig_args_dep.module("args")); + jetzig_module.addImport("args", zig_args_dep.module("args")); // This is the way to make it look nice in the zig build script // If we would do it the other way around, we would have to do diff --git a/build.zig.zon b/build.zig.zon index 3f72aa0..9aa49ce 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,8 +7,8 @@ .hash = "1220e5ede084ca6b94defd466a8f8779aab151d37bf688fefb928fded6f02cde4135", }, .args = .{ - .url = "https://github.com/MasterQ32/zig-args/archive/89f18a104d9c13763b90e97d6b4ce133da8a3e2b.tar.gz", - .hash = "12203ded54c85878eea7f12744066dcb4397177395ac49a7b2aa365bf6047b623829", + .url = "https://github.com/MasterQ32/zig-args/archive/01d72b9a0128c474aeeb9019edd48605fa6d95f7.tar.gz", + .hash = "12208a1de366740d11de525db7289345949f5fd46527db3f89eecc7bb49b012c0732", }, }, diff --git a/cli/build.zig.zon b/cli/build.zig.zon index 9350709..66a91ad 100644 --- a/cli/build.zig.zon +++ b/cli/build.zig.zon @@ -5,8 +5,8 @@ .dependencies = .{ .args = .{ - .url = "https://github.com/MasterQ32/zig-args/archive/89f18a104d9c13763b90e97d6b4ce133da8a3e2b.tar.gz", - .hash = "12203ded54c85878eea7f12744066dcb4397177395ac49a7b2aa365bf6047b623829", + .url = "https://github.com/MasterQ32/zig-args/archive/01d72b9a0128c474aeeb9019edd48605fa6d95f7.tar.gz", + .hash = "12208a1de366740d11de525db7289345949f5fd46527db3f89eecc7bb49b012c0732", }, }, .paths = .{ diff --git a/cli/cli.zig b/cli/cli.zig index ca62b78..8171e78 100644 --- a/cli/cli.zig +++ b/cli/cli.zig @@ -4,6 +4,7 @@ const init = @import("commands/init.zig"); const update = @import("commands/update.zig"); const generate = @import("commands/generate.zig"); const server = @import("commands/server.zig"); +const bundle = @import("commands/bundle.zig"); const Options = struct { help: bool = false, @@ -19,6 +20,7 @@ const Options = struct { .update = "Update current project to latest version of Jetzig", .generate = "Generate scaffolding", .server = "Run a development server", + .bundle = "Create a deployment bundle", .help = "Print help and exit", }, }; @@ -29,8 +31,10 @@ const Verb = union(enum) { update: update.Options, generate: generate.Options, server: server.Options, + bundle: bundle.Options, g: generate.Options, s: server.Options, + b: bundle.Options, }; /// Main entrypoint for `jetzig` executable. Parses command line args and generates a new @@ -52,7 +56,7 @@ pub fn main() !void { } }; - if (options.options.help or options.verb == null) { + if ((!options.options.help and options.verb == null) or (options.options.help and options.verb == null)) { try args.printHelp(Options, "jetzig", writer); try writer.writeAll( \\ @@ -62,6 +66,7 @@ pub fn main() !void { \\ update Update current project to latest version of Jetzig. \\ generate Generate scaffolding. \\ server Run a development server. + \\ bundle Create a deployment bundle. \\ \\ Pass --help to any command for more information, e.g. `jetzig init --help` \\ @@ -100,6 +105,13 @@ fn run(allocator: std.mem.Allocator, options: args.ParseArgsResult(Options, Verb options.positionals, .{ .help = options.options.help }, ), + .b, .bundle => |opts| bundle.run( + allocator, + opts, + writer, + options.positionals, + .{ .help = options.options.help }, + ), }; } } diff --git a/cli/commands/bundle.zig b/cli/commands/bundle.zig new file mode 100644 index 0000000..7435a7e --- /dev/null +++ b/cli/commands/bundle.zig @@ -0,0 +1,139 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const args = @import("args"); + +const util = @import("../util.zig"); + +/// Command line options for the `bundle` command. +pub const Options = struct { + optimize: enum { Debug, ReleaseFast, ReleaseSmall } = .ReleaseFast, + arch: enum { x86_64, aarch64, default } = .default, + os: enum { linux, macos, windows, default } = .default, + + pub const meta = .{ + .full_text = + \\Creates a deployment bundle. + \\ + \\On Windows, `tar.exe` is used to generate a `.zip` file. + \\ + \\On other operating systems, `tar` is used to generate a `.tar.gz` file. + \\ + \\The deployment bundle contains a compiled executable with the `public/` and `static/` + \\directories included. This bundle can be copied to a deployment server, unpacked, and + \\launched in place. + , + .option_docs = .{ + .optimize = "Set optimization level, must be one of { Debug, ReleaseFast, ReleaseSmall } (default: ReleaseFast)", + .arch = "Set build target CPU architecture, must be one of { x86_64, aarch64 } (default: Current CPU arch)", + .os = "Set build target operating system, must be one of { linux, macos, windows } (default: Current OS)", + }, + }; +}; + +/// Run the deployment bundle generator. Create an archive containing the Jetzig executable, +/// with `public/` and `static/` directories. +pub fn run( + allocator: std.mem.Allocator, + options: Options, + writer: anytype, + positionals: [][]const u8, + other_options: struct { help: bool }, +) !void { + _ = positionals; + if (other_options.help) { + try args.printHelp(Options, "jetzig bundle", writer); + return; + } + + std.debug.print("Compiling bundle...\n", .{}); + var cwd = try util.detectJetzigProjectDir(); + defer cwd.close(); + + const path = try cwd.realpathAlloc(allocator, "."); + defer allocator.free(path); + + if (try util.locateExecutable(allocator, cwd, .{ .relative = true })) |executable| { + defer allocator.free(executable); + + var tar_argv = std.ArrayList([]const u8).init(allocator); + defer tar_argv.deinit(); + + var install_argv = std.ArrayList([]const u8).init(allocator); + defer install_argv.deinit(); + + try install_argv.appendSlice(&[_][]const u8{ "zig", "build" }); + + switch (builtin.os.tag) { + .windows => try tar_argv.appendSlice(&[_][]const u8{ + "tar.exe", + "-a", + "-c", + "-f", + "bundle.zip", + executable, + }), + else => try tar_argv.appendSlice(&[_][]const u8{ + "tar", + "--transform=s,^,jetzig/,", + "--transform=s,^jetzig/zig-out/bin/,jetzig/,", + "-zcf", + "bundle.tar.gz", + executable, + }), + } + + switch (options.optimize) { + .ReleaseFast => try install_argv.append("-Doptimize=ReleaseFast"), + .ReleaseSmall => try install_argv.append("-Doptimize=ReleaseSmall"), + .Debug => try install_argv.append("-Doptimize=Debug"), + } + + var target_buf = std.ArrayList([]const u8).init(allocator); + defer target_buf.deinit(); + + try target_buf.append("-Dtarget="); + switch (options.arch) { + .x86_64 => try target_buf.append("x86_64"), + .aarch64 => try target_buf.append("aarch64"), + .default => try target_buf.append(@tagName(builtin.cpu.arch)), + } + + try target_buf.append("-"); + + switch (options.os) { + .linux => try target_buf.append("linux"), + .macos => try target_buf.append("macos"), + .windows => try target_buf.append("windows"), + .default => try target_buf.append(@tagName(builtin.os.tag)), + } + + const target = try std.mem.concat(allocator, u8, target_buf.items); + defer allocator.free(target); + + try install_argv.append(target); + try install_argv.append("install"); + + var public_dir: ?std.fs.Dir = cwd.openDir("public", .{}) catch null; + defer if (public_dir) |*dir| dir.close(); + + var static_dir: ?std.fs.Dir = cwd.openDir("static", .{}) catch null; + defer if (static_dir) |*dir| dir.close(); + + if (public_dir != null) try tar_argv.append("public"); + if (static_dir != null) try tar_argv.append("static"); + + try util.runCommand(allocator, path, install_argv.items); + try util.runCommand(allocator, path, tar_argv.items); + + switch (builtin.os.tag) { + .windows => std.debug.print("Bundle `bundle.zip` generated successfully.", .{}), + else => std.debug.print("Bundle `bundle.tar.gz` generated successfully.", .{}), + } + util.printSuccess(); + } else { + std.debug.print("Unable to locate compiled executable. Exiting.", .{}); + util.printFailure(); + std.os.exit(1); + } +} diff --git a/cli/commands/generate.zig b/cli/commands/generate.zig index b6fb6e9..5480a84 100644 --- a/cli/commands/generate.zig +++ b/cli/commands/generate.zig @@ -4,12 +4,13 @@ const view = @import("generate/view.zig"); const partial = @import("generate/partial.zig"); const layout = @import("generate/layout.zig"); const middleware = @import("generate/middleware.zig"); +const secret = @import("generate/secret.zig"); const util = @import("../util.zig"); /// Command line options for the `generate` command. pub const Options = struct { pub const meta = .{ - .usage_summary = "[view|partial|layout|middleware] [options]", + .usage_summary = "[view|partial|layout|middleware|secret] [options]", .full_text = \\Generates scaffolding for views, middleware, and other objects in future. \\ @@ -44,7 +45,7 @@ pub fn run( try args.printHelp(Options, "jetzig generate", writer); return; } - var generate_type: ?enum { view, partial, layout, middleware } = null; + var generate_type: ?enum { view, partial, layout, middleware, secret } = null; var sub_args = std.ArrayList([]const u8).init(allocator); defer sub_args.deinit(); @@ -57,6 +58,8 @@ pub fn run( generate_type = .layout; } else if (generate_type == null and std.mem.eql(u8, arg, "middleware")) { generate_type = .middleware; + } else if (generate_type == null and std.mem.eql(u8, arg, "secret")) { + generate_type = .secret; } else if (generate_type == null) { std.debug.print("Unknown generator command: {s}\n", .{arg}); return error.JetzigCommandError; @@ -71,6 +74,7 @@ pub fn run( .partial => partial.run(allocator, cwd, sub_args.items), .layout => layout.run(allocator, cwd, sub_args.items), .middleware => middleware.run(allocator, cwd, sub_args.items), + .secret => secret.run(allocator, cwd, sub_args.items), }; } else { std.debug.print("Missing sub-command. Expected: [view|partial|layout|middleware]\n", .{}); diff --git a/cli/commands/generate/secret.zig b/cli/commands/generate/secret.zig new file mode 100644 index 0000000..25da07f --- /dev/null +++ b/cli/commands/generate/secret.zig @@ -0,0 +1,16 @@ +const std = @import("std"); + +/// Generate a secure random secret and output to stdout. +pub fn run(allocator: std.mem.Allocator, cwd: std.fs.Dir, args: [][]const u8) !void { + _ = allocator; + _ = args; + _ = cwd; + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var secret: [44]u8 = undefined; + + for (0..44) |index| { + secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)]; + } + + std.debug.print("{s}\n", .{secret}); +} diff --git a/cli/commands/server.zig b/cli/commands/server.zig index 2e01e43..f38aca7 100644 --- a/cli/commands/server.zig +++ b/cli/commands/server.zig @@ -1,11 +1,12 @@ const std = @import("std"); + const args = @import("args"); + const util = @import("../util.zig"); -const builtin = @import("builtin"); pub const watch_changes_pause_duration = 1 * 1000 * 1000 * 1000; -/// Command line options for the `update` command. +/// Command line options for the `server` command. pub const Options = struct { reload: bool = true, @@ -68,7 +69,7 @@ pub fn run( &[_][]const u8{ "zig", "build", "-Djetzig_runner=true", "install" }, ); - const exe_path = try locateExecutable(allocator, cwd); + const exe_path = try util.locateExecutable(allocator, cwd, .{}); if (exe_path == null) { std.debug.print("Unable to locate compiled executable. Exiting.\n", .{}); std.os.exit(1); @@ -137,34 +138,3 @@ fn totalMtime(allocator: std.mem.Allocator, cwd: std.fs.Dir, sub_path: []const u return sum; } - -fn locateExecutable(allocator: std.mem.Allocator, dir: std.fs.Dir) !?[]const u8 { - const file = dir.openFile(".jetzig", .{}) catch |err| { - switch (err) { - error.FileNotFound => return null, - else => return err, - } - }; - const content = try file.readToEndAlloc(allocator, 1024); - defer allocator.free(content); - - const exe_name = util.strip(content); - const suffix = if (builtin.os.tag == .windows) ".exe" else ""; - const full_name = try std.mem.concat(allocator, u8, &[_][]const u8{ exe_name, suffix }); - defer allocator.free(full_name); - - // XXX: Will fail if user sets a custom install path. - var bin_dir = try dir.openDir("zig-out/bin", .{ .iterate = true }); - defer bin_dir.close(); - - var walker = try bin_dir.walk(allocator); - defer walker.deinit(); - - while (try walker.next()) |entry| { - if (entry.kind == .file and std.mem.eql(u8, entry.path, full_name)) { - return try bin_dir.realpathAlloc(allocator, entry.path); - } - } - - return null; -} diff --git a/cli/util.zig b/cli/util.zig index 2350911..9ff49d9 100644 --- a/cli/util.zig +++ b/cli/util.zig @@ -1,4 +1,5 @@ const std = @import("std"); +const builtin = @import("builtin"); /// Decode a base64 string, used for parsing out build artifacts generated by the CLI program's /// build.zig which are stored in the executable as a module. @@ -167,3 +168,43 @@ pub fn githubUrl(allocator: std.mem.Allocator) ![]const u8 { }, ); } + +/// Attempt to locate the main application executable in `zig-out/bin/` +pub fn locateExecutable( + allocator: std.mem.Allocator, + dir: std.fs.Dir, + options: struct { relative: bool = false }, +) !?[]const u8 { + const file = dir.openFile(".jetzig", .{}) catch |err| { + switch (err) { + error.FileNotFound => return null, + else => return err, + } + }; + const content = try file.readToEndAlloc(allocator, 1024); + defer allocator.free(content); + + const exe_name = strip(content); + const suffix = if (builtin.os.tag == .windows) ".exe" else ""; + const full_name = try std.mem.concat(allocator, u8, &[_][]const u8{ exe_name, suffix }); + defer allocator.free(full_name); + + // XXX: Will fail if user sets a custom install path. + var bin_dir = try dir.openDir("zig-out/bin", .{ .iterate = true }); + defer bin_dir.close(); + + var walker = try bin_dir.walk(allocator); + defer walker.deinit(); + + while (try walker.next()) |entry| { + if (entry.kind == .file and std.mem.eql(u8, entry.path, full_name)) { + if (options.relative) { + return try std.fs.path.join(allocator, &[_][]const u8{ "zig-out", "bin", entry.path }); + } else { + return try bin_dir.realpathAlloc(allocator, entry.path); + } + } + } + + return null; +} diff --git a/demo/src/app/middleware/DemoMiddleware.zig b/demo/src/app/middleware/DemoMiddleware.zig index accf8b9..a6758d2 100644 --- a/demo/src/app/middleware/DemoMiddleware.zig +++ b/demo/src/app/middleware/DemoMiddleware.zig @@ -33,14 +33,17 @@ pub fn init(request: *jetzig.http.Request) !*Self { /// Any calls to `request.render` or `request.redirect` will prevent further processing of the /// request, including any other middleware in the chain. pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { - request.server.logger.debug("[DemoMiddleware:afterRequest] my_custom_value: {s}", .{self.my_custom_value}); + try request.server.logger.DEBUG( + "[DemoMiddleware:afterRequest] my_custom_value: {s}", + .{self.my_custom_value}, + ); self.my_custom_value = @tagName(request.method); } /// Invoked immediately before the response renders to the client. /// The response can be modified here if needed. pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { - request.server.logger.debug( + try request.server.logger.DEBUG( "[DemoMiddleware:beforeResponse] my_custom_value: {s}, response status: {s}", .{ self.my_custom_value, @tagName(response.status_code) }, ); @@ -51,7 +54,7 @@ pub fn beforeResponse(self: *Self, request: *jetzig.http.Request, response: *jet pub fn afterResponse(self: *Self, request: *jetzig.http.Request, response: *jetzig.http.Response) !void { _ = self; _ = response; - request.server.logger.debug("[DemoMiddleware:afterResponse] response completed", .{}); + try request.server.logger.DEBUG("[DemoMiddleware:afterResponse] response completed", .{}); } /// Invoked after `afterResponse` is called. Use this function to do any clean-up. diff --git a/demo/src/main.zig b/demo/src/main.zig index 6ba3a19..268d2eb 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -3,13 +3,33 @@ const std = @import("std"); pub const jetzig = @import("jetzig"); pub const routes = @import("routes").routes; +// Override default settings in `jetzig.config` here: pub const jetzig_options = struct { + /// Middleware chain. Add any custom middleware here, or use middleware provided in + /// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`). pub const middleware: []const type = &.{ // htmx middleware skips layouts when `HX-Target` header is present and issues // `HX-Redirect` instead of a regular HTTP redirect when `request.redirect` is called. jetzig.middleware.HtmxMiddleware, + // Demo middleware included with new projects. Remove once you are familiar with Jetzig's + // middleware system. @import("app/middleware/DemoMiddleware.zig"), }; + + // Maximum bytes to allow in request body. + // pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 16); + + // Maximum filesize for `public/` content. + // pub const max_bytes_public_content: usize = std.math.pow(usize, 2, 20); + + // Maximum filesize for `static/` content (applies only to apps using `jetzig.http.StaticRequest`). + // pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 18); + + // Path relative to cwd() to serve public content from. Symlinks are not followed. + // pub const public_content_path = "public"; + + // HTTP buffer. Must be large enough to store all headers. This should typically not be modified. + // pub const http_buffer_size: usize = std.math.pow(usize, 2, 16); }; pub fn main() !void { diff --git a/src/jetzig.zig b/src/jetzig.zig index 91447f7..3657618 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -5,16 +5,19 @@ pub const zmpl = @import("zmpl").zmpl; pub const http = @import("jetzig/http.zig"); pub const loggers = @import("jetzig/loggers.zig"); pub const data = @import("jetzig/data.zig"); -pub const caches = @import("jetzig/caches.zig"); pub const views = @import("jetzig/views.zig"); pub const colors = @import("jetzig/colors.zig"); pub const middleware = @import("jetzig/middleware.zig"); pub const util = @import("jetzig/util.zig"); +pub const types = @import("jetzig/types.zig"); /// 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"); +/// Configuration options for the application server with command-line argument parsing. +pub const Environment = @import("jetzig/Environment.zig"); + /// An HTTP request which is passed to (dynamic) view functions and provides access to params, /// headers, and functions to render a response. pub const Request = http.Request; @@ -32,47 +35,60 @@ pub const Data = data.Data; /// generate a `View`. pub const View = views.View; +const root = @import("root"); + +/// Global configuration. Override these values by defining in `src/main.zig` with: +/// ```zig +/// pub const jetzig_options = struct { +/// // ... +/// } +/// ``` +/// All constants defined below can be overridden. pub const config = struct { + /// Maximum bytes to allow in request body. pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 16); - pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 16); + + /// Maximum filesize for `public/` content. + pub const max_bytes_public_content: usize = std.math.pow(usize, 2, 20); + + /// Maximum filesize for `static/` content (applies only to apps using `jetzig.http.StaticRequest`). + pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 18); + + /// Path relative to cwd() to serve public content from. Symlinks are not followed. + pub const public_content_path = "public"; + + /// Middleware chain. Add any custom middleware here, or use middleware provided in + /// `jetzig.middleware` (e.g. `jetzig.middleware.HtmxMiddleware`). + pub const middleware = &.{}; + + /// HTTP buffer. Must be large enough to store all headers. This should typically not be + /// modified. pub const http_buffer_size: usize = std.math.pow(usize, 2, 16); - pub const public_content = .{ .path = "public" }; + + /// Reconciles a configuration value from user-defined values and defaults provided by Jetzig. + pub fn get(T: type, comptime key: []const u8) T { + const self = @This(); + if (!@hasDecl(self, key)) @panic("Unknown config option: " ++ key); + + if (@hasDecl(root, "jetzig_options") and @hasDecl(root.jetzig_options, key)) { + return @field(root.jetzig_options, key); + } else { + return @field(self, key); + } + } }; +/// Initialize a new Jetzig app. Call this from `src/main.zig` and then call +/// `start(@import("routes").routes)` on the returned value. pub fn init(allocator: std.mem.Allocator) !App { const args = try std.process.argsAlloc(allocator); defer std.process.argsFree(allocator, args); - const host: []const u8 = if (args.len > 1) - try allocator.dupe(u8, args[1]) - else - try allocator.dupe(u8, "127.0.0.1"); - - // TODO: Fix this up with proper arg parsing - const port: u16 = if (args.len > 2) try std.fmt.parseInt(u16, args[2], 10) else 8080; - const use_cache: bool = args.len > 3 and std.mem.eql(u8, args[3], "--cache"); - const server_cache = switch (use_cache) { - true => caches.Cache{ .memory_cache = caches.MemoryCache.init(allocator) }, - false => caches.Cache{ .null_cache = caches.NullCache.init(allocator) }, - }; - var logger = loggers.Logger{ .development_logger = loggers.DevelopmentLogger.init(allocator) }; - const secret = try generateSecret(allocator); - logger.debug( - "Running in development mode, using auto-generated cookie encryption key:\n {s}", - .{secret}, - ); - - const server_options = http.Server.ServerOptions{ - .cache = server_cache, - .logger = logger, - .secret = secret, - }; + const environment = Environment.init(allocator); return .{ - .server_options = server_options, + .server_options = try environment.getServerOptions(), .allocator = allocator, - .host = host, - .port = port, }; } @@ -154,36 +170,3 @@ pub fn route(comptime routes: anytype) []views.Route { return &detected; } - -pub fn generateSecret(allocator: std.mem.Allocator) ![]const u8 { - const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; - var secret: [64]u8 = undefined; - - for (0..64) |index| { - secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)]; - } - - return try allocator.dupe(u8, &secret); -} - -pub fn base64Encode(allocator: std.mem.Allocator, string: []const u8) ![]const u8 { - const encoder = std.base64.Base64Encoder.init( - std.base64.url_safe_no_pad.alphabet_chars, - std.base64.url_safe_no_pad.pad_char, - ); - const size = encoder.calcSize(string.len); - const ptr = try allocator.alloc(u8, size); - _ = encoder.encode(ptr, string); - return ptr; -} - -pub fn base64Decode(allocator: std.mem.Allocator, string: []const u8) ![]const u8 { - const decoder = std.base64.Base64Decoder.init( - std.base64.url_safe_no_pad.alphabet_chars, - std.base64.url_safe_no_pad.pad_char, - ); - const size = try decoder.calcSizeForSlice(string); - const ptr = try allocator.alloc(u8, size); - try decoder.decode(ptr, string); - return ptr; -} diff --git a/src/jetzig/App.zig b/src/jetzig/App.zig index c0ea75b..f21e925 100644 --- a/src/jetzig/App.zig +++ b/src/jetzig/App.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const args = @import("args"); + const jetzig = @import("../jetzig.zig"); const mime_types = @import("mime_types").mime_types; // Generated at build time. @@ -7,8 +9,6 @@ const Self = @This(); server_options: jetzig.http.Server.ServerOptions, allocator: std.mem.Allocator, -host: []const u8, -port: u16, pub fn deinit(self: Self) void { _ = self; @@ -48,30 +48,40 @@ pub fn start(self: Self, comptime_routes: []jetzig.views.Route) !void { self.allocator.destroy(route); }; + if (self.server_options.detach) { + const argv = try std.process.argsAlloc(self.allocator); + defer std.process.argsFree(self.allocator, argv); + var child_argv = std.ArrayList([]const u8).init(self.allocator); + for (argv) |arg| { + if (!std.mem.eql(u8, "-d", arg) and !std.mem.eql(u8, "--detach", arg)) { + try child_argv.append(arg); + } + } + var child = std.process.Child.init(child_argv.items, self.allocator); + try child.spawn(); + std.debug.print("Spawned child process. PID: {}. Exiting.\n", .{child.id}); + std.process.exit(0); + } + var server = jetzig.http.Server.init( self.allocator, - self.host, - self.port, self.server_options, routes.items, &mime_map, ); - defer server.deinit(); - defer self.allocator.free(self.host); - defer self.allocator.free(server.options.secret); server.listen() catch |err| { switch (err) { error.AddressInUse => { - server.logger.debug( + try server.logger.ERROR( "Socket unavailable: {s}:{} - unable to start server.\n", - .{ self.host, self.port }, + .{ self.server_options.bind, self.server_options.port }, ); return; }, else => { - server.logger.debug("Encountered error: {}\nExiting.\n", .{err}); + try server.logger.ERROR("Encountered error: {}\nExiting.\n", .{err}); return err; }, } diff --git a/src/jetzig/Environment.zig b/src/jetzig/Environment.zig new file mode 100644 index 0000000..be8f842 --- /dev/null +++ b/src/jetzig/Environment.zig @@ -0,0 +1,154 @@ +const std = @import("std"); + +const args = @import("args"); + +const jetzig = @import("../jetzig.zig"); + +const Environment = @This(); + +allocator: std.mem.Allocator, + +const Options = struct { + help: bool = false, + bind: []const u8 = "127.0.0.1", + port: u16 = 8080, + // TODO: + // environment: []const u8 = "development", + log: []const u8 = "-", + @"log-error": []const u8 = "-", + @"log-level": jetzig.loggers.LogLevel = .INFO, + @"log-format": jetzig.loggers.LogFormat = .development, + detach: bool = false, + + pub const shorthands = .{ + .h = "help", + .b = "bind", + .p = "port", + // TODO: + // .e = "environment", + .d = "detach", + }; + + pub const wrap_len = 80; + + pub const meta = .{ + .option_docs = .{ + .bind = "IP address/hostname to bind to (default: 127.0.0.1)", + .port = "Port to listen on (default: 8080)", + // TODO: + // .environment = "Load an environment configuration from src/app/environments/.zig", + .log = "Path to log file. Use '-' for stdout (default: '-')", + .@"log-error" = + \\Optional path to separate error log file. Use '-' for stderr. If omitted, errors are logged to the location specified by the `log` option (or stderr if `log` is '-'). + , + .@"log-level" = + \\Minimum log level. Log events below the given level are ignored. Must be one of: { TRACE, DEBUG, INFO, WARN, ERROR, FATAL } (default: DEBUG) + , + .@"log-format" = + \\Output logs in the given format. Must be one of: { development, json } (default: development) + , + .detach = + \\Run the server in the background. Must be used in conjunction with --log (default: false) + , + .help = "Print help and exit", + }, + }; +}; + +pub fn init(allocator: std.mem.Allocator) Environment { + return .{ .allocator = allocator }; +} + +/// Generate server initialization options using command line args with defaults. +pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { + const options = try args.parseForCurrentProcess(Options, self.allocator, .print); + + if (options.options.help) { + const writer = std.io.getStdErr().writer(); + try args.printHelp(Options, options.executable_name orelse "", writer); + std.process.exit(0); + } + + var logger = switch (options.options.@"log-format") { + .development => jetzig.loggers.Logger{ + .development_logger = jetzig.loggers.DevelopmentLogger.init( + self.allocator, + options.options.@"log-level", + try getLogFile(.stdout, options.options), + try getLogFile(.stderr, options.options), + ), + }, + .json => jetzig.loggers.Logger{ + .json_logger = jetzig.loggers.JsonLogger.init( + self.allocator, + options.options.@"log-level", + try getLogFile(.stdout, options.options), + try getLogFile(.stderr, options.options), + ), + }, + }; + + if (options.options.detach and std.mem.eql(u8, options.options.log, "-")) { + try logger.ERROR("Must pass `--log` when using `--detach`.", .{}); + std.process.exit(1); + } + + // TODO: Generate nonce per session - do research to confirm correct best practice. + const secret_len = jetzig.http.Session.Cipher.key_length + jetzig.http.Session.Cipher.nonce_length; + const secret = try self.getSecret(&logger, secret_len); + + if (secret.len != secret_len) { + try logger.ERROR("Expected secret length: {}, found: {}.", .{ secret_len, secret.len }); + try logger.ERROR("Use `jetzig generate secret` to create a secure secret value.", .{}); + std.process.exit(1); + } + + return .{ + .logger = logger, + .secret = secret, + .bind = try self.allocator.dupe(u8, options.options.bind), + .port = options.options.port, + .detach = options.options.detach, + }; +} + +fn getLogFile(stream: enum { stdout, stderr }, options: Options) !std.fs.File { + const path = switch (stream) { + .stdout => options.log, + .stderr => options.@"log-error", + }; + + if (std.mem.eql(u8, path, "-")) return switch (stream) { + .stdout => std.io.getStdOut(), + .stderr => if (std.mem.eql(u8, options.log, "-")) + std.io.getStdErr() + else + try std.fs.createFileAbsolute(options.log, .{ .truncate = false }), + }; + + const file = try std.fs.createFileAbsolute(path, .{ .truncate = false }); + try file.seekFromEnd(0); + return file; +} + +fn getSecret(self: Environment, logger: *jetzig.loggers.Logger, comptime len: u10) ![]const u8 { + return std.process.getEnvVarOwned(self.allocator, "JETZIG_SECRET") catch |err| { + switch (err) { + error.EnvironmentVariableNotFound => { + // TODO: Make this a failure when running in non-development mode. + const secret = try jetzig.util.generateSecret(self.allocator, len); + try logger.WARN( + "Running in development mode, using auto-generated cookie encryption key: {s}", + .{secret}, + ); + try logger.WARN( + "Run `jetzig generate secret` and set `JETZIG_SECRET` to remove this warning.", + .{}, + ); + + return secret; + }, + else => return err, + } + }; +} diff --git a/src/jetzig/caches.zig b/src/jetzig/caches.zig deleted file mode 100644 index 94a2dc4..0000000 --- a/src/jetzig/caches.zig +++ /dev/null @@ -1,30 +0,0 @@ -const std = @import("std"); - -const http = @import("http.zig"); - -pub const Result = @import("caches/Result.zig"); -pub const MemoryCache = @import("caches/MemoryCache.zig"); -pub const NullCache = @import("caches/NullCache.zig"); - -pub const Cache = union(enum) { - memory_cache: MemoryCache, - null_cache: NullCache, - - pub fn deinit(self: *Cache) void { - switch (self.*) { - inline else => |*case| case.deinit(), - } - } - - pub fn get(self: *Cache, key: []const u8) ?Result { - return switch (self.*) { - inline else => |*case| case.get(key), - }; - } - - pub fn put(self: *Cache, key: []const u8, value: http.Response) !Result { - return switch (self.*) { - inline else => |*case| case.put(key, value), - }; - } -}; diff --git a/src/jetzig/caches/Cache.zig b/src/jetzig/caches/Cache.zig deleted file mode 100644 index 0769537..0000000 --- a/src/jetzig/caches/Cache.zig +++ /dev/null @@ -1,5 +0,0 @@ -const std = @import("std"); - -pub const Result = @import("Result.zig"); -pub const MemoryCache = @import("MemoryCache.zig"); -pub const NullCache = @import("NullCache.zig"); diff --git a/src/jetzig/caches/MemoryCache.zig b/src/jetzig/caches/MemoryCache.zig deleted file mode 100644 index 1363b0e..0000000 --- a/src/jetzig/caches/MemoryCache.zig +++ /dev/null @@ -1,39 +0,0 @@ -const std = @import("std"); - -const http = @import("../http.zig"); -const Result = @import("Result.zig"); - -allocator: std.mem.Allocator, -cache: std.StringHashMap(http.Response), - -const Self = @This(); - -pub fn init(allocator: std.mem.Allocator) Self { - const cache = std.StringHashMap(http.Response).init(allocator); - - return .{ .allocator = allocator, .cache = cache }; -} - -pub fn deinit(self: *Self) void { - var iterator = self.cache.keyIterator(); - while (iterator.next()) |key| { - self.allocator.free(key.*); - } - self.cache.deinit(); -} - -pub fn get(self: *Self, key: []const u8) ?Result { - if (self.cache.get(key)) |value| { - return Result.init(self.allocator, value, true); - } else { - return null; - } -} - -pub fn put(self: *Self, key: []const u8, value: http.Response) !Result { - const key_dupe = try self.allocator.dupe(u8, key); - const value_dupe = try value.dupe(); - try self.cache.put(key_dupe, value_dupe); - - return Result.init(self.allocator, value_dupe, true); -} diff --git a/src/jetzig/caches/NullCache.zig b/src/jetzig/caches/NullCache.zig deleted file mode 100644 index 1c67aa0..0000000 --- a/src/jetzig/caches/NullCache.zig +++ /dev/null @@ -1,27 +0,0 @@ -const std = @import("std"); - -const http = @import("../http.zig"); -const Result = @import("Result.zig"); - -const Self = @This(); - -allocator: std.mem.Allocator, - -pub fn init(allocator: std.mem.Allocator) Self { - return Self{ .allocator = allocator }; -} - -pub fn deinit(self: *const Self) void { - _ = self; -} - -pub fn get(self: *const Self, key: []const u8) ?Result { - _ = key; - _ = self; - return null; -} - -pub fn put(self: *const Self, key: []const u8, value: http.Response) !Result { - _ = key; - return Result{ .value = value, .cached = false, .allocator = self.allocator }; -} diff --git a/src/jetzig/caches/Result.zig b/src/jetzig/caches/Result.zig deleted file mode 100644 index 628645b..0000000 --- a/src/jetzig/caches/Result.zig +++ /dev/null @@ -1,17 +0,0 @@ -const std = @import("std"); - -const Self = @This(); - -const jetzig = @import("../../jetzig.zig"); - -value: jetzig.http.Response, -cached: bool, -allocator: std.mem.Allocator, - -pub fn init(allocator: std.mem.Allocator, value: jetzig.http.Response, cached: bool) Self { - return .{ .allocator = allocator, .cached = cached, .value = value }; -} - -pub fn deinit(self: *const Self) void { - if (!self.cached) self.value.deinit(); -} diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index b4088de..7aa0703 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -30,10 +30,12 @@ rendered: bool = false, redirected: bool = false, rendered_multiple: bool = false, rendered_view: ?jetzig.views.View = null, +start_time: i128, pub fn init( allocator: std.mem.Allocator, server: *jetzig.http.Server, + start_time: i128, std_http_request: std.http.Server.Request, response: *jetzig.http.Response, ) !Self { @@ -69,6 +71,7 @@ pub fn init( .query_data = query_data, .query = query, .std_http_request = std_http_request, + .start_time = start_time, }; } @@ -101,7 +104,7 @@ pub fn process(self: *Self) !void { self.session.parse() catch |err| { switch (err) { error.JetzigInvalidSessionCookie => { - self.server.logger.debug("Invalid session cookie detected. Resetting session.", .{}); + try self.server.logger.DEBUG("Invalid session cookie detected. Resetting session.", .{}); try self.session.reset(); }, else => return err, @@ -109,7 +112,7 @@ pub fn process(self: *Self) !void { }; const reader = try self.std_http_request.reader(); - self.body = try reader.readAllAlloc(self.allocator, jetzig.config.max_bytes_request_body); + self.body = try reader.readAllAlloc(self.allocator, jetzig.config.get(usize, "max_bytes_request_body")); self.processed = true; } @@ -310,7 +313,9 @@ pub fn hash(self: *Self) ![]const u8 { ); } -pub fn fmtMethod(self: *Self) []const u8 { +pub fn fmtMethod(self: *const Self, colorized: bool) []const u8 { + if (!colorized) return @tagName(self.method); + return switch (self.method) { .GET => jetzig.colors.cyan("GET"), .PUT => jetzig.colors.yellow("PUT"), diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index ee9dd68..dd8b60e 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -3,45 +3,32 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); const zmpl = @import("zmpl"); -const root_file = @import("root"); - -pub const jetzig_server_options = if (@hasDecl(root_file, "jetzig_options")) - root_file.jetzig_options -else - struct {}; - pub const ServerOptions = struct { - cache: jetzig.caches.Cache, logger: jetzig.loggers.Logger, + bind: []const u8, + port: u16, secret: []const u8, + detach: bool, }; allocator: std.mem.Allocator, -port: u16, -host: []const u8, -cache: jetzig.caches.Cache, logger: jetzig.loggers.Logger, options: ServerOptions, -start_time: i128 = undefined, routes: []*jetzig.views.Route, mime_map: *jetzig.http.mime.MimeMap, std_net_server: std.net.Server = undefined, +initialized: bool = false, const Self = @This(); pub fn init( allocator: std.mem.Allocator, - host: []const u8, - port: u16, options: ServerOptions, routes: []*jetzig.views.Route, mime_map: *jetzig.http.mime.MimeMap, ) Self { return .{ .allocator = allocator, - .host = host, - .port = port, - .cache = options.cache, .logger = options.logger, .options = options, .routes = routes, @@ -50,15 +37,18 @@ pub fn init( } pub fn deinit(self: *Self) void { - self.std_net_server.deinit(); + if (self.initialized) self.std_net_server.deinit(); + self.allocator.free(self.options.secret); + self.allocator.free(self.options.bind); } pub fn listen(self: *Self) !void { - const address = try std.net.Address.parseIp("127.0.0.1", 8080); + const address = try std.net.Address.parseIp(self.options.bind, self.options.port); self.std_net_server = try address.listen(.{ .reuse_port = true }); - const cache_status = if (self.options.cache == .null_cache) "disabled" else "enabled"; - self.logger.debug("Listening on http://{s}:{} [cache:{s}]", .{ self.host, self.port, cache_status }); + self.initialized = true; + + try self.logger.INFO("Listening on http://{s}:{}", .{ self.options.bind, self.options.port }); try self.processRequests(); } @@ -71,13 +61,12 @@ fn processRequests(self: *Self) !void { const connection = try self.std_net_server.accept(); - var buf: [jetzig.config.http_buffer_size]u8 = undefined; + var buf: [jetzig.config.get(usize, "http_buffer_size")]u8 = undefined; var std_http_server = std.http.Server.init(connection, &buf); errdefer std_http_server.connection.stream.close(); self.processNextRequest(allocator, &std_http_server) catch |err| { if (isBadHttpError(err)) { - std.debug.print("Encountered HTTP error: {s}\n", .{@errorName(err)}); std_http_server.connection.stream.close(); continue; } else return err; @@ -89,13 +78,13 @@ fn processRequests(self: *Self) !void { } fn processNextRequest(self: *Self, allocator: std.mem.Allocator, std_http_server: *std.http.Server) !void { - self.start_time = std.time.nanoTimestamp(); + const start_time = std.time.nanoTimestamp(); const std_http_request = try std_http_server.receiveHead(); if (std_http_server.state == .receiving_head) return error.JetzigParseHeadError; var response = try jetzig.http.Response.init(allocator); - var request = try jetzig.http.Request.init(allocator, self, std_http_request, &response); + var request = try jetzig.http.Request.init(allocator, self, start_time, std_http_request, &response); try request.process(); @@ -111,9 +100,7 @@ fn processNextRequest(self: *Self, allocator: std.mem.Allocator, std_http_server try jetzig.http.middleware.afterResponse(&middleware_data, &request); jetzig.http.middleware.deinit(&middleware_data, &request); - const log_message = try self.requestLogMessage(&request); - defer self.allocator.free(log_message); - self.logger.debug("{s}", .{log_message}); + try self.logger.logRequest(&request); } fn renderResponse(self: *Self, request: *jetzig.http.Request) !void { @@ -210,7 +197,7 @@ fn renderView( // `return request.render(.ok)`, but the actual rendered view is stored in // `request.rendered_view`. _ = route.render(route.*, request) catch |err| { - self.logger.debug("Encountered error: {s}", .{@errorName(err)}); + try self.logger.ERROR("Encountered error: {s}", .{@errorName(err)}); if (isUnhandledError(err)) return err; if (isBadRequest(err)) return try self.renderBadRequest(request); return try self.renderInternalServerError(request, err); @@ -230,7 +217,7 @@ fn renderView( return .{ .view = rendered_view, .content = "" }; } } else { - self.logger.debug("`request.render` was not invoked. Rendering empty content.", .{}); + try self.logger.WARN("`request.render` was not invoked. Rendering empty content.", .{}); request.response_data.reset(); return .{ .view = .{ .data = request.response_data, .status_code = .no_content }, @@ -254,7 +241,7 @@ fn renderTemplateWithLayout( if (zmpl.manifest.find(prefixed_name)) |layout| { return try template.renderWithLayout(layout, view.data); } else { - self.logger.debug("Unknown layout: {s}", .{layout_name}); + try self.logger.WARN("Unknown layout: {s}", .{layout_name}); return try template.render(view.data); } } else return try template.render(view.data); @@ -299,7 +286,7 @@ fn renderInternalServerError(self: *Self, request: *jetzig.http.Request, err: an try object.put("error", request.response_data.string(@errorName(err))); const stack = @errorReturnTrace(); - if (stack) |capture| try self.logStackTrace(capture, request, object); + if (stack) |capture| try self.logStackTrace(capture, request); return .{ .view = jetzig.views.View{ .data = request.response_data, .status_code = .internal_server_error }, @@ -324,40 +311,13 @@ fn logStackTrace( self: *Self, stack: *std.builtin.StackTrace, request: *jetzig.http.Request, - object: *jetzig.data.Value, ) !void { - _ = self; - std.debug.print("\nStack Trace:\n{}", .{stack}); - var array = std.ArrayList(u8).init(request.allocator); - const writer = array.writer(); + try self.logger.ERROR("\nStack Trace:\n{}", .{stack}); + var buf = std.ArrayList(u8).init(request.allocator); + defer buf.deinit(); + const writer = buf.writer(); try stack.format("", .{}, writer); - // TODO: Generate an array of objects with stack trace in useful data structure instead of - // dumping the whole formatted backtrace as a JSON string: - try object.put("backtrace", request.response_data.string(array.items)); -} - -fn requestLogMessage(self: *Self, request: *jetzig.http.Request) ![]const u8 { - const status: jetzig.http.status_codes.TaggedStatusCode = switch (request.response.status_code) { - inline else => |status_code| @unionInit( - jetzig.http.status_codes.TaggedStatusCode, - @tagName(status_code), - .{}, - ), - }; - - const formatted_duration = try jetzig.colors.duration(self.allocator, self.duration()); - defer self.allocator.free(formatted_duration); - - return try std.fmt.allocPrint(self.allocator, "[{s}/{s}/{s}] {s}", .{ - formatted_duration, - request.fmtMethod(), - status.format(), - request.path.path, - }); -} - -fn duration(self: *Self) i64 { - return @intCast(std.time.nanoTimestamp() - self.start_time); + try self.logger.ERROR("{s}\n", .{buf.items}); } fn matchRoute(self: *Self, request: *jetzig.http.Request, static: bool) !?*jetzig.views.Route { @@ -398,7 +358,7 @@ fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?StaticResour if (request.method != .GET) return null; var iterable_dir = std.fs.cwd().openDir( - jetzig.config.public_content.path, + jetzig.config.get([]const u8, "public_content_path"), .{ .iterate = true, .no_follow = true }, ) catch |err| { switch (err) { @@ -418,7 +378,7 @@ fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?StaticResour const content = try iterable_dir.readFileAlloc( request.allocator, file.path, - jetzig.config.max_bytes_static_content, + jetzig.config.get(usize, "max_bytes_public_content"), ); const extension = std.fs.path.extension(file.path); const mime_type = if (self.mime_map.get(extension)) |mime| mime else "application/octet-stream"; @@ -450,7 +410,7 @@ fn matchStaticContent(self: *Self, request: *jetzig.http.Request) !?[]const u8 { return static_dir.readFileAlloc( request.allocator, capture, - jetzig.config.max_bytes_static_content, + jetzig.config.get(usize, "max_bytes_static_content"), ) catch |err| { switch (err) { error.FileNotFound => return null, diff --git a/src/jetzig/http/Session.zig b/src/jetzig/http/Session.zig index 9dd55e3..343a4a5 100644 --- a/src/jetzig/http/Session.zig +++ b/src/jetzig/http/Session.zig @@ -3,7 +3,7 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); pub const cookie_name = "_jetzig-session"; -const Cipher = std.crypto.aead.aes_gcm.Aes256Gcm; +pub const Cipher = std.crypto.aead.aes_gcm.Aes256Gcm; allocator: std.mem.Allocator, encryption_key: ?[]const u8, @@ -99,7 +99,7 @@ fn save(self: *Self) !void { } self.encrypted = try self.encrypt(json); - const encoded = try jetzig.base64Encode(self.allocator, self.encrypted.?); + const encoded = try jetzig.util.base64Encode(self.allocator, self.encrypted.?); defer self.allocator.free(encoded); if (self.cookie) |*ptr| self.allocator.free(ptr.*.value); @@ -113,7 +113,7 @@ fn save(self: *Self) !void { fn parseSessionCookie(self: *Self, cookie_value: []const u8) !void { self.data = jetzig.data.Data.init(self.allocator); - const decoded = try jetzig.base64Decode(self.allocator, cookie_value); + const decoded = try jetzig.util.base64Decode(self.allocator, cookie_value); defer self.allocator.free(decoded); const buf = self.decrypt(decoded) catch |err| { diff --git a/src/jetzig/http/middleware.zig b/src/jetzig/http/middleware.zig index 965b612..bf28cf3 100644 --- a/src/jetzig/http/middleware.zig +++ b/src/jetzig/http/middleware.zig @@ -1,12 +1,7 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); -const server_options = jetzig.http.Server.jetzig_server_options; - -const middlewares: []const type = if (@hasDecl(server_options, "middleware")) - server_options.middleware -else - &.{}; +const middlewares: []const type = jetzig.config.get([]const type, "middleware"); const MiddlewareData = std.BoundedArray(*anyopaque, middlewares.len); diff --git a/src/jetzig/http/status_codes.zig b/src/jetzig/http/status_codes.zig index cc4bad6..5e78ad9 100644 --- a/src/jetzig/http/status_codes.zig +++ b/src/jetzig/http/status_codes.zig @@ -73,11 +73,12 @@ pub fn StatusCodeType(comptime code: []const u8, comptime message: []const u8) t const Self = @This(); - pub fn format(self: Self) []const u8 { + pub fn format(self: Self, colorized: bool) []const u8 { _ = self; - const full_message = code ++ " " ++ message; + if (!colorized) return full_message; + if (std.mem.startsWith(u8, code, "2")) { return jetzig.colors.green(full_message); } else if (std.mem.startsWith(u8, code, "3")) { @@ -158,9 +159,15 @@ pub const TaggedStatusCode = union(StatusCode) { const Self = @This(); - pub fn format(self: Self) []const u8 { + pub fn format(self: Self, colorized: bool) []const u8 { return switch (self) { - inline else => |capture| capture.format(), + inline else => |capture| capture.format(colorized), + }; + } + + pub fn getCode(self: Self) []const u8 { + return switch (self) { + inline else => |capture| capture.code, }; } }; diff --git a/src/jetzig/loggers.zig b/src/jetzig/loggers.zig index 53a4f6d..67683a7 100644 --- a/src/jetzig/loggers.zig +++ b/src/jetzig/loggers.zig @@ -1,21 +1,64 @@ const std = @import("std"); +const jetzig = @import("../jetzig.zig"); + const Self = @This(); pub const DevelopmentLogger = @import("loggers/DevelopmentLogger.zig"); +pub const JsonLogger = @import("loggers/JsonLogger.zig"); -const LogLevel = enum { - debug, -}; +pub const LogLevel = enum(u4) { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }; +pub const LogFormat = enum { development, json }; pub const Logger = union(enum) { development_logger: DevelopmentLogger, + json_logger: JsonLogger, - pub fn debug(self: *Logger, comptime message: []const u8, args: anytype) void { + /// Log a TRACE level message to the configured logger. + pub fn TRACE(self: *const Logger, comptime message: []const u8, args: anytype) !void { switch (self.*) { - inline else => |*case| case.debug(message, args) catch |err| { - std.debug.print("{}\n", .{err}); - }, + inline else => |*logger| try logger.log(.TRACE, message, args), + } + } + + /// Log a DEBUG level message to the configured logger. + pub fn DEBUG(self: *const Logger, comptime message: []const u8, args: anytype) !void { + switch (self.*) { + inline else => |*logger| try logger.log(.DEBUG, message, args), + } + } + + /// Log an INFO level message to the configured logger. + pub fn INFO(self: *const Logger, comptime message: []const u8, args: anytype) !void { + switch (self.*) { + inline else => |*logger| try logger.log(.INFO, message, args), + } + } + + /// Log a WARN level message to the configured logger. + pub fn WARN(self: *const Logger, comptime message: []const u8, args: anytype) !void { + switch (self.*) { + inline else => |*logger| try logger.log(.WARN, message, args), + } + } + + /// Log an ERROR level message to the configured logger. + pub fn ERROR(self: *const Logger, comptime message: []const u8, args: anytype) !void { + switch (self.*) { + inline else => |*logger| try logger.log(.ERROR, message, args), + } + } + + /// Log a FATAL level message to the configured logger. + pub fn FATAL(self: *const Logger, comptime message: []const u8, args: anytype) !void { + switch (self.*) { + inline else => |*logger| try logger.log(.FATAL, message, args), + } + } + + pub fn logRequest(self: *const Logger, request: *const jetzig.http.Request) !void { + switch (self.*) { + inline else => |*logger| try logger.logRequest(request), } } }; diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig index 90415e0..6575ab5 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -1,19 +1,105 @@ const std = @import("std"); -const Self = @This(); -const Timestamp = @import("../types/Timestamp.zig"); +const jetzig = @import("../../jetzig.zig"); + +const DevelopmentLogger = @This(); + +const Timestamp = jetzig.types.Timestamp; +const LogLevel = jetzig.loggers.LogLevel; allocator: std.mem.Allocator, +stdout: std.fs.File, +stderr: std.fs.File, +stdout_colorized: bool, +stderr_colorized: bool, +level: LogLevel, -pub fn init(allocator: std.mem.Allocator) Self { - return .{ .allocator = allocator }; +/// Initialize a new Development Logger. +pub fn init( + allocator: std.mem.Allocator, + level: LogLevel, + stdout: std.fs.File, + stderr: std.fs.File, +) DevelopmentLogger { + return .{ + .allocator = allocator, + .level = level, + .stdout = stdout, + .stderr = stderr, + .stdout_colorized = stdout.isTty(), + .stderr_colorized = stderr.isTty(), + }; } -pub fn debug(self: *Self, comptime message: []const u8, args: anytype) !void { +/// Generic log function, receives log level, message (format string), and args for format string. +pub fn log( + self: DevelopmentLogger, + comptime level: LogLevel, + comptime message: []const u8, + args: anytype, +) !void { + if (@intFromEnum(level) < @intFromEnum(self.level)) return; + const output = try std.fmt.allocPrint(self.allocator, message, args); defer self.allocator.free(output); + const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); const iso8601 = try timestamp.iso8601(); defer self.allocator.free(iso8601); - std.debug.print("[{s}] {s}\n", .{ iso8601, output }); + + const colorized = switch (level) { + .TRACE, .DEBUG, .INFO => self.stdout_colorized, + .WARN, .ERROR, .FATAL => self.stderr_colorized, + }; + const file = switch (level) { + .TRACE, .DEBUG, .INFO => self.stdout, + .WARN, .ERROR, .FATAL => self.stderr, + }; + const writer = file.writer(); + const level_formatted = if (colorized) colorizedLogLevel(level) else @tagName(level); + + try writer.print("{s: >5} [{s}] {s}\n", .{ level_formatted, iso8601, output }); + + if (!file.isTty()) try file.sync(); +} + +/// Log a one-liner including response status code, path, method, duration, etc. +pub fn logRequest(self: DevelopmentLogger, request: *const jetzig.http.Request) !void { + const formatted_duration = if (self.stdout_colorized) + try jetzig.colors.duration(self.allocator, jetzig.util.duration(request.start_time)) + else + try std.fmt.allocPrint( + self.allocator, + "{}", + .{std.fmt.fmtDurationSigned(jetzig.util.duration(request.start_time))}, + ); + defer self.allocator.free(formatted_duration); + + const status: jetzig.http.status_codes.TaggedStatusCode = switch (request.response.status_code) { + inline else => |status_code| @unionInit( + jetzig.http.status_codes.TaggedStatusCode, + @tagName(status_code), + .{}, + ), + }; + + const message = try std.fmt.allocPrint(self.allocator, "[{s}/{s}/{s}] {s}", .{ + formatted_duration, + request.fmtMethod(self.stdout_colorized), + status.format(self.stdout_colorized), + request.path.path, + }); + defer self.allocator.free(message); + try self.log(.INFO, "{s}", .{message}); +} + +fn colorizedLogLevel(comptime level: LogLevel) []const u8 { + return switch (level) { + .TRACE => jetzig.colors.white(@tagName(level)), + .DEBUG => jetzig.colors.cyan(@tagName(level)), + .INFO => jetzig.colors.blue(@tagName(level) ++ " "), + .WARN => jetzig.colors.yellow(@tagName(level) ++ " "), + .ERROR => jetzig.colors.red(@tagName(level)), + .FATAL => jetzig.colors.red(@tagName(level)), + }; } diff --git a/src/jetzig/loggers/JsonLogger.zig b/src/jetzig/loggers/JsonLogger.zig new file mode 100644 index 0000000..ae2600d --- /dev/null +++ b/src/jetzig/loggers/JsonLogger.zig @@ -0,0 +1,114 @@ +const std = @import("std"); + +const jetzig = @import("../../jetzig.zig"); + +const JsonLogger = @This(); + +const Timestamp = jetzig.types.Timestamp; +const LogLevel = jetzig.loggers.LogLevel; +const LogMessage = struct { + level: []const u8, + timestamp: []const u8, + message: []const u8, +}; +const RequestLogMessage = struct { + level: []const u8, + timestamp: []const u8, + method: []const u8, + status: []const u8, + path: []const u8, + duration: i64, +}; + +allocator: std.mem.Allocator, +stdout: std.fs.File, +stderr: std.fs.File, +level: LogLevel, + +/// Initialize a new JSON Logger. +pub fn init( + allocator: std.mem.Allocator, + level: LogLevel, + stdout: std.fs.File, + stderr: std.fs.File, +) JsonLogger { + return .{ + .allocator = allocator, + .level = level, + .stdout = stdout, + .stderr = stderr, + }; +} + +/// Generic log function, receives log level, message (format string), and args for format string. +pub fn log( + self: JsonLogger, + comptime level: LogLevel, + comptime message: []const u8, + args: anytype, +) !void { + if (@intFromEnum(level) < @intFromEnum(self.level)) return; + + const output = try std.fmt.allocPrint(self.allocator, message, args); + defer self.allocator.free(output); + + const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); + const iso8601 = try timestamp.iso8601(); + defer self.allocator.free(iso8601); + + const file = self.getFile(level); + const writer = file.writer(); + const log_message = LogMessage{ .level = @tagName(level), .timestamp = iso8601, .message = output }; + + const json = try std.json.stringifyAlloc(self.allocator, log_message, .{ .whitespace = .minified }); + defer self.allocator.free(json); + + try writer.writeAll(json); + try writer.writeByte('\n'); + + if (!file.isTty()) try file.sync(); // Make configurable ? +} + +/// Log a one-liner including response status code, path, method, duration, etc. +pub fn logRequest(self: JsonLogger, request: *const jetzig.http.Request) !void { + const level: LogLevel = .INFO; + + const duration = jetzig.util.duration(request.start_time); + + const timestamp = Timestamp.init(std.time.timestamp(), self.allocator); + const iso8601 = try timestamp.iso8601(); + defer self.allocator.free(iso8601); + + const status = switch (request.response.status_code) { + inline else => |status_code| @unionInit( + jetzig.http.status_codes.TaggedStatusCode, + @tagName(status_code), + .{}, + ), + }; + const message = RequestLogMessage{ + .level = @tagName(level), + .timestamp = iso8601, + .method = @tagName(request.method), + .status = status.getCode(), + .path = request.path.path, + .duration = duration, + }; + const json = try std.json.stringifyAlloc(self.allocator, message, .{ .whitespace = .minified }); + defer self.allocator.free(json); + + const file = self.getFile(level); + const writer = file.writer(); + + try writer.writeAll(json); + try writer.writeByte('\n'); + + if (!file.isTty()) try file.sync(); // Make configurable ? +} + +fn getFile(self: JsonLogger, level: LogLevel) std.fs.File { + return switch (level) { + .TRACE, .DEBUG, .INFO => self.stdout, + .WARN, .ERROR, .FATAL => self.stderr, + }; +} diff --git a/src/jetzig/middleware/HtmxMiddleware.zig b/src/jetzig/middleware/HtmxMiddleware.zig index 453b5d0..ebedcfe 100644 --- a/src/jetzig/middleware/HtmxMiddleware.zig +++ b/src/jetzig/middleware/HtmxMiddleware.zig @@ -16,7 +16,7 @@ pub fn init(request: *jetzig.http.Request) !*Self { pub fn afterRequest(self: *Self, request: *jetzig.http.Request) !void { _ = self; if (request.getHeader("HX-Target")) |target| { - request.server.logger.debug( + try request.server.logger.DEBUG( "[middleware-htmx] htmx request detected, disabling layout. (#{s})", .{target}, ); diff --git a/src/jetzig/util.zig b/src/jetzig/util.zig index 495beaa..6e40ada 100644 --- a/src/jetzig/util.zig +++ b/src/jetzig/util.zig @@ -1,5 +1,6 @@ const std = @import("std"); +/// Compare two strings with case-insensitive matching. pub fn equalStringsCaseInsensitive(expected: []const u8, actual: []const u8) bool { if (expected.len != actual.len) return false; for (expected, actual) |expected_char, actual_char| { @@ -7,3 +8,44 @@ pub fn equalStringsCaseInsensitive(expected: []const u8, actual: []const u8) boo } return true; } + +/// Encode arbitrary input to Base64. +pub fn base64Encode(allocator: std.mem.Allocator, string: []const u8) ![]const u8 { + const encoder = std.base64.Base64Encoder.init( + std.base64.url_safe_no_pad.alphabet_chars, + std.base64.url_safe_no_pad.pad_char, + ); + const size = encoder.calcSize(string.len); + const ptr = try allocator.alloc(u8, size); + _ = encoder.encode(ptr, string); + return ptr; +} + +/// Decode arbitrary input from Base64. +pub fn base64Decode(allocator: std.mem.Allocator, string: []const u8) ![]const u8 { + const decoder = std.base64.Base64Decoder.init( + std.base64.url_safe_no_pad.alphabet_chars, + std.base64.url_safe_no_pad.pad_char, + ); + const size = try decoder.calcSizeForSlice(string); + const ptr = try allocator.alloc(u8, size); + try decoder.decode(ptr, string); + return ptr; +} + +/// Generate a secure random string of `len` characters (for cryptographic purposes). +pub fn generateSecret(allocator: std.mem.Allocator, comptime len: u10) ![]const u8 { + const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + var secret: [len]u8 = undefined; + + for (0..len) |index| { + secret[index] = chars[std.crypto.random.intRangeAtMost(u8, 0, chars.len)]; + } + + return try allocator.dupe(u8, &secret); +} + +/// Calculate a duration from a given start time (in nanoseconds) to the current time. +pub fn duration(start_time: i128) i64 { + return @intCast(std.time.nanoTimestamp() - start_time); +}