b7241c32
feat: muxweb — the hub binary; tiles are argv, transports are mux's own
a73x 2026-08-13 10:37
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -474,6 +474,29 @@ pub fn build(b: *std.Build) void { | |||
| 474 | wasm_exe.rdynamic = true; | 474 | wasm_exe.rdynamic = true; |
| 475 | b.installArtifact(wasm_exe); | 475 | b.installArtifact(wasm_exe); |
| 476 | 476 | ||
| 477 | // ---- muxweb, the hub binary (M-web Task 7) ---- | ||
| 478 | // The page's three assets arrive as anonymous imports so @embedFile | ||
| 479 | // can name them; the wasm one is the artifact itself, which also | ||
| 480 | // sequences the wasm build before the hub's. | ||
| 481 | const webhub_main_mod = b.createModule(.{ | ||
| 482 | .root_source_file = b.path("src/webhub_main.zig"), | ||
| 483 | .target = target, | ||
| 484 | .optimize = optimize, | ||
| 485 | .link_libc = true, | ||
| 486 | }); | ||
| 487 | webhub_main_mod.addImport("client", client_mod); | ||
| 488 | webhub_main_mod.addImport("webhub", webhub_mod); | ||
| 489 | webhub_main_mod.addImport("xdg", xdg_mod); | ||
| 490 | webhub_main_mod.addImport("build_options", version_opts.createModule()); | ||
| 491 | webhub_main_mod.addAnonymousImport("index.html", .{ .root_source_file = b.path("web/index.html") }); | ||
| 492 | webhub_main_mod.addAnonymousImport("mux.js", .{ .root_source_file = b.path("web/mux.js") }); | ||
| 493 | webhub_main_mod.addAnonymousImport("mux_core.wasm", .{ .root_source_file = wasm_exe.getEmittedBin() }); | ||
| 494 | const webhub_exe = b.addExecutable(.{ .name = "muxweb", .root_module = webhub_main_mod }); | ||
| 495 | webhub_exe.use_llvm = true; | ||
| 496 | webhub_exe.use_lld = true; | ||
| 497 | linkQuic(b, webhub_exe, quic); | ||
| 498 | b.installArtifact(webhub_exe); | ||
| 499 | |||
| 477 | const test_step = b.step("test", "Run unit tests"); | 500 | const test_step = b.step("test", "Run unit tests"); |
| 478 | // delta_mod and sockpath_mod sit BEFORE server_mod, deliberately: their | 501 | // delta_mod and sockpath_mod sit BEFORE server_mod, deliberately: their |
| 479 | // tests are seconds-long and socket-free, while a regression in either | 502 | // tests are seconds-long and socket-free, while a regression in either |
| @@ -486,7 +509,7 @@ pub fn build(b: *std.Build) void { | |||
| 486 | // absence here was a live hazard recorded in decisions.md — muxd's | 509 | // absence here was a live hazard recorded in decisions.md — muxd's |
| 487 | // entrypoint could grow tests that silently never ran, exactly as | 510 | // entrypoint could grow tests that silently never ran, exactly as |
| 488 | // mux_main.zig's five did before it was added. | 511 | // mux_main.zig's five did before it was added. |
| 489 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod }) |mod| { | 512 | for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_mod, webhub_mod, sockpath_mod, server_mod, client_mod, proxy_mod, mux_mod, quic_mod, quic_server_mod, exe_mod, testtmp_mod, quic_client_mod, predict_mod, rawmode_mod, delaypipe_mod, xdg_mod, spawn_mod, handoff_mod, paint_mod, render_mod, ptyclient_mod, webhub_main_mod}) |mod| { |
| 490 | const t = b.addTest(.{ .root_module = mod }); | 513 | const t = b.addTest(.{ .root_module = mod }); |
| 491 | t.use_llvm = true; | 514 | t.use_llvm = true; |
| 492 | t.use_lld = true; | 515 | t.use_lld = true; |
| @@ -498,7 +521,8 @@ pub fn build(b: *std.Build) void { | |||
| 498 | // found no libraries and no explanation. | 521 | // found no libraries and no explanation. |
| 499 | if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or | 522 | if (mod == server_mod or mod == quic_mod or mod == quic_server_mod or |
| 500 | mod == exe_mod or mod == client_mod or mod == mux_mod or | 523 | mod == exe_mod or mod == client_mod or mod == mux_mod or |
| 501 | mod == quic_client_mod or mod == webhub_mod) linkQuic(b, t, quic); | 524 | mod == quic_client_mod or mod == webhub_mod or |
| 525 | mod == webhub_main_mod) linkQuic(b, t, quic); | ||
| 502 | test_step.dependOn(&b.addRunArtifact(t).step); | 526 | test_step.dependOn(&b.addRunArtifact(t).step); |
| 503 | } | 527 | } |
| 504 | 528 | ||
src/webhub.zig
| Old | New | ||
|---|---|---|---|
| @@ -246,6 +246,72 @@ fn dialLoop( | |||
| 246 | } | 246 | } |
| 247 | } | 247 | } |
| 248 | 248 | ||
| 249 | /// One accepted connection, start to finish (M-web Task 7). Static | ||
| 250 | /// requests loop for keep-alive; a WebSocket upgrade consumes the | ||
| 251 | /// connection into a tile pump and never returns to HTTP. | ||
| 252 | pub fn serveConn( | ||
| 253 | alloc: std.mem.Allocator, | ||
| 254 | stream: std.net.Stream, | ||
| 255 | port: u16, | ||
| 256 | targets: []const client.Target, | ||
| 257 | assets: Assets, | ||
| 258 | ) void { | ||
| 259 | defer stream.close(); | ||
| 260 | // The ONE buffer: max HTTP header and max inbound WS message alike. | ||
| 261 | var in_buf: [ws_buffer_len]u8 = undefined; | ||
| 262 | var out_buf: [8 * 1024]u8 = undefined; | ||
| 263 | var conn_reader = stream.reader(&in_buf); | ||
| 264 | var conn_writer = stream.writer(&out_buf); | ||
| 265 | var server = std.http.Server.init(conn_reader.interface(), &conn_writer.interface); | ||
| 266 | |||
| 267 | while (true) { | ||
| 268 | var req = server.receiveHead() catch return; | ||
| 269 | const path = req.head.target; | ||
| 270 | |||
| 271 | if (wsTileIndex(path, targets.len)) |idx| { | ||
| 272 | // Origin BEFORE upgrade, always: the refusal must happen while | ||
| 273 | // this is still HTTP, so a hostile page gets a 403 and never a | ||
| 274 | // socket. std's upgradeRequested does not look at Origin. | ||
| 275 | var origin: ?[]const u8 = null; | ||
| 276 | var it = req.iterateHeaders(); | ||
| 277 | while (it.next()) |h| { | ||
| 278 | if (std.ascii.eqlIgnoreCase(h.name, "origin")) origin = h.value; | ||
| 279 | } | ||
| 280 | if (!originAllowed(origin, port)) { | ||
| 281 | req.respond("forbidden\n", .{ .status = .forbidden }) catch {}; | ||
| 282 | return; | ||
| 283 | } | ||
| 284 | const key = switch (req.upgradeRequested()) { | ||
| 285 | .websocket => |k| k orelse { | ||
| 286 | req.respond("bad upgrade\n", .{ .status = .bad_request }) catch {}; | ||
| 287 | return; | ||
| 288 | }, | ||
| 289 | else => { | ||
| 290 | req.respond("websocket only\n", .{ .status = .bad_request }) catch {}; | ||
| 291 | return; | ||
| 292 | }, | ||
| 293 | }; | ||
| 294 | var ws = req.respondWebSocket(.{ .key = key }) catch return; | ||
| 295 | ws.flush() catch return; | ||
| 296 | pumpTile(alloc, &ws, stream.handle, targets[idx]); | ||
| 297 | return; | ||
| 298 | } | ||
| 299 | |||
| 300 | if (route(assets, path)) |asset| { | ||
| 301 | req.respond(asset.body, .{ | ||
| 302 | .extra_headers = &.{ | ||
| 303 | .{ .name = "content-type", .value = asset.content_type }, | ||
| 304 | // The page and its wasm never change under a running | ||
| 305 | // hub; the browser may cache for the session. | ||
| 306 | .{ .name = "cache-control", .value = "no-cache" }, | ||
| 307 | }, | ||
| 308 | }) catch return; | ||
| 309 | } else { | ||
| 310 | req.respond("not found\n", .{ .status = .not_found }) catch return; | ||
| 311 | } | ||
| 312 | } | ||
| 313 | } | ||
| 314 | |||
| 249 | test "origin: exactly our two spellings pass, everything else refuses" { | 315 | test "origin: exactly our two spellings pass, everything else refuses" { |
| 250 | const cases = [_]struct { origin: ?[]const u8, port: u16, want: bool }{ | 316 | const cases = [_]struct { origin: ?[]const u8, port: u16, want: bool }{ |
| 251 | .{ .origin = "http://127.0.0.1:7681", .port = 7681, .want = true }, | 317 | .{ .origin = "http://127.0.0.1:7681", .port = 7681, .want = true }, |
src/webhub_main.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,289 @@ | |||
| 1 | //! muxweb — the hub binary (M-web Task 7). `muxweb TARGET [TARGET ...] | ||
| 2 | //! [--port N]`: serves the wall page on 127.0.0.1 and pumps one | ||
| 3 | //! WebSocket per tile, dialing each TARGET the way the mux CLI does. | ||
| 4 | //! TARGET spellings are mux's own: bare HOST (ssh→QUIC handoff), | ||
| 5 | //! --sock PATH, quic://HOST[:PORT] (with --key / MUX_KEY_FILE as in | ||
| 6 | //! mux). The TARGET string is the tile's label; the tile list is argv — | ||
| 7 | //! no config file, per the standing non-goal. | ||
| 8 | |||
| 9 | const std = @import("std"); | ||
| 10 | const client = @import("client"); | ||
| 11 | const webhub = @import("webhub"); | ||
| 12 | const build_options = @import("build_options"); | ||
| 13 | const xdg = @import("xdg"); | ||
| 14 | |||
| 15 | const usage = | ||
| 16 | \\usage: muxweb TARGET [TARGET ...] [--port N] | ||
| 17 | \\ each TARGET is a tile: HOST | --sock PATH | quic://HOST[:PORT] | ||
| 18 | \\ quic:// tiles use --key FILE, MUX_KEY_FILE, or ~/.config/mux/key | ||
| 19 | \\ [--quic-idle-ms N] tunes how fast a dead link is noticed | ||
| 20 | \\ --port N serves on 127.0.0.1:N (default 7681); localhost only, | ||
| 21 | \\ remote viewing is `ssh -L` | ||
| 22 | \\ --version prints the version | ||
| 23 | \\ | ||
| 24 | ; | ||
| 25 | |||
| 26 | /// One tile as the command line spelled it. Building the full | ||
| 27 | /// client.Target needs an allocator and the environment, so the parse | ||
| 28 | /// records spellings and main resolves them — the same split mux_main | ||
| 29 | /// uses, for the same testability reason. | ||
| 30 | const TileSpec = union(enum) { | ||
| 31 | sock: []const u8, | ||
| 32 | host: []const u8, | ||
| 33 | quic: []const u8, | ||
| 34 | }; | ||
| 35 | |||
| 36 | const Parsed = struct { | ||
| 37 | tiles: std.ArrayList(TileSpec), | ||
| 38 | port: u16 = webhub.default_port, | ||
| 39 | key: ?[]const u8 = null, | ||
| 40 | idle_ms: u32 = client.quic_idle_ms_default, | ||
| 41 | |||
| 42 | fn deinit(self: *Parsed, alloc: std.mem.Allocator) void { | ||
| 43 | self.tiles.deinit(alloc); | ||
| 44 | } | ||
| 45 | }; | ||
| 46 | |||
| 47 | const ParseResult = union(enum) { | ||
| 48 | serve: Parsed, | ||
| 49 | version, | ||
| 50 | usage_error, | ||
| 51 | }; | ||
| 52 | |||
| 53 | fn parseArgs( | ||
| 54 | alloc: std.mem.Allocator, | ||
| 55 | args: []const [:0]const u8, | ||
| 56 | env_key: ?[]const u8, | ||
| 57 | ) !ParseResult { | ||
| 58 | var p = Parsed{ .tiles = .empty }; | ||
| 59 | errdefer p.tiles.deinit(alloc); | ||
| 60 | var key: ?[]const u8 = null; | ||
| 61 | |||
| 62 | var i: usize = 1; | ||
| 63 | while (i < args.len) : (i += 1) { | ||
| 64 | const a = args[i]; | ||
| 65 | if (std.mem.eql(u8, a, "--version")) { | ||
| 66 | p.tiles.deinit(alloc); | ||
| 67 | return .version; | ||
| 68 | } else if (std.mem.eql(u8, a, "--sock") and i + 1 < args.len) { | ||
| 69 | i += 1; | ||
| 70 | try p.tiles.append(alloc, .{ .sock = args[i] }); | ||
| 71 | } else if (std.mem.eql(u8, a, "--port") and i + 1 < args.len) { | ||
| 72 | i += 1; | ||
| 73 | p.port = std.fmt.parseInt(u16, args[i], 10) catch { | ||
| 74 | p.tiles.deinit(alloc); | ||
| 75 | return .usage_error; | ||
| 76 | }; | ||
| 77 | } else if (std.mem.eql(u8, a, "--key") and i + 1 < args.len) { | ||
| 78 | i += 1; | ||
| 79 | key = args[i]; | ||
| 80 | } else if (std.mem.eql(u8, a, "--quic-idle-ms") and i + 1 < args.len) { | ||
| 81 | i += 1; | ||
| 82 | const n = std.fmt.parseInt(u32, args[i], 10) catch { | ||
| 83 | p.tiles.deinit(alloc); | ||
| 84 | return .usage_error; | ||
| 85 | }; | ||
| 86 | if (n == 0) { | ||
| 87 | p.tiles.deinit(alloc); | ||
| 88 | return .usage_error; | ||
| 89 | } | ||
| 90 | p.idle_ms = n; | ||
| 91 | } else if (std.mem.startsWith(u8, a, "quic://")) { | ||
| 92 | const hp = a["quic://".len..]; | ||
| 93 | if (hp.len == 0) { | ||
| 94 | p.tiles.deinit(alloc); | ||
| 95 | return .usage_error; | ||
| 96 | } | ||
| 97 | try p.tiles.append(alloc, .{ .quic = hp }); | ||
| 98 | } else if (a.len > 0 and a[0] != '-') { | ||
| 99 | try p.tiles.append(alloc, .{ .host = a }); | ||
| 100 | } else { | ||
| 101 | p.tiles.deinit(alloc); | ||
| 102 | return .usage_error; | ||
| 103 | } | ||
| 104 | } | ||
| 105 | |||
| 106 | if (p.tiles.items.len == 0) { | ||
| 107 | p.tiles.deinit(alloc); | ||
| 108 | return .usage_error; | ||
| 109 | } | ||
| 110 | // --key wins over the environment, exactly mux_main's rule; empty | ||
| 111 | // means unset either way. | ||
| 112 | var k = key orelse env_key; | ||
| 113 | if (k) |kk| { | ||
| 114 | if (kk.len == 0) k = null; | ||
| 115 | } | ||
| 116 | p.key = k; | ||
| 117 | return .{ .serve = p }; | ||
| 118 | } | ||
| 119 | |||
| 120 | pub fn main() !u8 { | ||
| 121 | var gpa: std.heap.DebugAllocator(.{}) = .init; | ||
| 122 | defer _ = gpa.deinit(); | ||
| 123 | const alloc = gpa.allocator(); | ||
| 124 | |||
| 125 | const args = try std.process.argsAlloc(alloc); | ||
| 126 | defer std.process.argsFree(alloc, args); | ||
| 127 | |||
| 128 | var parsed = switch (try parseArgs(alloc, args, std.posix.getenv(client_key_env))) { | ||
| 129 | .version => { | ||
| 130 | var vbuf: [64]u8 = undefined; | ||
| 131 | const s = std.fmt.bufPrint(&vbuf, "muxweb {s}\n", .{build_options.version}) catch unreachable; | ||
| 132 | _ = std.posix.write(std.posix.STDOUT_FILENO, s) catch {}; | ||
| 133 | return 0; | ||
| 134 | }, | ||
| 135 | .usage_error => { | ||
| 136 | std.debug.print("{s}", .{usage}); | ||
| 137 | return 2; | ||
| 138 | }, | ||
| 139 | .serve => |p| p, | ||
| 140 | }; | ||
| 141 | defer parsed.deinit(alloc); | ||
| 142 | |||
| 143 | // Resolve spellings into dialable Targets. The recipes mirror | ||
| 144 | // mux_main's .host/.quic arms (a cross-file duplication, noted as a | ||
| 145 | // dedup candidate); labels are the argv spellings verbatim. | ||
| 146 | var targets: std.ArrayList(client.Target) = .empty; | ||
| 147 | defer targets.deinit(alloc); | ||
| 148 | var labels: std.ArrayList([]const u8) = .empty; | ||
| 149 | defer labels.deinit(alloc); | ||
| 150 | var owned: std.ArrayList([]const u8) = .empty; | ||
| 151 | defer { | ||
| 152 | for (owned.items) |s| alloc.free(s); | ||
| 153 | owned.deinit(alloc); | ||
| 154 | } | ||
| 155 | |||
| 156 | for (parsed.tiles.items) |spec| switch (spec) { | ||
| 157 | .sock => |path| { | ||
| 158 | if (path.len > 107) { | ||
| 159 | std.debug.print("muxweb: socket path too long ({d} bytes, max 107): {s}\n", .{ path.len, path }); | ||
| 160 | return 2; | ||
| 161 | } | ||
| 162 | try targets.append(alloc, .{ .sock = path }); | ||
| 163 | try labels.append(alloc, path); | ||
| 164 | }, | ||
| 165 | .host => |h| { | ||
| 166 | const cmd = try std.fmt.allocPrint(alloc, "ssh {s} muxd endpoint", .{h}); | ||
| 167 | try owned.append(alloc, cmd); | ||
| 168 | const cache: ?[]const u8 = xdg.hostCachePath(alloc, h) catch null; | ||
| 169 | if (cache) |c| try owned.append(alloc, c); | ||
| 170 | try targets.append(alloc, .{ .hand = .{ | ||
| 171 | .host = h, | ||
| 172 | .ssh_cmd = cmd, | ||
| 173 | .cache_path = cache, | ||
| 174 | .idle_ms = parsed.idle_ms, | ||
| 175 | } }); | ||
| 176 | try labels.append(alloc, h); | ||
| 177 | }, | ||
| 178 | .quic => |hp| { | ||
| 179 | const key_path = parsed.key orelse blk: { | ||
| 180 | const p = try xdg.keyPath(alloc); | ||
| 181 | try owned.append(alloc, p); | ||
| 182 | std.fs.cwd().access(p, .{}) catch { | ||
| 183 | std.debug.print( | ||
| 184 | "muxweb: no key for quic://{s}: pass --key, set MUX_KEY_FILE, or run `muxd keygen` (default {s})\n", | ||
| 185 | .{ hp, p }, | ||
| 186 | ); | ||
| 187 | return 2; | ||
| 188 | }; | ||
| 189 | break :blk p; | ||
| 190 | }; | ||
| 191 | try targets.append(alloc, .{ .quic = .{ | ||
| 192 | .host_port = hp, | ||
| 193 | .key_path = key_path, | ||
| 194 | .idle_ms = parsed.idle_ms, | ||
| 195 | } }); | ||
| 196 | const label = try std.fmt.allocPrint(alloc, "quic://{s}", .{hp}); | ||
| 197 | try owned.append(alloc, label); | ||
| 198 | try labels.append(alloc, label); | ||
| 199 | }, | ||
| 200 | }; | ||
| 201 | |||
| 202 | const addr = std.net.Address.parseIp("127.0.0.1", parsed.port) catch unreachable; | ||
| 203 | var listener = addr.listen(.{ .reuse_address = true }) catch |err| { | ||
| 204 | std.debug.print("muxweb: cannot bind 127.0.0.1:{d}: {s}\n", .{ parsed.port, @errorName(err) }); | ||
| 205 | return 1; | ||
| 206 | }; | ||
| 207 | defer listener.deinit(); | ||
| 208 | |||
| 209 | // The tile list, one line each, then the door: everything a script | ||
| 210 | // (or a user) needs to know the hub is up and what it serves. | ||
| 211 | for (labels.items, 0..) |label, i| { | ||
| 212 | std.debug.print("muxweb: tile {d}: {s}\n", .{ i, label }); | ||
| 213 | } | ||
| 214 | std.debug.print("muxweb: serving http://127.0.0.1:{d} pid={d}\n", .{ | ||
| 215 | parsed.port, | ||
| 216 | std.os.linux.getpid(), | ||
| 217 | }); | ||
| 218 | |||
| 219 | const assets = webhub.Assets{ | ||
| 220 | .index_html = @embedFile("index.html"), | ||
| 221 | .mux_js = @embedFile("mux.js"), | ||
| 222 | .core_wasm = @embedFile("mux_core.wasm"), | ||
| 223 | }; | ||
| 224 | |||
| 225 | while (true) { | ||
| 226 | const conn = listener.accept() catch continue; | ||
| 227 | const th = std.Thread.spawn(.{}, webhub.serveConn, .{ | ||
| 228 | alloc, conn.stream, parsed.port, targets.items, assets, | ||
| 229 | }) catch { | ||
| 230 | conn.stream.close(); | ||
| 231 | continue; | ||
| 232 | }; | ||
| 233 | th.detach(); | ||
| 234 | } | ||
| 235 | } | ||
| 236 | |||
| 237 | /// mux_main.key_env's value, spelled again rather than imported: pulling | ||
| 238 | /// an exe root into the module graph for one string constant is not | ||
| 239 | /// worth the tangle. mux_main.zig:49 is the origin; a drift between the | ||
| 240 | /// two spellings would make the two binaries read different variables, | ||
| 241 | /// so change both or neither. | ||
| 242 | const client_key_env = "MUX_KEY_FILE"; | ||
| 243 | |||
| 244 | test "parse: three spellings become three tiles in argv order, port and key bind" { | ||
| 245 | const alloc = std.testing.allocator; | ||
| 246 | const args = [_][:0]const u8{ | ||
| 247 | "muxweb", "box1", "--sock", "/tmp/a.sock", "quic://h:4433", "--key", "/k", "--port", "8000", | ||
| 248 | }; | ||
| 249 | var r = (try parseArgs(alloc, &args, null)).serve; | ||
| 250 | defer r.deinit(alloc); | ||
| 251 | try std.testing.expectEqual(@as(usize, 3), r.tiles.items.len); | ||
| 252 | try std.testing.expectEqualStrings("box1", r.tiles.items[0].host); | ||
| 253 | try std.testing.expectEqualStrings("/tmp/a.sock", r.tiles.items[1].sock); | ||
| 254 | try std.testing.expectEqualStrings("h:4433", r.tiles.items[2].quic); | ||
| 255 | try std.testing.expectEqual(@as(u16, 8000), r.port); | ||
| 256 | try std.testing.expectEqualStrings("/k", r.key.?); | ||
| 257 | } | ||
| 258 | |||
| 259 | test "parse: zero targets, bad flags, and flag-beats-env" { | ||
| 260 | const alloc = std.testing.allocator; | ||
| 261 | // No targets is a usage error, not an empty wall. | ||
| 262 | try std.testing.expect((try parseArgs(alloc, &[_][:0]const u8{"muxweb"}, null)) == .usage_error); | ||
| 263 | // A flag with no value is a usage mistake, not a transport. | ||
| 264 | try std.testing.expect((try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "--sock" }, null)) == .usage_error); | ||
| 265 | try std.testing.expect((try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--port", "x" }, null)) == .usage_error); | ||
| 266 | // Env fills in when --key is absent; --key wins when both are set. | ||
| 267 | { | ||
| 268 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "/env-key")).serve; | ||
| 269 | defer r.deinit(alloc); | ||
| 270 | try std.testing.expectEqualStrings("/env-key", r.key.?); | ||
| 271 | } | ||
| 272 | { | ||
| 273 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--key", "/flag-key" }, "/env-key")).serve; | ||
| 274 | defer r.deinit(alloc); | ||
| 275 | try std.testing.expectEqualStrings("/flag-key", r.key.?); | ||
| 276 | } | ||
| 277 | // Empty either way means unset. | ||
| 278 | { | ||
| 279 | var r = (try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h" }, "")).serve; | ||
| 280 | defer r.deinit(alloc); | ||
| 281 | try std.testing.expectEqual(@as(?[]const u8, null), r.key); | ||
| 282 | } | ||
| 283 | } | ||
| 284 | |||
| 285 | test "version short-circuits everything else on the line" { | ||
| 286 | const alloc = std.testing.allocator; | ||
| 287 | const r = try parseArgs(alloc, &[_][:0]const u8{ "muxweb", "h", "--version", "--bogus" }, null); | ||
| 288 | try std.testing.expect(r == .version); | ||
| 289 | } | ||