a73x

a52200c3

feat: webhub — origin gate, route table, ws endpoint naming

a73x   2026-08-13 10:29

Commit message
feat: webhub — origin gate, route table, ws endpoint naming

The decisions std.http does not make for us (M-web Task 5): the Origin
check (exactly http://127.0.0.1:PORT and http://localhost:PORT pass;
absent refuses — absent-means-yes is how localhost servers get owned),
the three-asset route table with content types, and /ws/<idx> parsing
that 404s an out-of-range tile. Assets are injected by the exe root so
this module's tests build no artifacts; ws_buffer_len (64 KiB) is named
here as the one number bounding both max HTTP header and max inbound WS
message, with the browser-side 32 KiB paste chunking as its contract.
Skeleton web/index.html and web/mux.js land for the @embedFile wiring;
the shell proper is Task 8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

build.zig
Old New
@@ -205,6 +205,15 @@ pub fn build(b: *std.Build) void {
205 .optimize = optimize, 205 .optimize = optimize,
206 }); 206 });
207 207
208 // The muxweb hub's HTTP/WebSocket decisions: Origin gate, route
209 // table, WS endpoint naming. Assets are injected (the exe root
210 // @embedFiles them), so its tests build no artifacts.
211 const webhub_mod = b.createModule(.{
212 .root_source_file = b.path("src/webhub.zig"),
213 .target = target,
214 .optimize = optimize,
215 });
216
208 // The socket path's identity and the right to bind it: the stale-socket 217 // The socket path's identity and the right to bind it: the stale-socket
209 // claim and the dev+ino record teardown compares against. A leaf — it 218 // claim and the dev+ino record teardown compares against. A leaf — it
210 // takes a path and nothing else, and knows no Server exists. 219 // takes a path and nothing else, and knows no Server exists.
@@ -472,7 +481,7 @@ pub fn build(b: *std.Build) void {
472 // absence here was a live hazard recorded in decisions.md — muxd's 481 // absence here was a live hazard recorded in decisions.md — muxd's
473 // entrypoint could grow tests that silently never ran, exactly as 482 // entrypoint could grow tests that silently never ran, exactly as
474 // mux_main.zig's five did before it was added. 483 // mux_main.zig's five did before it was added.
475 for ([_]*std.Build.Module{ protocol_mod, engine_mod, pty_mod, delta_mod, replica_mod, keymap_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| { 484 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| {
476 const t = b.addTest(.{ .root_module = mod }); 485 const t = b.addTest(.{ .root_module = mod });
477 t.use_llvm = true; 486 t.use_llvm = true;
478 t.use_lld = true; 487 t.use_lld = true;
src/webhub.zig
Old New
@@ -0,0 +1,136 @@
1 //! The muxweb hub's HTTP/WebSocket layer (M-web Task 5): route table,
2 //! Origin gate, and the WS endpoint naming — the decisions std.http does
3 //! NOT make for us. The connection loop and per-tile pump build on this
4 //! (Tasks 6-7); the assets are @embedFile'd by webhub_main.zig (the exe
5 //! root) and injected here, which keeps this module testable without
6 //! building the wasm artifact.
7 //!
8 //! Localhost only, by construction: the hub binds 127.0.0.1 and there is
9 //! no flag to change that in v1 — remote viewing is `ssh -L`,
10 //! authenticated by ssh like everything else in this project.
11
12 const std = @import("std");
13
14 pub const default_port: u16 = 7681;
15
16 /// ONE number bounds two things, a property of std.http.Server: the
17 /// buffer handed to the connection's Reader is both the max HTTP header
18 /// size AND the max inbound WebSocket message (readSmallMessage rejects
19 /// fragmented messages outright and caps at the buffer length). 64 KiB:
20 /// headers never approach it, keystrokes are bytes, and the browser side
21 /// chunks pastes at 32 KiB so no message approaches it either. Hub→
22 /// browser has no such bound (u64 lengths) — snapshots are safe.
23 pub const ws_buffer_len = 64 * 1024;
24
25 /// The WebSocket Origin check, non-negotiable (spec): any webpage open
26 /// in the browser may attempt ws://127.0.0.1:PORT — localhost binding
27 /// does not stop cross-origin WebSocket dials, and this socket carries
28 /// shell input to every device. Exactly our own two spellings pass;
29 /// no Origin header refuses. std's upgradeRequested does NOT check this;
30 /// it is entirely ours.
31 pub fn originAllowed(origin: ?[]const u8, port: u16) bool {
32 const o = origin orelse return false;
33 var buf: [40]u8 = undefined;
34 inline for (.{ "127.0.0.1", "localhost" }) |host| {
35 const want = std.fmt.bufPrint(&buf, "http://" ++ host ++ ":{d}", .{port}) catch
36 unreachable;
37 if (std.mem.eql(u8, o, want)) return true;
38 }
39 return false;
40 }
41
42 /// `/ws/<idx>` → the tile index, or null for anything else (including an
43 /// index that fails to parse or overruns the tile list — the caller sees
44 /// null and 404s rather than indexing air).
45 pub fn wsTileIndex(path: []const u8, tile_count: usize) ?usize {
46 const prefix = "/ws/";
47 if (!std.mem.startsWith(u8, path, prefix)) return null;
48 const idx = std.fmt.parseInt(usize, path[prefix.len..], 10) catch return null;
49 if (idx >= tile_count) return null;
50 return idx;
51 }
52
53 /// The embedded page, injected by the exe root (webhub_main @embedFiles
54 /// them; tests inject fakes).
55 pub const Assets = struct {
56 index_html: []const u8,
57 mux_js: []const u8,
58 core_wasm: []const u8,
59 };
60
61 pub const Asset = struct {
62 body: []const u8,
63 content_type: []const u8,
64 };
65
66 /// The whole static route table. Anything else is a 404 — there are no
67 /// other files, and inventing a directory to traverse would be the only
68 /// way to get one.
69 pub fn route(assets: Assets, path: []const u8) ?Asset {
70 if (std.mem.eql(u8, path, "/") or std.mem.eql(u8, path, "/index.html"))
71 return .{ .body = assets.index_html, .content_type = "text/html; charset=utf-8" };
72 if (std.mem.eql(u8, path, "/mux.js"))
73 return .{ .body = assets.mux_js, .content_type = "application/javascript" };
74 if (std.mem.eql(u8, path, "/mux_core.wasm"))
75 return .{ .body = assets.core_wasm, .content_type = "application/wasm" };
76 return null;
77 }
78
79 // ---------------------------------------------------------------------------
80
81 test "origin: exactly our two spellings pass, everything else refuses" {
82 const cases = [_]struct { origin: ?[]const u8, port: u16, want: bool }{
83 .{ .origin = "http://127.0.0.1:7681", .port = 7681, .want = true },
84 .{ .origin = "http://localhost:7681", .port = 7681, .want = true },
85 .{ .origin = "http://127.0.0.1:41234", .port = 41234, .want = true },
86 // The port is part of the identity.
87 .{ .origin = "http://127.0.0.1:7682", .port = 7681, .want = false },
88 // https is a different origin even on the right host+port.
89 .{ .origin = "https://127.0.0.1:7681", .port = 7681, .want = false },
90 // Any other page, including one that merely CONTAINS ours.
91 .{ .origin = "http://evil.example", .port = 7681, .want = false },
92 .{ .origin = "http://127.0.0.1:7681.evil.example", .port = 7681, .want = false },
93 .{ .origin = "http://[::1]:7681", .port = 7681, .want = false },
94 // A missing Origin header is a refusal, not a shrug: browsers
95 // always send it on cross-origin WebSocket dials, so its absence
96 // means a non-browser client that can speak to the daemon
97 // directly anyway — and "absent means yes" is how localhost
98 // servers get owned.
99 .{ .origin = null, .port = 7681, .want = false },
100 .{ .origin = "", .port = 7681, .want = false },
101 };
102 for (cases) |c| {
103 try std.testing.expectEqual(c.want, originAllowed(c.origin, c.port));
104 }
105 }
106
107 test "ws path: /ws/<idx> in range, null for everything else" {
108 try std.testing.expectEqual(@as(?usize, 0), wsTileIndex("/ws/0", 3));
109 try std.testing.expectEqual(@as(?usize, 2), wsTileIndex("/ws/2", 3));
110 try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/3", 3)); // over
111 try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/", 3));
112 try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/x", 3));
113 try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/ws/-1", 3));
114 try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/wsx/0", 3));
115 try std.testing.expectEqual(@as(?usize, null), wsTileIndex("/", 3));
116 }
117
118 test "routes: the three assets with their content types, 404 for the rest" {
119 const assets = Assets{
120 .index_html = "<html>",
121 .mux_js = "js",
122 .core_wasm = "\x00asm",
123 };
124 const idx = route(assets, "/").?;
125 try std.testing.expectEqualStrings("<html>", idx.body);
126 try std.testing.expectEqualStrings("text/html; charset=utf-8", idx.content_type);
127 try std.testing.expectEqualStrings("<html>", route(assets, "/index.html").?.body);
128 const js = route(assets, "/mux.js").?;
129 try std.testing.expectEqualStrings("application/javascript", js.content_type);
130 const wasm = route(assets, "/mux_core.wasm").?;
131 try std.testing.expectEqualStrings("application/wasm", wasm.content_type);
132 try std.testing.expectEqualStrings("\x00asm", wasm.body);
133 try std.testing.expectEqual(@as(?Asset, null), route(assets, "/etc/passwd"));
134 try std.testing.expectEqual(@as(?Asset, null), route(assets, "/ws/0"));
135 try std.testing.expectEqual(@as(?Asset, null), route(assets, "/../src/main.zig"));
136 }
web/index.html
Old New
@@ -0,0 +1,17 @@
1 <!doctype html>
2 <!-- muxweb: the wall of devices. Skeleton (M-web Task 5); the shell
3 proper lands in Task 8. -->
4 <html lang="en">
5 <head>
6 <meta charset="utf-8">
7 <title>mux</title>
8 <style>
9 body { margin: 0; background: #111; color: #ccc; font: 14px monospace; }
10 #wall { display: grid; gap: 8px; padding: 8px; }
11 </style>
12 </head>
13 <body>
14 <div id="wall"></div>
15 <script src="/mux.js"></script>
16 </body>
17 </html>
web/mux.js
Old New
@@ -0,0 +1,7 @@
1 // muxweb glue skeleton (M-web Task 5). The renderer, input, and tile
2 // logic land in Task 8. Discipline stated once, here, for everything
3 // that follows: NEVER cache a view of wasm memory — every call can grow
4 // linear memory and growth detaches every ArrayBuffer view. Re-read
5 // exports.memory.buffer after each call.
6 'use strict';
7 console.log('muxweb: shell skeleton');