From 87bcc4c9e0b69927fd0a6e63e2e772365905eebd Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Tue, 19 Mar 2024 23:03:55 +0000 Subject: [PATCH 1/5] Provide tooling for running Jetzig in deployment --- build.zig | 2 +- build.zig.zon | 4 +- cli/build.zig.zon | 4 +- cli/commands/generate.zig | 8 +- cli/commands/generate/secret.zig | 16 +++ demo/src/app/middleware/DemoMiddleware.zig | 9 +- src/jetzig.zig | 67 +---------- src/jetzig/App.zig | 30 +++-- src/jetzig/Environment.zig | 124 +++++++++++++++++++++ src/jetzig/caches.zig | 30 ----- src/jetzig/caches/Cache.zig | 5 - src/jetzig/caches/MemoryCache.zig | 39 ------- src/jetzig/caches/NullCache.zig | 27 ----- src/jetzig/caches/Result.zig | 17 --- src/jetzig/http/Request.zig | 6 +- src/jetzig/http/Server.zig | 57 +++++----- src/jetzig/http/Session.zig | 6 +- src/jetzig/http/status_codes.zig | 9 +- src/jetzig/loggers.zig | 52 +++++++-- src/jetzig/loggers/DevelopmentLogger.zig | 65 ++++++++++- src/jetzig/middleware/HtmxMiddleware.zig | 2 +- src/jetzig/util.zig | 37 ++++++ 22 files changed, 365 insertions(+), 251 deletions(-) create mode 100644 cli/commands/generate/secret.zig create mode 100644 src/jetzig/Environment.zig delete mode 100644 src/jetzig/caches.zig delete mode 100644 src/jetzig/caches/Cache.zig delete mode 100644 src/jetzig/caches/MemoryCache.zig delete mode 100644 src/jetzig/caches/NullCache.zig delete mode 100644 src/jetzig/caches/Result.zig 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..1bc2895 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/bobf/zig-args/archive/e827c93f00e8bd95bd4b970c59593f393a6b08d5.tar.gz", + .hash = "12208a1de366740d11de525db7289345949f5fd46527db3f89eecc7bb49b012c0732", }, }, diff --git a/cli/build.zig.zon b/cli/build.zig.zon index 9350709..f703560 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/bobf/zig-args/archive/e827c93f00e8bd95bd4b970c59593f393a6b08d5.tar.gz", + .hash = "12208a1de366740d11de525db7289345949f5fd46527db3f89eecc7bb49b012c0732", }, }, .paths = .{ 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/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/src/jetzig.zig b/src/jetzig.zig index 91447f7..9f782a9 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; @@ -43,36 +46,11 @@ 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 +132,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..73f2d84 --- /dev/null +++ b/src/jetzig/Environment.zig @@ -0,0 +1,124 @@ +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, + environment: []const u8 = "development", + log: []const u8 = "-", + @"log-error": []const u8 = "-", + @"log-level": jetzig.loggers.LogLevel = .DEBUG, + detach: bool = false, + + pub const shorthands = .{ + .h = "help", + .b = "bind", + .p = "port", + .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)", + .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 stdout. If omitted, errors are logged to the location specified by the `log` option. + , + .@"log-level" = + \\Specify the minimum log level. Log events below the given level are ignored. Must be one of: TRACE, DEBUG, INFO, WARN, ERROR, FATAL (default: DEBUG) + , + .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 = jetzig.loggers.Logger{ + .development_logger = jetzig.loggers.DevelopmentLogger.init( + self.allocator, + try getLogFile(options.options.log), + ), + }; + + 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(path: []const u8) !std.fs.File { + if (std.mem.eql(u8, path, "-")) return std.io.getStdOut(); + + 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..9992bfb 100644 --- a/src/jetzig/http/Request.zig +++ b/src/jetzig/http/Request.zig @@ -101,7 +101,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, @@ -310,7 +310,9 @@ pub fn hash(self: *Self) ![]const u8 { ); } -pub fn fmtMethod(self: *Self) []const u8 { +pub fn fmtMethod(self: *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..c3e3bca 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -11,37 +11,32 @@ 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 +45,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(); } @@ -77,7 +75,6 @@ fn processRequests(self: *Self) !void { 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; @@ -113,7 +110,7 @@ fn processNextRequest(self: *Self, allocator: std.mem.Allocator, std_http_server const log_message = try self.requestLogMessage(&request); defer self.allocator.free(log_message); - self.logger.debug("{s}", .{log_message}); + try self.logger.INFO("{s}", .{log_message}); } fn renderResponse(self: *Self, request: *jetzig.http.Request) !void { @@ -210,7 +207,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 +227,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 +251,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 +296,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,16 +321,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)); + try self.logger.ERROR("{s}", .{buf.items}); } fn requestLogMessage(self: *Self, request: *jetzig.http.Request) ![]const u8 { @@ -345,13 +339,16 @@ fn requestLogMessage(self: *Self, request: *jetzig.http.Request) ![]const u8 { ), }; - const formatted_duration = try jetzig.colors.duration(self.allocator, self.duration()); + const formatted_duration = if (self.logger.isColorized()) + try jetzig.colors.duration(self.allocator, self.duration()) + else + try std.fmt.allocPrint(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.fmtMethod(self.logger.isColorized()), + status.format(self.logger.isColorized()), request.path.path, }); } 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/status_codes.zig b/src/jetzig/http/status_codes.zig index cc4bad6..f1e0008 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,9 @@ 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), }; } }; diff --git a/src/jetzig/loggers.zig b/src/jetzig/loggers.zig index 53a4f6d..367678d 100644 --- a/src/jetzig/loggers.zig +++ b/src/jetzig/loggers.zig @@ -4,18 +4,56 @@ const Self = @This(); pub const DevelopmentLogger = @import("loggers/DevelopmentLogger.zig"); -const LogLevel = enum { - debug, -}; +pub const LogLevel = enum { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }; pub const Logger = union(enum) { development_logger: DevelopmentLogger, - pub fn debug(self: *Logger, comptime message: []const u8, args: anytype) void { + pub fn isColorized(self: Logger) bool { + switch (self) { + inline else => |logger| return logger.isColorized(), + } + } + + /// 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.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.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.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.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.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.FATAL(message, args), } } }; diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig index 90415e0..038ae92 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -1,19 +1,74 @@ const std = @import("std"); +const jetzig = @import("../../jetzig.zig"); + const Self = @This(); -const Timestamp = @import("../types/Timestamp.zig"); + +const Timestamp = jetzig.types.Timestamp; +const LogLevel = jetzig.loggers.LogLevel; allocator: std.mem.Allocator, +file: std.fs.File, +colorized: bool, -pub fn init(allocator: std.mem.Allocator) Self { - return .{ .allocator = allocator }; +pub fn init(allocator: std.mem.Allocator, file: std.fs.File) Self { + return .{ .allocator = allocator, .file = file, .colorized = file.isTty() }; } -pub fn debug(self: *Self, comptime message: []const u8, args: anytype) !void { +/// Return true if logger was initialized with colorization (i.e. if log file is a tty) +pub fn isColorized(self: Self) bool { + return self.colorized; +} + +/// Log a TRACE level message to the configured logger. +pub fn TRACE(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.DEBUG, message, args); +} + +/// Log a DEBUG level message to the configured logger. +pub fn DEBUG(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.DEBUG, message, args); +} + +/// Log an INFO level message to the configured logger. +pub fn INFO(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.INFO, message, args); +} + +/// Log a WARN level message to the configured logger. +pub fn WARN(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.WARN, message, args); +} + +/// Log an ERROR level message to the configured logger. +pub fn ERROR(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.ERROR, message, args); +} + +/// Log a FATAL level message to the configured logger. +pub fn FATAL(self: *const Self, comptime message: []const u8, args: anytype) !void { + try self.log(.FATAL, message, args); +} + +pub fn log(self: Self, comptime level: LogLevel, comptime message: []const u8, args: anytype) !void { 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 writer = self.file.writer(); + const level_formatted = if (self.colorized) colorizedLogLevel(level) else @tagName(level); + try writer.print("{s: >5} [{s}] {s}\n", .{ level_formatted, iso8601, output }); + if (!self.file.isTty()) try self.file.sync(); +} + +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/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..0301b75 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,39 @@ 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); +} From 381c38d85d67a7bdce0b25af2564d4d8af084ac3 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Wed, 20 Mar 2024 21:17:14 +0000 Subject: [PATCH 2/5] Overhaul logging, implement JSON logger Log errors to separate files, specify minimum log level, implement JSON logging. --- build.zig.zon | 2 +- cli/build.zig.zon | 2 +- demo/src/main.zig | 6 ++ src/jetzig.zig | 37 +++++++- src/jetzig/Environment.zig | 55 ++++++++--- src/jetzig/http/Request.zig | 7 +- src/jetzig/http/Server.zig | 53 ++--------- src/jetzig/http/middleware.zig | 7 +- src/jetzig/http/status_codes.zig | 6 ++ src/jetzig/loggers.zig | 31 +++--- src/jetzig/loggers/DevelopmentLogger.zig | 113 +++++++++++++--------- src/jetzig/loggers/JsonLogger.zig | 114 +++++++++++++++++++++++ src/jetzig/util.zig | 5 + 13 files changed, 312 insertions(+), 126 deletions(-) create mode 100644 src/jetzig/loggers/JsonLogger.zig diff --git a/build.zig.zon b/build.zig.zon index 1bc2895..9aa49ce 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -7,7 +7,7 @@ .hash = "1220e5ede084ca6b94defd466a8f8779aab151d37bf688fefb928fded6f02cde4135", }, .args = .{ - .url = "https://github.com/bobf/zig-args/archive/e827c93f00e8bd95bd4b970c59593f393a6b08d5.tar.gz", + .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 f703560..66a91ad 100644 --- a/cli/build.zig.zon +++ b/cli/build.zig.zon @@ -5,7 +5,7 @@ .dependencies = .{ .args = .{ - .url = "https://github.com/bobf/zig-args/archive/e827c93f00e8bd95bd4b970c59593f393a6b08d5.tar.gz", + .url = "https://github.com/MasterQ32/zig-args/archive/01d72b9a0128c474aeeb9019edd48605fa6d95f7.tar.gz", .hash = "12208a1de366740d11de525db7289345949f5fd46527db3f89eecc7bb49b012c0732", }, }, diff --git a/demo/src/main.zig b/demo/src/main.zig index 6ba3a19..bfd2fec 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -3,6 +3,7 @@ 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 { pub const middleware: []const type = &.{ // htmx middleware skips layouts when `HX-Target` header is present and issues @@ -10,6 +11,11 @@ pub const jetzig_options = struct { jetzig.middleware.HtmxMiddleware, @import("app/middleware/DemoMiddleware.zig"), }; + + 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); + pub const http_buffer_size: usize = std.math.pow(usize, 2, 16); + pub const public_content_path = "public"; }; pub fn main() !void { diff --git a/src/jetzig.zig b/src/jetzig.zig index 9f782a9..39be497 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -35,13 +35,48 @@ 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); + + /// Maximum filesize for `public/` content. pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 16); + + /// 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); diff --git a/src/jetzig/Environment.zig b/src/jetzig/Environment.zig index 73f2d84..4b81fd5 100644 --- a/src/jetzig/Environment.zig +++ b/src/jetzig/Environment.zig @@ -12,17 +12,20 @@ const Options = struct { help: bool = false, bind: []const u8 = "127.0.0.1", port: u16 = 8080, - environment: []const u8 = "development", + // TODO: + // environment: []const u8 = "development", log: []const u8 = "-", @"log-error": []const u8 = "-", - @"log-level": jetzig.loggers.LogLevel = .DEBUG, + @"log-level": jetzig.loggers.LogLevel = .INFO, + @"log-format": jetzig.loggers.LogFormat = .development, detach: bool = false, pub const shorthands = .{ .h = "help", .b = "bind", .p = "port", - .e = "environment", + // TODO: + // .e = "environment", .d = "detach", }; @@ -32,13 +35,17 @@ const Options = struct { .option_docs = .{ .bind = "IP address/hostname to bind to (default: 127.0.0.1)", .port = "Port to listen on (default: 8080)", - .environment = "Load an environment configuration from src/app/environments/.zig", - .log = "Path to log file. Use '-' for stdout (default: -)", + // 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 stdout. If omitted, errors are logged to the location specified by the `log` option. + \\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" = - \\Specify the minimum log level. Log events below the given level are ignored. Must be one of: TRACE, DEBUG, INFO, WARN, ERROR, FATAL (default: DEBUG) + \\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) @@ -62,11 +69,23 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { std.process.exit(0); } - var logger = jetzig.loggers.Logger{ - .development_logger = jetzig.loggers.DevelopmentLogger.init( - self.allocator, - try getLogFile(options.options.log), - ), + 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, "-")) { @@ -93,8 +112,16 @@ pub fn getServerOptions(self: Environment) !jetzig.http.Server.ServerOptions { }; } -fn getLogFile(path: []const u8) !std.fs.File { - if (std.mem.eql(u8, path, "-")) return std.io.getStdOut(); +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 => std.io.getStdErr(), + }; const file = try std.fs.createFileAbsolute(path, .{ .truncate = false }); try file.seekFromEnd(0); diff --git a/src/jetzig/http/Request.zig b/src/jetzig/http/Request.zig index 9992bfb..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, }; } @@ -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,7 @@ pub fn hash(self: *Self) ![]const u8 { ); } -pub fn fmtMethod(self: *Self, colorized: bool) []const u8 { +pub fn fmtMethod(self: *const Self, colorized: bool) []const u8 { if (!colorized) return @tagName(self.method); return switch (self.method) { diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index c3e3bca..fe95207 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -3,13 +3,6 @@ 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 { logger: jetzig.loggers.Logger, bind: []const u8, @@ -21,7 +14,6 @@ pub const ServerOptions = struct { allocator: std.mem.Allocator, 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, @@ -69,7 +61,7 @@ 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(); @@ -86,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(); @@ -108,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); - try self.logger.INFO("{s}", .{log_message}); + try self.logger.logRequest(&request); } fn renderResponse(self: *Self, request: *jetzig.http.Request) !void { @@ -327,34 +317,7 @@ fn logStackTrace( defer buf.deinit(); const writer = buf.writer(); try stack.format("", .{}, writer); - try self.logger.ERROR("{s}", .{buf.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 = if (self.logger.isColorized()) - try jetzig.colors.duration(self.allocator, self.duration()) - else - try std.fmt.allocPrint(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(self.logger.isColorized()), - status.format(self.logger.isColorized()), - 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 { @@ -395,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) { @@ -415,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_static_content"), ); const extension = std.fs.path.extension(file.path); const mime_type = if (self.mime_map.get(extension)) |mime| mime else "application/octet-stream"; @@ -447,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/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 f1e0008..5e78ad9 100644 --- a/src/jetzig/http/status_codes.zig +++ b/src/jetzig/http/status_codes.zig @@ -164,4 +164,10 @@ pub const TaggedStatusCode = union(StatusCode) { 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 367678d..67683a7 100644 --- a/src/jetzig/loggers.zig +++ b/src/jetzig/loggers.zig @@ -1,59 +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"); -pub const LogLevel = enum { TRACE, DEBUG, INFO, WARN, ERROR, FATAL }; +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, - - pub fn isColorized(self: Logger) bool { - switch (self) { - inline else => |logger| return logger.isColorized(), - } - } + json_logger: JsonLogger, /// 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 => |*logger| try logger.TRACE(message, args), + 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.DEBUG(message, args), + 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.INFO(message, args), + 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.WARN(message, args), + 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.ERROR(message, args), + 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.FATAL(message, args), + 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 038ae92..cd1ba18 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -2,64 +2,91 @@ const std = @import("std"); const jetzig = @import("../../jetzig.zig"); -const Self = @This(); +const DevelopmentLogger = @This(); const Timestamp = jetzig.types.Timestamp; const LogLevel = jetzig.loggers.LogLevel; allocator: std.mem.Allocator, -file: std.fs.File, -colorized: bool, +stdout: std.fs.File, +stderr: std.fs.File, +stdout_colorized: bool, +stderr_colorized: bool, +level: LogLevel, -pub fn init(allocator: std.mem.Allocator, file: std.fs.File) Self { - return .{ .allocator = allocator, .file = file, .colorized = file.isTty() }; +/// 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(), + }; } -/// Return true if logger was initialized with colorization (i.e. if log file is a tty) -pub fn isColorized(self: Self) bool { - return self.colorized; -} +/// 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; -/// Log a TRACE level message to the configured logger. -pub fn TRACE(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.DEBUG, message, args); -} - -/// Log a DEBUG level message to the configured logger. -pub fn DEBUG(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.DEBUG, message, args); -} - -/// Log an INFO level message to the configured logger. -pub fn INFO(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.INFO, message, args); -} - -/// Log a WARN level message to the configured logger. -pub fn WARN(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.WARN, message, args); -} - -/// Log an ERROR level message to the configured logger. -pub fn ERROR(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.ERROR, message, args); -} - -/// Log a FATAL level message to the configured logger. -pub fn FATAL(self: *const Self, comptime message: []const u8, args: anytype) !void { - try self.log(.FATAL, message, args); -} - -pub fn log(self: Self, comptime level: LogLevel, comptime message: []const u8, args: anytype) !void { 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 writer = self.file.writer(); - const level_formatted = if (self.colorized) colorizedLogLevel(level) else @tagName(level); + + 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 (!self.file.isTty()) try self.file.sync(); + + 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, "{}", .{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 { 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/util.zig b/src/jetzig/util.zig index 0301b75..6e40ada 100644 --- a/src/jetzig/util.zig +++ b/src/jetzig/util.zig @@ -44,3 +44,8 @@ pub fn generateSecret(allocator: std.mem.Allocator, comptime len: u10) ![]const 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); +} From cc0a1c5792b16d4956396a8c67be1b3960424b1c Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Wed, 20 Mar 2024 21:26:59 +0000 Subject: [PATCH 3/5] Document default config values in init project --- demo/src/main.zig | 24 +++++++++++++++++++----- src/jetzig.zig | 5 ++++- src/jetzig/http/Server.zig | 2 +- 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/demo/src/main.zig b/demo/src/main.zig index bfd2fec..268d2eb 100644 --- a/demo/src/main.zig +++ b/demo/src/main.zig @@ -3,19 +3,33 @@ const std = @import("std"); pub const jetzig = @import("jetzig"); pub const routes = @import("routes").routes; -// Override default settings in jetzig.config here: +// 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"), }; - 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); - pub const http_buffer_size: usize = std.math.pow(usize, 2, 16); - pub const public_content_path = "public"; + // 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 39be497..3657618 100644 --- a/src/jetzig.zig +++ b/src/jetzig.zig @@ -49,7 +49,10 @@ pub const config = struct { pub const max_bytes_request_body: usize = std.math.pow(usize, 2, 16); /// Maximum filesize for `public/` content. - pub const max_bytes_static_content: usize = std.math.pow(usize, 2, 16); + 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"; diff --git a/src/jetzig/http/Server.zig b/src/jetzig/http/Server.zig index fe95207..dd8b60e 100644 --- a/src/jetzig/http/Server.zig +++ b/src/jetzig/http/Server.zig @@ -378,7 +378,7 @@ fn matchPublicContent(self: *Self, request: *jetzig.http.Request) !?StaticResour const content = try iterable_dir.readFileAlloc( request.allocator, file.path, - jetzig.config.get(usize, "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"; From 09bbcebb563049fa91ab1542d4300796cb064278 Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Wed, 20 Mar 2024 23:14:51 +0000 Subject: [PATCH 4/5] Deployment bundle This needs some work (and testing on Windows) but it solves simple cases and provides a starting point for a more advanced bundler. --- cli/cli.zig | 14 +++- cli/commands/bundle.zig | 139 ++++++++++++++++++++++++++++++++++++++++ cli/commands/server.zig | 38 ++--------- cli/util.zig | 41 ++++++++++++ 4 files changed, 197 insertions(+), 35 deletions(-) create mode 100644 cli/commands/bundle.zig 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/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; +} From 054d1b5d8217a283d29fdee0df93250c1625f48d Mon Sep 17 00:00:00 2001 From: Bob Farrell Date: Wed, 20 Mar 2024 23:39:27 +0000 Subject: [PATCH 5/5] Fix default error logfile Don't use stderr for error logfile if primary logfile is not stdout (i.e. always use the same file). Fix non-colorized duration in DeveolpmentLogger - use std.fmt.fmtDurationSigned. --- src/jetzig/Environment.zig | 5 ++++- src/jetzig/loggers/DevelopmentLogger.zig | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/jetzig/Environment.zig b/src/jetzig/Environment.zig index 4b81fd5..be8f842 100644 --- a/src/jetzig/Environment.zig +++ b/src/jetzig/Environment.zig @@ -120,7 +120,10 @@ fn getLogFile(stream: enum { stdout, stderr }, options: Options) !std.fs.File { if (std.mem.eql(u8, path, "-")) return switch (stream) { .stdout => std.io.getStdOut(), - .stderr => std.io.getStdErr(), + .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 }); diff --git a/src/jetzig/loggers/DevelopmentLogger.zig b/src/jetzig/loggers/DevelopmentLogger.zig index cd1ba18..6575ab5 100644 --- a/src/jetzig/loggers/DevelopmentLogger.zig +++ b/src/jetzig/loggers/DevelopmentLogger.zig @@ -68,7 +68,11 @@ pub fn logRequest(self: DevelopmentLogger, request: *const jetzig.http.Request) 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, "{}", .{jetzig.util.duration(request.start_time)}); + 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) {