From be5713ac670783925b8aa222f069d187d7837df7 Mon Sep 17 00:00:00 2001 From: Toby Jaffey Date: Fri, 7 Nov 2025 16:08:40 +0000 Subject: [PATCH 1/4] Add a zig webserver (derived from https://github.com/andrewrk/StaticHttpFileServer) rather than rely on emrun.py Generates an index.html with links to all built samples --- README.md | 5 + build.zig | 49 ++++++++ build.zig.zon | 4 + tools/httpserver/root.zig | 249 +++++++++++++++++++++++++++++++++++++ tools/httpserver/serve.zig | 91 ++++++++++++++ 5 files changed, 398 insertions(+) create mode 100644 tools/httpserver/root.zig create mode 100644 tools/httpserver/serve.zig diff --git a/README.md b/README.md index dc2e91e..08a469f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,11 @@ zig build run-clear -Dtarget=wasm32-emscripten ... ``` +To build and serve the examples on http://localhost:8000 run: +```sh +zig build examples -Dtarget=wasm32-emscripten && zig build serve -- zig-out/web -p 8000 +``` + When building with target `wasm32-emscripten` for the first time, the build script will install and activate the Emscripten SDK into the Zig package cache for the latest SDK version. There is currently no build system functionality to update or delete the Emscripten SDK diff --git a/build.zig b/build.zig index 77de96f..220968f 100644 --- a/build.zig +++ b/build.zig @@ -107,6 +107,9 @@ pub fn build(b: *Build) !void { }); // a manually invoked build step to build auto-docs buildDocs(b, target); + + // web server + buildWebServer(b, target, optimize); } // helper function to resolve .auto backend based on target platform @@ -484,6 +487,36 @@ fn emSdkSetupStep(b: *Build, emsdk: *Build.Dependency) !?*Build.Step.Run { } } +fn buildWebServer(b: *Build, target: Build.ResolvedTarget, optimize: OptimizeMode) void { + const serve_exe = b.addExecutable(.{ + .name = "serve", + .root_module = b.createModule(.{ + .root_source_file = b.path("tools/httpserver/serve.zig"), + .target = target, + .optimize = optimize, + }), + }); + + const mod_server = b.addModule("StaticHttpFileServer", .{ + .root_source_file = b.path("tools/httpserver/root.zig"), + .target = target, + .optimize = optimize, + }); + + mod_server.addImport("mime", b.dependency("mime", .{ + .target = target, + .optimize = optimize, + }).module("mime")); + + serve_exe.root_module.addImport("StaticHttpFileServer", mod_server); + + const run_serve_exe = b.addRunArtifact(serve_exe); + if (b.args) |args| run_serve_exe.addArgs(args); + + const serve_step = b.step("serve", "Serve a directory of files"); + serve_step.dependOn(&run_serve_exe.step); +} + //== DOCUMENTATION ===================================================================================================== fn buildDocs(b: *Build, target: Build.ResolvedTarget) void { const lib = b.addLibrary(.{ @@ -542,9 +575,25 @@ const ExampleOptions = struct { fn buildExamples(b: *Build, options: ExampleOptions) !void { // a top level build step for all examples const examples_step = b.step("examples", "Build all examples"); + inline for (examples) |example| { try buildExample(b, example, examples_step, options); } + + if (isPlatform(options.target.result, .web)) { + var buf: [256 * examples.len]u8 = undefined; + var str_writer = std.Io.Writer.fixed(&buf); + + _ = try str_writer.print("

Examples

", .{}); + + const wf = b.addWriteFile("index.html", str_writer.buffered()); + const index = b.addInstallFile(wf.getDirectory().path(b, "index.html"), "web/index.html"); + examples_step.dependOn(&index.step); + } } // build one of the examples diff --git a/build.zig.zon b/build.zig.zon index b31d0cb..3b2e18d 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -16,5 +16,9 @@ .url = "git+https://github.com/floooh/sokol-tools-bin#f190d396afac7d328ce14d098c9ab7448dfc920b", .hash = "sokolshdc-0.1.0-r2KZDr4qbAT82i8_UYph-mz3b-ACM3tQr93-rZQ94ghV", }, + .mime = .{ + .url = "git+https://github.com/andrewrk/mime.git#4535b74bd5fcaa03e22f2f25af164a4c6253af00", + .hash = "mime-4.0.0-zwmL--0gAACndOVlROQeDnM0chfojNDS8tpohlsBqMNy", + }, }, } diff --git a/tools/httpserver/root.zig b/tools/httpserver/root.zig new file mode 100644 index 0000000..1604522 --- /dev/null +++ b/tools/httpserver/root.zig @@ -0,0 +1,249 @@ +/// The key is index into backing_memory, where a HTTP request path is stored. +files: File.Table, +/// Stores file names relative to root directory and file contents, interleaved. +bytes: std.ArrayListUnmanaged(u8), +etag: []const u8, + +pub const File = struct { + mime_type: mime.Type, + name_start: usize, + name_len: u16, + /// Stored separately to make aliases work. + contents_start: usize, + contents_len: usize, + + pub const Table = std.HashMapUnmanaged( + File, + void, + FileNameContext, + std.hash_map.default_max_load_percentage, + ); +}; + +pub const Options = struct { + allocator: std.mem.Allocator, + /// Must have been opened with iteration permissions. + root_dir: fs.Dir, + cache_control_header: []const u8 = "max-age=0, must-revalidate", + max_file_size: usize = std.math.maxInt(usize), + /// Special alias "404" allows setting a particular file as the file sent + /// for "not found" errors. If this alias is not provided, `serve` returns + /// `error.FileNotFound` instead, leaving the response's state unmodified. + aliases: []const Alias = &.{ + .{ .request_path = "/", .file_path = "/index.html" }, + .{ .request_path = "404", .file_path = "/404.html" }, + }, + ignoreFile: *const fn (path: []const u8) bool = &defaultIgnoreFile, + etag: []const u8, + + pub const Alias = struct { + request_path: []const u8, + file_path: []const u8, + }; + +}; + +pub const InitError = error{ + OutOfMemory, + InitFailed, +}; + +pub fn init(options: Options) InitError!Server { + const gpa = options.allocator; + + var it = try options.root_dir.walk(gpa); + defer it.deinit(); + + var files: File.Table = .{}; + errdefer files.deinit(gpa); + + var bytes: std.ArrayListUnmanaged(u8) = .{}; + errdefer bytes.deinit(gpa); + + while (it.next() catch |err| { + log.err("unable to scan root directory: {s}", .{@errorName(err)}); + return error.InitFailed; + }) |entry| { + switch (entry.kind) { + .file => { + if (options.ignoreFile(entry.path)) continue; + + var file = options.root_dir.openFile(entry.path, .{}) catch |err| { + log.err("unable to open '{s}': {s}", .{ entry.path, @errorName(err) }); + return error.InitFailed; + }; + defer file.close(); + + const size = file.getEndPos() catch |err| { + log.err("unable to stat '{s}': {s}", .{ entry.path, @errorName(err) }); + return error.InitFailed; + }; + + if (size > options.max_file_size) { + log.err("file exceeds maximum size: '{s}'", .{entry.path}); + return error.InitFailed; + } + + const name_len = 1 + entry.path.len; + try bytes.ensureUnusedCapacity(gpa, name_len + size); + + // Make the file system path identical independently of + // operating system path inconsistencies. This converts + // backslashes into forward slashes. + const name_start = bytes.items.len; + bytes.appendAssumeCapacity(canonical_sep); + bytes.appendSliceAssumeCapacity(entry.path); + if (fs.path.sep != canonical_sep) + normalizePath(bytes.items[name_start..][0..name_len]); + + const contents_start = bytes.items.len; + const contents_len = file.readAll(bytes.unusedCapacitySlice()) catch |e| { + log.err("unable to read '{s}': {s}", .{ entry.path, @errorName(e) }); + return error.InitFailed; + }; + if (contents_len != size) { + log.err("unexpected EOF when reading '{s}'", .{entry.path}); + return error.InitFailed; + } + bytes.items.len += contents_len; + + const ext = fs.path.extension(entry.basename); + + try files.putNoClobberContext(gpa, .{ + .mime_type = mime.extension_map.get(ext) orelse .@"application/octet-stream", + .name_start = name_start, + .name_len = @intCast(name_len), + .contents_start = contents_start, + .contents_len = contents_len, + }, {}, FileNameContext{ + .bytes = bytes.items, + }); + }, + else => continue, + } + } + + try files.ensureUnusedCapacityContext(gpa, @intCast(options.aliases.len), FileNameContext{ + .bytes = bytes.items, + }); + + for (options.aliases) |alias| { + const file = files.getKeyAdapted(alias.file_path, FileNameAdapter{ + .bytes = bytes.items, + }) orelse { + log.err("alias '{s}' points to nonexistent file '{s}'", .{ + alias.request_path, alias.file_path, + }); + return error.InitFailed; + }; + + const name_start = bytes.items.len; + try bytes.appendSlice(gpa, alias.request_path); + + if (files.getOrPutAssumeCapacityContext(.{ + .mime_type = file.mime_type, + .name_start = name_start, + .name_len = @intCast(alias.request_path.len), + .contents_start = file.contents_start, + .contents_len = file.contents_len, + }, FileNameContext{ + .bytes = bytes.items, + }).found_existing) { + log.err("alias '{s}'->'{s}' clobbers existing file or alias", .{ + alias.request_path, alias.file_path, + }); + return error.InitFailed; + } + } + + return .{ + .files = files, + .bytes = bytes, + .etag = options.etag, + }; +} + +pub fn deinit(s: *Server, allocator: std.mem.Allocator) void { + s.files.deinit(allocator); + s.bytes.deinit(allocator); + s.* = undefined; +} + +pub const ServeError = error{FileNotFound} || error{HttpExpectationFailed,WriteFailed}; + +pub fn serve(s: *Server, request: *std.http.Server.Request) ServeError!void { + const path = request.head.target; + const file_name_adapter: FileNameAdapter = .{ .bytes = s.bytes.items }; + const file, const status: std.http.Status = b: { + break :b .{ + s.files.getKeyAdapted(path, file_name_adapter) orelse { + break :b .{ + s.files.getKeyAdapted(@as([]const u8, "404"), file_name_adapter) orelse + return error.FileNotFound, + .not_found, + }; + }, + .ok, + }; + }; + const content = s.bytes.items[file.contents_start..][0..file.contents_len]; + + return request.respond(content, .{ + .status = status, + .extra_headers = &.{ + .{ .name = "content-type", .value = @tagName(file.mime_type) }, + .{ .name = "Etag", .value = s.etag }, + .{ .name = "Cross-Origin-Opener-Policy", .value = "same-origin" }, + .{ .name = "Cross-Origin-Embedder-Policy", .value = "require-corp" }, + }, + }); +} + +pub fn defaultIgnoreFile(path: []const u8) bool { + const basename = fs.path.basename(path); + return std.mem.startsWith(u8, basename, ".") or + std.mem.endsWith(u8, basename, "~"); +} + +const Server = @This(); +const mime = @import("mime"); +const std = @import("std"); +const fs = std.fs; +const assert = std.debug.assert; +const log = std.log.scoped(.@"static-http-files"); + +const canonical_sep = fs.path.sep_posix; + +fn normalizePath(bytes: []u8) void { + assert(fs.path.sep != canonical_sep); + std.mem.replaceScalar(u8, bytes, fs.path.sep, canonical_sep); +} + +const FileNameContext = struct { + bytes: []const u8, + + pub fn eql(self: @This(), a: File, b: File) bool { + const a_name = self.bytes[a.name_start..][0..a.name_len]; + const b_name = self.bytes[b.name_start..][0..b.name_len]; + return std.mem.eql(u8, a_name, b_name); + } + + pub fn hash(self: @This(), x: File) u64 { + const name = self.bytes[x.name_start..][0..x.name_len]; + return std.hash_map.hashString(name); + } +}; + +const FileNameAdapter = struct { + bytes: []const u8, + + pub fn eql(self: @This(), a_name: []const u8, b: File) bool { + const b_name = self.bytes[b.name_start..][0..b.name_len]; + return std.mem.eql(u8, a_name, b_name); + } + + pub fn hash(self: @This(), adapted_key: []const u8) u64 { + _ = self; + return std.hash_map.hashString(adapted_key); + } +}; diff --git a/tools/httpserver/serve.zig b/tools/httpserver/serve.zig new file mode 100644 index 0000000..13d9e52 --- /dev/null +++ b/tools/httpserver/serve.zig @@ -0,0 +1,91 @@ +const std = @import("std"); +const StaticHttpFileServer = @import("StaticHttpFileServer"); + +var general_purpose_allocator = std.heap.GeneralPurposeAllocator(.{}){}; + +pub fn main() !void { + var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const gpa = general_purpose_allocator.allocator(); + + const args = try std.process.argsAlloc(arena); + + var listen_port: u16 = 0; + var opt_root_dir_path: ?[]const u8 = null; + + { + var i: usize = 1; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (std.mem.startsWith(u8, arg, "-")) { + if (std.mem.eql(u8, arg, "-p")) { + i += 1; + if (i >= args.len) fatal("expected arg after '{s}'", .{arg}); + listen_port = std.fmt.parseInt(u16, args[i], 10) catch |err| { + fatal("unable to parse port '{s}': {s}", .{ args[i], @errorName(err) }); + }; + } else { + fatal("unrecognized argument: '{s}'", .{arg}); + } + } else if (opt_root_dir_path == null) { + opt_root_dir_path = arg; + } else { + fatal("unexpected positional argument: '{s}'", .{arg}); + } + } + } + + const root_dir_path = opt_root_dir_path orelse fatal("missing root dir path", .{}); + + var root_dir = std.fs.cwd().openDir(root_dir_path, .{ .iterate = true }) catch |err| + fatal("unable to open directory '{s}': {s}", .{ root_dir_path, @errorName(err) }); + defer root_dir.close(); + + const aliases:[2]StaticHttpFileServer.Options.Alias = .{ + .{ .request_path = "/", .file_path = "/index.html" }, + .{ .request_path = "404", .file_path = "/index.html" }, + }; + + var etag_buf:[32]u8 = undefined; + + var static_http_file_server = try StaticHttpFileServer.init(.{ + .allocator = gpa, + .root_dir = root_dir, + .aliases = &aliases, + .etag = try std.fmt.bufPrint(&etag_buf, "{d}", .{std.time.nanoTimestamp()}), + }); + defer static_http_file_server.deinit(gpa); + + const address = try std.net.Address.parseIp("127.0.0.1", listen_port); + var http_server = try address.listen(.{ + .reuse_address = true, + }); + const port = http_server.listen_address.in.getPort(); + std.debug.print("Listening at http://127.0.0.1:{d}/\n", .{port}); + + var read_buffer: [8000]u8 = undefined; + var write_buffer: [8000]u8 = undefined; + accept: while (true) { + var connection = try http_server.accept(); + defer connection.stream.close(); + + var reader = connection.stream.reader(&read_buffer); + var writer = connection.stream.writer(&write_buffer); + + var server = std.http.Server.init(reader.interface(), &writer.interface); + while (server.reader.state == .ready) { + var request = server.receiveHead() catch |err| { + std.debug.print("error: {s}\n", .{@errorName(err)}); + continue :accept; + }; + try static_http_file_server.serve(&request); + continue :accept; // force reset after each request + } + } +} + +fn fatal(comptime format: []const u8, args: anytype) noreturn { + std.debug.print(format ++ "\n", args); + std.process.exit(1); +} From dc13e8be1dd824170b42503400c1f616f9b9d9ea Mon Sep 17 00:00:00 2001 From: Toby Jaffey Date: Fri, 7 Nov 2025 16:54:12 +0000 Subject: [PATCH 2/4] Combine build and serve steps into "zig build serve-wasm", which builds all wasm examples and serves on localhost:8000 --- README.md | 2 +- build.zig | 14 ++++++++------ 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 08a469f..2f1ded6 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ zig build run-clear -Dtarget=wasm32-emscripten To build and serve the examples on http://localhost:8000 run: ```sh -zig build examples -Dtarget=wasm32-emscripten && zig build serve -- zig-out/web -p 8000 +zig build serve-wasm ``` When building with target `wasm32-emscripten` for the first time, the build script will diff --git a/build.zig b/build.zig index 220968f..194402b 100644 --- a/build.zig +++ b/build.zig @@ -98,7 +98,7 @@ pub fn build(b: *Build) !void { mod_sokol.linkLibrary(lib_sokol); // examples build step - try buildExamples(b, .{ + const examples_step = try buildExamples(b, .{ .target = target, .optimize = optimize, .backend = sokol_backend, @@ -109,7 +109,7 @@ pub fn build(b: *Build) !void { buildDocs(b, target); // web server - buildWebServer(b, target, optimize); + buildWebServer(b, target, optimize, examples_step); } // helper function to resolve .auto backend based on target platform @@ -487,7 +487,7 @@ fn emSdkSetupStep(b: *Build, emsdk: *Build.Dependency) !?*Build.Step.Run { } } -fn buildWebServer(b: *Build, target: Build.ResolvedTarget, optimize: OptimizeMode) void { +fn buildWebServer(b: *Build, target: Build.ResolvedTarget, optimize: OptimizeMode, examples_step:*Build.Step) void { const serve_exe = b.addExecutable(.{ .name = "serve", .root_module = b.createModule(.{ @@ -511,10 +511,11 @@ fn buildWebServer(b: *Build, target: Build.ResolvedTarget, optimize: OptimizeMod serve_exe.root_module.addImport("StaticHttpFileServer", mod_server); const run_serve_exe = b.addRunArtifact(serve_exe); - if (b.args) |args| run_serve_exe.addArgs(args); + run_serve_exe.addArgs(&.{"zig-out/web", "-p", "8000"}); - const serve_step = b.step("serve", "Serve a directory of files"); + const serve_step = b.step("serve-wasm", "Serve wasm examples"); serve_step.dependOn(&run_serve_exe.step); + serve_step.dependOn(examples_step); } //== DOCUMENTATION ===================================================================================================== @@ -572,7 +573,7 @@ const ExampleOptions = struct { }; // build all examples -fn buildExamples(b: *Build, options: ExampleOptions) !void { +fn buildExamples(b: *Build, options: ExampleOptions) !*Build.Step { // a top level build step for all examples const examples_step = b.step("examples", "Build all examples"); @@ -594,6 +595,7 @@ fn buildExamples(b: *Build, options: ExampleOptions) !void { const index = b.addInstallFile(wf.getDirectory().path(b, "index.html"), "web/index.html"); examples_step.dependOn(&index.step); } + return examples_step; } // build one of the examples From cf339eb7278fdacb6ac9ce1b03e4750a35e9cbac Mon Sep 17 00:00:00 2001 From: Toby Jaffey Date: Thu, 13 Nov 2025 16:44:35 +0000 Subject: [PATCH 3/4] "zig build -Dtarget=wasm32-emscripten serve-wasm" to build wasm examples, then serve using native web server. This would potentially allow for a different target for the examples, so makes sense - though a longer command. --- README.md | 2 +- build.zig | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2f1ded6..351d0f8 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ zig build run-clear -Dtarget=wasm32-emscripten To build and serve the examples on http://localhost:8000 run: ```sh -zig build serve-wasm +zig build -Dtarget=wasm32-emscripten serve-wasm ``` When building with target `wasm32-emscripten` for the first time, the build script will diff --git a/build.zig b/build.zig index 194402b..86bd402 100644 --- a/build.zig +++ b/build.zig @@ -109,7 +109,7 @@ pub fn build(b: *Build) !void { buildDocs(b, target); // web server - buildWebServer(b, target, optimize, examples_step); + buildWebServer(b, optimize, examples_step); } // helper function to resolve .auto backend based on target platform @@ -487,24 +487,26 @@ fn emSdkSetupStep(b: *Build, emsdk: *Build.Dependency) !?*Build.Step.Run { } } -fn buildWebServer(b: *Build, target: Build.ResolvedTarget, optimize: OptimizeMode, examples_step:*Build.Step) void { +fn buildWebServer(b: *Build, optimize: OptimizeMode, examples_step:*Build.Step) void { + const hosttarget = b.graph.host; + const serve_exe = b.addExecutable(.{ .name = "serve", .root_module = b.createModule(.{ .root_source_file = b.path("tools/httpserver/serve.zig"), - .target = target, + .target = hosttarget, .optimize = optimize, }), }); const mod_server = b.addModule("StaticHttpFileServer", .{ .root_source_file = b.path("tools/httpserver/root.zig"), - .target = target, + .target = hosttarget, .optimize = optimize, }); mod_server.addImport("mime", b.dependency("mime", .{ - .target = target, + .target = hosttarget, .optimize = optimize, }).module("mime")); From 3a478bb07429cd5d629577a096846268727920e2 Mon Sep 17 00:00:00 2001 From: Toby Jaffey Date: Fri, 14 Nov 2025 23:13:14 +0000 Subject: [PATCH 4/4] Update serve-wasm README to also build examples --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 351d0f8..471c75b 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ zig build run-clear -Dtarget=wasm32-emscripten To build and serve the examples on http://localhost:8000 run: ```sh -zig build -Dtarget=wasm32-emscripten serve-wasm +zig build -Dtarget=wasm32-emscripten examples serve-wasm ``` When building with target `wasm32-emscripten` for the first time, the build script will