Debug console

Use `jetzig server` or `zig build -Ddebug_console=true run` to launch a
development server with the debug console enabled.

When an error is encountered a formatted stack trace is dumped to the
browser along with the current response data. Both outputs are
syntax-highlighted using Prism JS.
This commit is contained in:
Bob Farrell 2024-11-23 18:07:19 +00:00
parent 8095bbcb76
commit 1565ae3b73
10 changed files with 464 additions and 13 deletions

21
LICENSE-Zig Normal file
View File

@ -0,0 +1,21 @@
The MIT License (Expat)
Copyright (c) Zig contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -110,6 +110,7 @@ pub fn build(b: *std.Build) !void {
const test_build_options = b.addOptions();
test_build_options.addOption(Environment, "environment", .testing);
test_build_options.addOption(bool, "build_static", true);
test_build_options.addOption(bool, "debug_console", false);
const run_main_tests = b.addRunArtifact(main_tests);
main_tests.root_module.addOptions("build_options", test_build_options);
@ -138,12 +139,24 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
"environment",
"Jetzig server environment.",
) orelse .development;
const build_static = b.option(
bool,
"build_static",
"Pre-render static routes. [default: false in development, true in testing/production]",
) orelse (environment != .development);
const debug_console = b.option(
bool,
"debug_console",
"Render a debug console on error. Default: false.",
) orelse false;
if (debug_console == true and environment != .development) {
std.debug.print("Environment must be `development` when `debug_console` is enabled.", .{});
return error.JetzigBuildError;
}
const jetzig_dep = b.dependency(
"jetzig",
.{
@ -171,6 +184,7 @@ pub fn jetzigInit(b: *std.Build, exe: *std.Build.Step.Compile, options: JetzigIn
const build_options = b.addOptions();
build_options.addOption(Environment, "environment", environment);
build_options.addOption(bool, "build_static", build_static);
build_options.addOption(bool, "debug_console", debug_console);
jetzig_module.addOptions("build_options", build_options);
exe.root_module.addImport("jetzig", jetzig_module);

View File

@ -70,6 +70,7 @@ pub fn run(
"build",
util.environmentBuildOption(main_options.options.environment),
"-Djetzig_runner=true",
"-Ddebug_console=true",
"install",
"--color",
"on",

View File

@ -1,7 +1,12 @@
const std = @import("std");
const jetzig = @import("jetzig");
pub const layout = "application";
// Anti-CSRF middleware can be included in the view's `actions` declaration to apply CSRF
// protection just to this specific view, or it can be added to your application's global
// middleware stack defined in `jetzig_options` in `src/main.zig`.
//
// Use `{{context.authenticityToken()}}` or `{{context.authenticityFormField()}}` in a Zmpl
// template to generate a token, store it in the user's session, and inject it into the page.
pub const actions = .{
.before = .{jetzig.middleware.AntiCsrfMiddleware},

View File

@ -6,3 +6,5 @@
<input type="submit" value="Submit Spam" />
</form>
<div>Try clearing `_jetzig_session` cookie before clicking "Submit Spam"</div>

121
src/assets/debug.css Normal file
View File

@ -0,0 +1,121 @@
/* reset https://www.joshwcomeau.com/css/custom-css-reset/ */
*, *::before, *::after {
box-sizing: border-box;
}
* {
margin: 0;
}
body {
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img, picture, video, canvas, svg {
display: block;
max-width: 100%;
}
input, button, textarea, select {
font: inherit;
}
p, h1, h2, h3, h4, h5, h6 {
overflow-wrap: break-word;
}
p {
text-wrap: pretty;
}
h1, h2, h3, h4, h5, h6 {
text-wrap: balance;
}
#root, #__next {
isolation: isolate;
}
/* styles */
body {
background-color: #222;
}
h1 {
font-family: monospace;
color: #e55;
padding: 1rem;
font-size: 1.6rem;
}
h2 {
font-family: monospace;
color: #a0a0a0;
padding: 1rem;
font-size: 1.6rem;
}
.stack-trace {
/* background-color: #e555; */
padding: 1rem;
font-family: monospace;
}
.stack-trace .stack-source-line .file-name {
color: #90bfd7;
display: block;
padding: 0.4rem;
font-weight: bold;
}
.stack-trace .stack-source-line {
background-color: #333;
padding: 1rem;
margin: 0;
border-bottom: 1px solid #ffa3;
}
.stack-trace .stack-source-line:last-child {
border-bottom: none;
}
.stack-trace .stack-source-line .line-content {
margin: 0;
padding: 0;
}
.stack-trace .stack-source-line .line-content.surrounding {
color: #ffa;
}
.stack-trace .stack-source-line .line-content.target {
color: #faa;
background-color: #e552;
}
.stack-trace .stack-source-line .line-content .line-number {
display: inline;
}
pre {
display: inline;
margin-right: 1rem;
}
.response-data {
color: #fff;
background-color: #333;
padding: 1rem;
margin: 1rem;
}
/* PrismJS 1.29.0
https://prismjs.com/download.html#themes=prism-tomorrow&languages=json+zig */
code[class*=language-],pre[class*=language-]{color:#ccc;font-family:monospace;text-align:left;white-space:pre;word-spacing:normal;word-break:normal;word-wrap:normal;-moz-tab-size:4;-o-tab-size:4;tab-size:4;-webkit-hyphens:none;-moz-hyphens:none;-ms-hyphens:none;hyphens:none}pre[class*=language-]{overflow:auto}:not(pre)>code[class*=language-],pre[class*=language-]:not(pre)>code[class*=language-]{white-space:normal}.token.block-comment,.token.cdata,.token.comment,.token.doctype,.token.prolog{color:#999}.token.punctuation{color:#ccc}.token.attr-name,.token.deleted,.token.namespace,.token.tag{color:#e2777a}.token.function-name{color:#6196cc}.token.boolean,.token.function,.token.number{color:#f08d49}.token.class-name,.token.constant,.token.property,.token.symbol{color:#f8c555}.token.atrule,.token.builtin,.token.important,.token.keyword,.token.selector{color:#cc99cd}.token.attr-value,.token.char,.token.regex,.token.string,.token.variable{color:#7ec699}.token.entity,.token.operator,.token.url{color:#67cdcc}.token.bold,.token.important{font-weight:700}.token.italic{font-style:italic}.token.entity{cursor:help}.token.inserted{color:green}
.stack-trace .stack-source-line .line-number {
color: #ffa !important;
}

5
src/assets/debug.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -23,6 +23,7 @@ pub const testing = @import("jetzig/testing.zig");
pub const config = @import("jetzig/config.zig");
pub const auth = @import("jetzig/auth.zig");
pub const callbacks = @import("jetzig/callbacks.zig");
pub const debug = @import("jetzig/debug.zig");
pub const TemplateContext = @import("jetzig/TemplateContext.zig");
pub const DateTime = jetcommon.types.DateTime;

218
src/jetzig/debug.zig Normal file
View File

@ -0,0 +1,218 @@
const std = @import("std");
const builtin = @import("builtin");
pub const ErrorInfo = struct {
stack_trace: ?*std.builtin.StackTrace = null,
err: ?anyerror = null,
};
pub fn sourceLocations(
allocator: std.mem.Allocator,
debug_info: *std.debug.SelfInfo,
stack_trace: *std.builtin.StackTrace,
) ![]const std.debug.SourceLocation {
var source_locations = std.ArrayList(std.debug.SourceLocation).init(allocator);
if (builtin.strip_debug_info) return error.MissingDebugInfo;
var frame_index: usize = 0;
var frames_left: usize = @min(stack_trace.index, stack_trace.instruction_addresses.len);
while (frames_left != 0) : ({
frames_left -= 1;
frame_index = (frame_index + 1) % stack_trace.instruction_addresses.len;
}) {
const return_address = stack_trace.instruction_addresses[frame_index];
const address = return_address - 1;
const module = try debug_info.getModuleForAddress(address);
const symbol_info = try module.getSymbolAtAddress(debug_info.allocator, address);
if (symbol_info.source_location) |source_location| {
try source_locations.append(source_location);
}
}
return try source_locations.toOwnedSlice();
}
pub const HtmlStackTrace = struct {
allocator: std.mem.Allocator,
stack_trace: *std.builtin.StackTrace,
pub fn format(self: HtmlStackTrace, _: anytype, _: anytype, writer: anytype) !void {
const debug_info = try std.debug.getSelfDebugInfo();
const source_locations = try sourceLocations(
self.allocator,
debug_info,
self.stack_trace,
);
for (source_locations) |source_location| {
defer debug_info.allocator.free(source_location.file_name);
try writer.print(
\\<div class='stack-source-line'>
\\ <span class='file-name'>{s}:{d}</span>
\\
,
.{
source_location.file_name,
source_location.line,
},
);
const surrounding_previous = try surroundingLinesFromFile(
self.allocator,
.previous,
3,
source_location.file_name,
source_location.line,
);
const surrounding_next = try surroundingLinesFromFile(
self.allocator,
.next,
3,
source_location.file_name,
source_location.line,
);
const target_source_line = try readLineFromFile(
self.allocator,
source_location.file_name,
source_location.line,
);
for (surrounding_previous) |source_line| {
try writer.print(surrounding_line_template, .{ source_line.line, source_line.content });
}
try writer.print(target_line_template, .{ target_source_line.line, target_source_line.content });
for (surrounding_next) |source_line| {
try writer.print(surrounding_line_template, .{ source_line.line, source_line.content });
}
try writer.print(
\\</div>
\\
, .{});
}
}
};
const SourceLine = struct { content: []const u8, line: usize };
pub fn readLineFromFile(allocator: std.mem.Allocator, path: []const u8, line: usize) !SourceLine {
const file = try std.fs.openFileAbsolute(path, .{});
var buf: [std.mem.page_size]u8 = undefined;
var count: usize = 1;
var cursor: usize = 0;
seek: {
while (true) {
const bytes_read = try file.readAll(buf[0..]);
for (buf[0..bytes_read]) |char| {
if (char == '\n') count += 1;
cursor += 1;
if (count == line) {
cursor += 1;
break :seek;
}
}
if (bytes_read < buf.len) return error.EndOfFile;
}
}
var size: usize = 0;
try file.seekTo(cursor);
read: {
const bytes_read = try file.readAll(buf[0..]);
if (std.mem.indexOf(u8, buf[0..bytes_read], "\n")) |index| {
size += index;
break :read;
} else if (bytes_read < buf.len) {
size += bytes_read;
break :read;
} else {
while (true) {
const more_bytes_read = try file.readAll(buf[0..]);
if (std.mem.indexOf(u8, buf[0..more_bytes_read], "\n")) |index| {
size += index;
break :read;
} else if (more_bytes_read < buf.len) {
size += more_bytes_read;
break :read;
} else {
size += more_bytes_read;
}
}
}
}
const line_content = try allocator.alloc(u8, size);
try file.seekTo(cursor);
const bytes_read = try file.readAll(line_content[0..]);
std.debug.assert(bytes_read == size);
return .{ .content = line_content[0..], .line = line };
}
fn surroundingLinesFromFile(
allocator: std.mem.Allocator,
context: enum { previous, next },
desired_count: usize,
path: []const u8,
target_line: usize,
) ![]SourceLine {
// This isn't very efficient but we only use it in debug mode so not a huge deal.
const start = switch (context) {
.previous => if (target_line > desired_count)
target_line - desired_count
else
target_line,
.next => target_line + 2,
};
var lines = std.ArrayList(SourceLine).init(allocator);
switch (context) {
.previous => {
for (start..target_line) |line| {
try lines.append(try readLineFromFile(allocator, path, line));
}
},
.next => {
for (0..desired_count, start..) |_, line| {
try lines.append(try readLineFromFile(allocator, path, line));
}
},
}
return try lines.toOwnedSlice();
}
pub const console_template =
\\<!DOCTYPE html>
\\<html>
\\ <head>
\\ <style>
\\{3s}
\\ </style>
\\ </head>
\\ <body>
\\ <h1>Encountered Error: {0s}</h1>
\\ <div class="stack-trace">
\\ {1}
\\ </div>
\\ <h2>Response Data</h2>
\\ <div class="response-data">
\\ <pre><code class="language-json">{2s}</code></pre>
\\ </div>
\\ <script src="/_jetzig_debug.js"></script>
\\ </body>
\\</html>
;
const surrounding_line_template =
\\<div class='line-content surrounding'><pre class="line-number">{d: >4}</pre><pre><code class="language-zig">{s}</code></pre></div>
\\
;
const target_line_template =
\\<div class='line-content target'><pre class="line-number">{d: >4}</pre><pre><code class="language-zig">{s}</code></pre></div>
\\
;

View File

@ -198,7 +198,10 @@ fn renderResponse(self: *Server, request: *jetzig.http.Request) !void {
try callback(request, route);
if (request.rendered_view) |view| {
if (request.state == .failed) {
request.setResponse(try self.renderError(request, view.status_code), .{});
request.setResponse(
try self.renderError(request, view.status_code, .{}),
.{},
);
} else if (request.state == .rendered) {
// TODO: Allow callbacks to set content
}
@ -334,7 +337,7 @@ fn renderView(
.data = request.response_data,
.status_code = .internal_server_error,
};
return try self.renderError(request, view.status_code);
return try self.renderError(request, view.status_code, .{});
}
const template: ?zmpl.Template = if (request.dynamic_assigned_template) |request_template|
@ -353,12 +356,17 @@ fn renderView(
.content = try self.renderTemplateWithLayout(request, capture, rendered_view, route),
};
} else {
try self.logger.DEBUG(
"Missing template for route `{s}.{s}`. Expected: `src/app/views/{s}.zmpl`.",
.{ route.view_name, @tagName(route.action), route.template },
);
return switch (request.requestFormat()) {
.HTML, .UNKNOWN => try self.renderNotFound(request),
.HTML, .UNKNOWN => blk: {
try self.logger.DEBUG(
"Missing template for route `{s}.{s}`. Expected: `src/app/views/{s}.zmpl`.",
.{ route.view_name, @tagName(route.action), route.template },
);
if (comptime jetzig.build_options.debug_console) {
return error.ZmplTemplateNotFound;
}
break :blk try self.renderNotFound(request);
},
.JSON => .{ .view = rendered_view, .content = "" },
};
}
@ -467,32 +475,44 @@ fn renderInternalServerError(
stack_trace: ?*std.builtin.StackTrace,
err: anyerror,
) !RenderedView {
request.response_data.reset();
// request.response_data.reset();
try self.logger.logError(stack_trace, err);
const status = .internal_server_error;
return try self.renderError(request, status);
const status = jetzig.http.StatusCode.internal_server_error;
return try self.renderError(request, status, .{ .stack_trace = stack_trace, .err = err });
}
fn renderNotFound(self: *Server, request: *jetzig.http.Request) !RenderedView {
request.response_data.reset();
const status: jetzig.http.StatusCode = .not_found;
return try self.renderError(request, status);
return try self.renderError(request, status, .{});
}
fn renderBadRequest(self: *Server, request: *jetzig.http.Request) !RenderedView {
request.response_data.reset();
const status: jetzig.http.StatusCode = .bad_request;
return try self.renderError(request, status);
return try self.renderError(request, status, .{});
}
fn renderError(
self: Server,
request: *jetzig.http.Request,
status_code: jetzig.http.StatusCode,
error_info: jetzig.debug.ErrorInfo,
) !RenderedView {
if (comptime jetzig.build_options.debug_console) {
return try self.renderDebugConsole(request, status_code, error_info);
} else return try self.renderGeneralError(request, status_code);
}
fn renderGeneralError(
self: Server,
request: *jetzig.http.Request,
status_code: jetzig.http.StatusCode,
) !RenderedView {
if (try self.renderErrorView(request, status_code)) |view| return view;
if (try renderStaticErrorPage(request, status_code)) |view| return view;
@ -500,6 +520,42 @@ fn renderError(
return try renderDefaultError(request, status_code);
}
fn renderDebugConsole(
self: Server,
request: *jetzig.http.Request,
status_code: jetzig.http.StatusCode,
error_info: jetzig.debug.ErrorInfo,
) !RenderedView {
if (comptime jetzig.build_options.debug_console) {
var buf = std.ArrayList(u8).init(request.allocator);
const writer = buf.writer();
if (error_info.stack_trace) |stack_trace| {
const debug_content = jetzig.debug.HtmlStackTrace{
.allocator = request.allocator,
.stack_trace = stack_trace,
};
const error_name = if (error_info.err) |err| @errorName(err) else "[UnknownError]";
try writer.print(
jetzig.debug.console_template,
.{
error_name,
debug_content,
try request.response_data.toJsonOptions(.{ .pretty = true }),
@embedFile("../../assets/debug.css"),
},
);
} else return try self.renderGeneralError(request, status_code);
const content = try buf.toOwnedSlice();
return .{
.view = .{ .data = request.response_data, .status_code = status_code },
.content = if (content.len == 0) "" else content,
};
} else unreachable;
}
fn renderErrorView(
self: Server,
request: *jetzig.http.Request,
@ -647,6 +703,13 @@ const StaticResource = struct {
};
fn matchStaticResource(self: *Server, request: *jetzig.http.Request) !?StaticResource {
if (comptime jetzig.build_options.debug_console) {
if (std.mem.eql(u8, request.path.path, "/_jetzig_debug.js")) return .{
.content = @embedFile("../../assets/debug.js"),
.mime_type = "text/javascript",
};
}
// TODO: Map public and static routes at launch to avoid accessing the file system when
// matching any route - currently every request causes file system traversal.
const public_resource = try self.matchPublicContent(request);