From ba996acfcad1698adbf4a1834fe50e73b1c6cab9 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Sat, 19 Sep 2026 23:28:22 -0300 Subject: Rename programs: 9player -> 9ns, introspect -> 9proc, app -> web (9web) Directories, binaries, build options (-D9ns, -D9proc), step names, module name (9proc), thread and fs names, env var NINEPLAYER_MOUNT -> NINE_MOUNT, docs and test scripts. Browser assets move to web/static. Co-Authored-By: Claude Fable 5.1 --- web/main.zig | 204 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 web/main.zig (limited to 'web/main.zig') diff --git a/web/main.zig b/web/main.zig new file mode 100644 index 0000000..e6aa421 --- /dev/null +++ b/web/main.zig @@ -0,0 +1,204 @@ +//! HTTP application; no web or namespace policy is added to the cloud9 library. +const std = @import("std"); +const c9 = @import("cloud9"); +const Io = std.Io; +const max_frame = 65536; +const max_connections = 32; +var serial_busy: std.atomic.Value(bool) = .init(false); +const Upstream = union(enum) { network: c9.transport.Address, file: []const u8 }; +var connections: std.atomic.Value(u32) = .init(0); + +const Config = struct { + upstream: Upstream, + host: []const u8, + origin: []const u8, + public_host: []const u8, + timeout_ms: u32, + browser_config: []const u8, +}; + +pub fn main(init: std.process.Init) !void { + const allocator = init.arena.allocator(); + const args = try init.minimal.args.toSlice(allocator); + var listen_text: []const u8 = "127.0.0.1:8080"; + var upstream_text: []const u8 = "tcp:127.0.0.1:564"; + var timeout_ms: u32 = 300000; + var user: []const u8 = "user"; + var tree: []const u8 = ""; + var public_origin: ?[]const u8 = null; + var i: usize = 1; + while (i < args.len) : (i += 1) { + if (std.mem.eql(u8, args[i], "--help")) { + std.debug.print("Usage: 9web [--listen IP:PORT] [--upstream tcp:IP:PORT|unix:PATH|file:PATH] [--origin https://HOST:PORT] [--timeout-ms 300000] [--user NAME] [--tree NAME]\nDefault: http://127.0.0.1:8080 -> tcp:127.0.0.1:564\n", .{}); + return; + } + if (i + 1 == args.len) return error.MissingArgument; + if (std.mem.eql(u8, args[i], "--listen")) { + i += 1; + listen_text = args[i]; + } else if (std.mem.eql(u8, args[i], "--upstream")) { + i += 1; + upstream_text = args[i]; + } else if (std.mem.eql(u8, args[i], "--origin")) { + i += 1; + public_origin = args[i]; + } else if (std.mem.eql(u8, args[i], "--timeout-ms")) { + i += 1; + timeout_ms = try std.fmt.parseInt(u32, args[i], 10); + } else if (std.mem.eql(u8, args[i], "--user")) { + i += 1; + user = args[i]; + } else if (std.mem.eql(u8, args[i], "--tree")) { + i += 1; + tree = args[i]; + } else return error.UnknownArgument; + } + const address = try Io.net.IpAddress.parseLiteral(listen_text); + const upstream: Upstream = if (std.mem.startsWith(u8, upstream_text, "tcp:")) + .{ .network = .{ .tcp = try Io.net.IpAddress.parseLiteral(upstream_text[4..]) } } + else if (std.mem.startsWith(u8, upstream_text, "unix:")) + .{ .network = .{ .unix = try allocator.dupeZ(u8, upstream_text[5..]) } } + else if (std.mem.startsWith(u8, upstream_text, "file:")) + .{ .file = upstream_text[5..] } + else + return error.InvalidUpstream; + const io = init.io; + var listener = c9.transport.listen(io, .{ .tcp = address }, max_connections) catch |err| { + if (err == error.AddressInUse) { + std.debug.print("9web: cannot listen on {s}: address already in use.\nUse --listen 127.0.0.1:0 to select a free port; the URL is printed at startup.\n", .{listen_text}); + } else { + std.debug.print("9web: cannot listen on {s}: {s}\n", .{ listen_text, @errorName(err) }); + } + return err; + }; + defer listener.deinit(io); + const host = try std.fmt.allocPrint(allocator, "{f}", .{listener.socket.address}); + const origin_text = public_origin orelse try std.fmt.allocPrint(allocator, "http://{s}", .{host}); + const origin_uri = try std.Uri.parse(origin_text); + if ((!std.mem.eql(u8, origin_uri.scheme, "https") and !std.mem.eql(u8, origin_uri.scheme, "http")) or + origin_uri.host == null or origin_uri.user != null or origin_uri.password != null or + origin_uri.path.percent_encoded.len != 0 or origin_uri.query != null or origin_uri.fragment != null) return error.InvalidOrigin; + if (user.len > 256 or tree.len > 256) return error.AttachNameTooLong; + const browser_config = try std.json.Stringify.valueAlloc(allocator, .{ .user = user, .tree = tree }, .{}); + const config: Config = .{ .upstream = upstream, .host = host, .origin = origin_text, .public_host = origin_text[origin_uri.scheme.len + 3 ..], .timeout_ms = timeout_ms, .browser_config = browser_config }; + std.debug.print("9web {s} -> {s}\n", .{ config.origin, upstream_text }); + var group: Io.Group = .init; + defer group.cancel(io); + while (true) { + const stream = try listener.accept(io); + if (connections.fetchAdd(1, .monotonic) >= max_connections) { + _ = connections.fetchSub(1, .monotonic); + stream.close(io); + continue; + } + group.concurrent(io, handle, .{ io, stream, config }) catch |err| { + _ = connections.fetchSub(1, .monotonic); + stream.close(io); + return err; + }; + } +} + +fn handle(io: Io, stream: Io.net.Stream, config: Config) void { + defer _ = connections.fetchSub(1, .monotonic); + defer stream.close(io); + var work: Connection = .{ .io = io, .stream = stream, .config = config }; + var group: Io.Group = .init; + defer group.cancel(io); + group.concurrent(io, Connection.run, .{&work}) catch return; + const timeout: Io.Timeout = if (config.timeout_ms == 0) .none else .{ .duration = .{ .raw = .fromMilliseconds(config.timeout_ms), .clock = .awake } }; + work.done.waitTimeout(io, timeout) catch {}; +} + +const Connection = struct { + io: Io, + stream: Io.net.Stream, + config: Config, + done: Io.Event = .unset, + fn run(connection: *Connection) void { + defer connection.done.set(connection.io); + serve(connection.io, connection.stream, connection.config) catch {}; + } +}; + +fn respond(request: *std.http.Server.Request, content: []const u8, mime: []const u8, status: std.http.Status) !void { + try request.respond(content, .{ .status = status, .keep_alive = false, .extra_headers = &.{ + .{ .name = "content-type", .value = mime }, + .{ .name = "cache-control", .value = "no-store" }, + .{ .name = "x-content-type-options", .value = "nosniff" }, + .{ .name = "content-security-policy", .value = "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'none'" }, + } }); +} + +fn serve(io: Io, stream: Io.net.Stream, config: Config) !void { + var input: [max_frame + 14]u8 = undefined; + var output: [8192]u8 = undefined; + var reader = stream.reader(io, &input); + var writer = stream.writer(io, &output); + var http: std.http.Server = .init(&reader.interface, &writer.interface); + http.reader.max_head_len = 8192; + var request = try http.receiveHead(); + var host: ?[]const u8 = null; + var origin: ?[]const u8 = null; + var version: ?[]const u8 = null; + var headers = request.iterateHeaders(); + while (headers.next()) |header| { + if (std.ascii.eqlIgnoreCase(header.name, "host")) host = header.value; + if (std.ascii.eqlIgnoreCase(header.name, "origin")) origin = header.value; + if (std.ascii.eqlIgnoreCase(header.name, "sec-websocket-version")) version = header.value; + } + if (!std.mem.eql(u8, host orelse "", config.host) and !std.mem.eql(u8, host orelse "", config.public_host)) return respond(&request, "Unexpected Host\n", "text/plain", .forbidden); + if (request.head.method != .GET) return respond(&request, "Use GET\n", "text/plain", .method_not_allowed); + const path = std.mem.sliceTo(request.head.target, '?'); + if (std.mem.eql(u8, path, "/_cloud9/config.json")) return respond(&request, config.browser_config, "application/json", .ok); + if (std.mem.eql(u8, path, "/_cloud9/app.mjs")) return respond(&request, @embedFile("static/app.mjs"), "text/javascript; charset=utf-8", .ok); + if (std.mem.eql(u8, path, "/_cloud9/client.mjs")) return respond(&request, @embedFile("static/client.mjs"), "text/javascript; charset=utf-8", .ok); + if (std.mem.eql(u8, path, "/_cloud9/style.css")) return respond(&request, @embedFile("static/style.css"), "text/css; charset=utf-8", .ok); + if (std.mem.eql(u8, path, "/_cloud9/cloud9.wasm")) return respond(&request, @embedFile("client.wasm"), "application/wasm", .ok); + if (!std.mem.eql(u8, path, "/_cloud9/9p")) { + if (std.mem.eql(u8, path, "/_cloud9") or std.mem.startsWith(u8, path, "/_cloud9/")) + return respond(&request, "Not found\n", "text/plain", .not_found); + // File URLs boot the same browser client. The client resolves the path + // in the upstream 9P namespace, never in this machine's filesystem. + return respond(&request, @embedFile("static/index.html"), "text/html; charset=utf-8", .ok); + } + if (!std.mem.eql(u8, origin orelse "", config.origin)) return respond(&request, "Unexpected Origin\n", "text/plain", .forbidden); + if (!std.mem.eql(u8, version orelse "", "13")) return respond(&request, "WebSocket version 13 required\n", "text/plain", .bad_request); + switch (config.upstream) { + .network => |address| { + const upstream = c9.transport.connect(io, address) catch return respond(&request, "9P upstream unavailable\n", "text/plain", .bad_gateway); + defer upstream.close(io); + var in_buffer: [8192]u8 = undefined; + var out_buffer: [8192]u8 = undefined; + var upstream_reader = upstream.reader(io, &in_buffer); + var upstream_writer = upstream.writer(io, &out_buffer); + try bridge(io, &request, &upstream_reader.interface, &upstream_writer.interface); + }, + .file => |path_name| { + if (serial_busy.swap(true, .acquire)) return respond(&request, "Device already in use\n", "text/plain", .service_unavailable); + defer serial_busy.store(false, .release); + const file = Io.Dir.cwd().openFile(io, path_name, .{ .mode = .read_write }) catch return respond(&request, "9P device unavailable\n", "text/plain", .bad_gateway); + defer file.close(io); + var in_buffer: [8192]u8 = undefined; + var out_buffer: [8192]u8 = undefined; + var upstream_reader = file.readerStreaming(io, &in_buffer); + var upstream_writer = file.writerStreaming(io, &out_buffer); + try bridge(io, &request, &upstream_reader.interface, &upstream_writer.interface); + }, + } +} + +fn bridge(io: Io, request: *std.http.Server.Request, reader: *Io.Reader, writer: *Io.Writer) !void { + var ws = try c9.http.accept(request); + var requests: [max_frame]u8 = undefined; + var replies: [max_frame]u8 = undefined; + var relay: c9.http.Bridge = .{ + .socket = &ws, + .upstream_reader = reader, + .upstream_writer = writer, + .request_buffer = &requests, + .reply_buffer = &replies, + .frame_limit = max_frame, + }; + try relay.run(io, .none); +} -- cgit v1.3