summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/client.zig176
-rw-r--r--app/main.zig204
-rw-r--r--app/web/app.mjs183
-rw-r--r--app/web/client.mjs138
-rw-r--r--app/web/index.html53
-rw-r--r--app/web/style.css87
6 files changed, 841 insertions, 0 deletions
diff --git a/app/client.zig b/app/client.zig
new file mode 100644
index 0000000..2ec9163
--- /dev/null
+++ b/app/client.zig
@@ -0,0 +1,176 @@
+//! Browser ABI. Protocol state, encoding, reply validation, and directory decoding
+//! all use the same cloud9 sources as native clients. No allocator or host imports.
+const std = @import("std");
+const c9 = @import("cloud9");
+const capacity = 65536;
+var input: [capacity]u8 = undefined;
+var output: [capacity]u8 = undefined;
+var staging: [capacity]u8 = undefined;
+var json: [capacity * 2]u8 = undefined;
+var client: c9.Client = undefined;
+var data: []const u8 = "";
+var number: u32 = 0;
+var directory: bool = false;
+
+export fn init() void {
+ client = .init(.{ .in = &input, .out = &output });
+ data = "";
+ number = 0;
+ directory = false;
+}
+export fn input_ptr() [*]u8 {
+ return &staging;
+}
+export fn input_capacity() u32 {
+ return capacity;
+}
+export fn output_ptr() [*]const u8 {
+ return client.output().ptr;
+}
+export fn output_len() u32 {
+ return @intCast(client.output().len);
+}
+export fn sent() void {
+ client.wrote(client.output().len);
+}
+export fn data_ptr() [*]const u8 {
+ return data.ptr;
+}
+export fn data_len() u32 {
+ return @intCast(data.len);
+}
+export fn result_number() u32 {
+ return number;
+}
+export fn result_directory() bool {
+ return directory;
+}
+export fn is_dead() bool {
+ return client.dead;
+}
+export fn max_read() u32 {
+ return client.maxRead();
+}
+export fn max_write() u32 {
+ return client.maxWrite();
+}
+
+fn fail(message: []const u8) i32 {
+ data = message;
+ return -1;
+}
+fn submit(request: c9.Client.Request) i32 {
+ _ = client.submit(request) catch |err| return fail(@errorName(err));
+ return 0;
+}
+export fn version() i32 {
+ return submit(.{ .version = .{} });
+}
+export fn attach(user_len: u32, tree_len: u32) i32 {
+ if (user_len > capacity or tree_len > capacity - user_len) return fail("InputTooLarge");
+ return submit(.{ .attach = .{ .fid = 0, .uname = staging[0..user_len], .aname = staging[user_len..][0..tree_len] } });
+}
+export fn walk(fid: u32, newfid: u32, len: u32) i32 {
+ if (len > capacity) return fail("InputTooLarge");
+ var names: [c9.max_welem][]const u8 = undefined;
+ var count: usize = 0;
+ var it = std.mem.splitScalar(u8, staging[0..len], '/');
+ while (it.next()) |name| {
+ if (name.len == 0) continue;
+ if (count == names.len) return fail("TooManyNames");
+ names[count] = name;
+ count += 1;
+ }
+ return submit(.{ .walk = .{ .fid = fid, .newfid = newfid, .names = names[0..count] } });
+}
+export fn open(fid: u32, mode: u8) i32 {
+ return submit(.{ .open = .{ .fid = fid, .mode = mode } });
+}
+export fn read(fid: u32, offset: u64, count: u32) i32 {
+ return submit(.{ .read = .{ .fid = fid, .offset = offset, .count = count } });
+}
+export fn write(fid: u32, offset: u64, len: u32) i32 {
+ if (len > capacity) return fail("InputTooLarge");
+ return submit(.{ .write = .{ .fid = fid, .offset = offset, .data = staging[0..len] } });
+}
+export fn clunk(fid: u32) i32 {
+ return submit(.{ .clunk = .{ .fid = fid } });
+}
+export fn stat(fid: u32) i32 {
+ return submit(.{ .stat = .{ .fid = fid } });
+}
+
+fn writeStat(s: c9.Stat, writer: *std.Io.Writer) !void {
+ try std.json.Stringify.value(.{ .name = s.name, .directory = s.qid.type & c9.qtdir != 0, .length = s.length, .mode = s.mode }, .{}, writer);
+}
+
+/// Result codes: 0 incomplete, -1 error, 1 version, 2 attach, 3 walk,
+/// 4 open, 5 read, 6 write, 7 clunk, 8 stat. Data is borrowed until next call.
+export fn receive(len: u32) i32 {
+ if (len > capacity) return fail("InputTooLarge");
+ if (client.push(staging[0..len]) != len) return fail("InputFull");
+ const done = client.take() orelse return if (client.dead) fail("ProtocolError") else 0;
+ data = "";
+ number = 0;
+ directory = false;
+ return switch (done.result) {
+ .fail => |message| fail(message),
+ .version => |v| blk: {
+ data = v.version;
+ number = v.msize;
+ break :blk 1;
+ },
+ .attach => |qid| blk: {
+ directory = qid.type & c9.qtdir != 0;
+ break :blk 2;
+ },
+ .walk => |w| blk: {
+ number = w.nwqid;
+ if (w.nwqid != 0) directory = w.wqid[w.nwqid - 1].type & c9.qtdir != 0;
+ break :blk 3;
+ },
+ .open => |o| blk: {
+ number = o.iounit;
+ directory = o.qid.type & c9.qtdir != 0;
+ break :blk 4;
+ },
+ .read => |bytes| blk: {
+ data = bytes;
+ break :blk 5;
+ },
+ .write => |count| blk: {
+ number = count;
+ break :blk 6;
+ },
+ .clunk => 7,
+ .stat => |s| blk: {
+ var writer: std.Io.Writer = .fixed(&json);
+ writeStat(s, &writer) catch return fail("OutputTooLarge");
+ data = writer.buffered();
+ break :blk 8;
+ },
+ else => fail("UnexpectedReply"),
+ };
+}
+
+/// Parse complete directory stat records from one Rread, using cloud9.Stat.
+export fn decode_directory(len: u32) i32 {
+ if (len > capacity) return fail("InputTooLarge");
+ var remaining: []const u8 = staging[0..len];
+ var writer: std.Io.Writer = .fixed(&json);
+ writer.writeByte('[') catch unreachable;
+ var first = true;
+ while (remaining.len != 0) {
+ if (remaining.len < 2) return fail("TruncatedDirectory");
+ const size: usize = @as(usize, std.mem.readInt(u16, remaining[0..2], .little)) + 2;
+ if (size > remaining.len) return fail("TruncatedDirectory");
+ const s = c9.Stat.decode(remaining[0..size]) catch return fail("InvalidDirectory");
+ if (!first) writer.writeByte(',') catch return fail("OutputTooLarge");
+ writeStat(s, &writer) catch return fail("OutputTooLarge");
+ first = false;
+ remaining = remaining[size..];
+ }
+ writer.writeByte(']') catch return fail("OutputTooLarge");
+ data = writer.buffered();
+ return 0;
+}
diff --git a/app/main.zig b/app/main.zig
new file mode 100644
index 0000000..720503f
--- /dev/null
+++ b/app/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: cloud9-http [--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("cloud9-http: 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("cloud9-http: 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("cloud9-http {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("web/app.mjs"), "text/javascript; charset=utf-8", .ok);
+ if (std.mem.eql(u8, path, "/_cloud9/client.mjs")) return respond(&request, @embedFile("web/client.mjs"), "text/javascript; charset=utf-8", .ok);
+ if (std.mem.eql(u8, path, "/_cloud9/style.css")) return respond(&request, @embedFile("web/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("web/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);
+}
diff --git a/app/web/app.mjs b/app/web/app.mjs
new file mode 100644
index 0000000..a6985f0
--- /dev/null
+++ b/app/web/app.mjs
@@ -0,0 +1,183 @@
+import { Client } from './client.mjs';
+const $ = id => document.getElementById(id);
+const text = new TextDecoder('utf-8', { fatal: true });
+let client = null, current = null, currentPath = '/', busy = false, dirty = false;
+let configuration = null;
+
+function status(message, error = false) {
+ $('status').textContent = message;
+ $('status').classList.toggle('error', error);
+}
+function controls() {
+ for (const id of ['go', 'reload']) $(id).disabled = busy;
+ $('save').disabled = busy || !current || current.stat.directory || $('editor').hidden || !dirty;
+ $('download').disabled = busy || !current;
+ $('editor').readOnly = busy;
+ $('edit-status').textContent = dirty ? 'Unsaved changes.' : 'No unsaved changes.';
+ document.querySelector('main').setAttribute('aria-busy', String(busy));
+}
+async function action(operation) {
+ if (busy) return;
+ busy = true;
+ controls();
+ try { await operation(); }
+ catch (error) { status(error.message, true); }
+ finally { busy = false; controls(); }
+}
+async function connectedClient() {
+ if (client && !client.closed) return client;
+ if (!configuration) {
+ const response = await fetch('/_cloud9/config.json');
+ if (!response.ok) throw new Error('Unable to load server settings. Reload to try again.');
+ configuration = await response.json();
+ }
+ try { client = await Client.connect('/_cloud9/9p', configuration.user, configuration.tree); }
+ catch { throw new Error('Unable to reach the file server. Reload to try again.'); }
+ return client;
+}
+async function readPath(path) {
+ const active = await connectedClient();
+ try { return await active.readPath(path); }
+ catch (error) {
+ // A timed-out/closed transport has lost its fids. Retry reads once on a
+ // fresh session. Writes are never replayed after an uncertain result.
+ if (!active.closed) throw error;
+ return (await connectedClient()).readPath(path);
+ }
+}
+function normalizePath(path) {
+ const parts = [];
+ for (const name of path.split('/')) {
+ if (!name || name === '.') continue;
+ if (name === '..') parts.pop(); else parts.push(name);
+ }
+ return '/' + parts.join('/');
+}
+function pathURL(path) {
+ const parts = path.split('/').map(encodeURIComponent);
+ // Preserve access to an upstream directory named _cloud9 without colliding
+ // with the literal gateway prefix. Decode each component exactly once.
+ if (parts[1] === '_cloud9') parts[1] = '%5Fcloud9';
+ return parts.join('/');
+}
+function locationPath() {
+ const parts = location.pathname.split('/').map(part => {
+ let name;
+ try { name = decodeURIComponent(part); }
+ catch { throw new Error('The URL contains invalid path encoding.'); }
+ if (name.includes('/') || name.includes('\0')) throw new Error('The URL contains an invalid path component.');
+ return name;
+ });
+ return normalizePath(parts.join('/'));
+}
+function child(path, name) { return `${path.replace(/\/$/, '')}/${name}`; }
+function mode(stat) {
+ let result = stat.directory ? 'd' : '-';
+ for (let bit = 8; bit >= 0; bit--) result += stat.mode & (1 << bit) ? 'rwx'[(8 - bit) % 3] : '-';
+ return result;
+}
+function pathLink(label, path, className = '') {
+ const link = document.createElement('a');
+ link.textContent = label;
+ link.href = pathURL(path);
+ link.dataset.path = path;
+ link.className = className;
+ return link;
+}
+function breadcrumbs(path) {
+ const node = $('breadcrumbs');
+ node.replaceChildren(pathLink('root', '/'));
+ let walked = '';
+ for (const name of path.split('/').filter(Boolean)) {
+ walked += '/' + name;
+ const separator = document.createElement('span');
+ separator.textContent = '/'; separator.className = 'separator';
+ node.append(separator, pathLink(name, walked));
+ }
+ node.lastElementChild.setAttribute('aria-current', 'page');
+ const parent = path.replace(/\/[^/]+$/, '') || '/';
+ $('up').hidden = path === '/';
+ $('up').href = pathURL(parent);
+ $('up').dataset.path = parent;
+}
+async function navigate(path, historyMode = 'push') {
+ path = normalizePath(path);
+ status('Loading…');
+ const result = await readPath(path);
+ current = result; currentPath = path; dirty = false;
+ $('path').value = path;
+ const url = pathURL(path);
+ if (historyMode === 'push' && location.pathname + location.search + location.hash !== url) history.pushState(null, '', url);
+ document.title = `${path} — cloud9`;
+ breadcrumbs(path);
+ $('directory').hidden = !result.stat.directory;
+ $('file').hidden = result.stat.directory;
+ if (result.stat.directory) {
+ const rows = document.createDocumentFragment();
+ const sorted = result.entries.sort((a, b) => Number(b.directory) - Number(a.directory) || a.name.localeCompare(b.name));
+ for (const entry of sorted) {
+ const row = document.createElement('tr');
+ const permissions = document.createElement('td'), name = document.createElement('td'), size = document.createElement('td');
+ permissions.className = 'mode'; permissions.textContent = mode(entry);
+ name.append(pathLink(entry.name + (entry.directory ? '/' : ''), child(path, entry.name), 'entry' + (entry.directory ? ' directory' : '')));
+ size.className = 'size'; size.textContent = entry.directory ? '—' : entry.length.toLocaleString();
+ row.append(permissions, name, size); rows.append(row);
+ }
+ $('entries').replaceChildren(rows);
+ $('empty').hidden = sorted.length !== 0;
+ status(`${sorted.length} ${sorted.length === 1 ? 'entry' : 'entries'}`);
+ } else {
+ $('filename').textContent = result.stat.name;
+ $('file-mode').textContent = mode(result.stat);
+ $('file-size').textContent = `${result.bytes.length.toLocaleString()} bytes`;
+ let editable = true;
+ try {
+ if (result.bytes.includes(0)) throw new Error('Binary');
+ $('editor').value = text.decode(result.bytes);
+ } catch { editable = false; $('editor').value = ''; }
+ $('editor').hidden = !editable;
+ $('binary').hidden = editable;
+ $('edit-actions').hidden = !editable;
+ status('File loaded.');
+ }
+}
+function mayLeave() { return !dirty || window.confirm('Discard unsaved changes?'); }
+document.addEventListener('click', event => {
+ const link = event.target.closest('a[data-path]');
+ if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
+ event.preventDefault();
+ if (!busy && mayLeave()) action(() => navigate(link.dataset.path));
+});
+$('path-form').onsubmit = event => {
+ event.preventDefault();
+ if (mayLeave()) action(() => navigate($('path').value));
+};
+$('reload').onclick = () => { if (mayLeave()) action(() => navigate(current ? currentPath : locationPath(), 'none')); };
+$('editor').oninput = () => { dirty = true; controls(); };
+$('save').onclick = () => action(async () => {
+ const bytes = new TextEncoder().encode($('editor').value);
+ const active = await connectedClient();
+ let count;
+ try { count = await active.writePath(currentPath, bytes); }
+ catch (error) {
+ if (active.closed) throw new Error('Save interrupted. The file may be partially written; your edits are still here.');
+ throw error;
+ }
+ // Keep the editor intact if refreshing after a successful write fails.
+ dirty = false;
+ await navigate(currentPath, 'none');
+ status(`Saved ${count.toLocaleString()} bytes.`);
+});
+$('download').onclick = () => {
+ const url = URL.createObjectURL(new Blob([current.bytes]));
+ const link = document.createElement('a'); link.href = url; link.download = current.stat.name; link.click();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+};
+window.addEventListener('popstate', () => {
+ if (busy || !mayLeave()) { history.pushState(null, '', pathURL(currentPath)); return; }
+ action(() => navigate(locationPath(), 'none'));
+});
+window.addEventListener('beforeunload', event => {
+ if (dirty) { event.preventDefault(); event.returnValue = ''; }
+});
+action(() => navigate(locationPath(), 'none'));
diff --git a/app/web/client.mjs b/app/web/client.mjs
new file mode 100644
index 0000000..69f44d2
--- /dev/null
+++ b/app/web/client.mjs
@@ -0,0 +1,138 @@
+// Browser transport and file operations. All 9P serialization lives in WASM.
+const encoder = new TextEncoder();
+const decoder = new TextDecoder();
+export class Client {
+ static async connect(url = '/_cloud9/9p', user = 'user', tree = '') {
+ const { instance } = await WebAssembly.instantiateStreaming(fetch(new URL('./cloud9.wasm', import.meta.url)), {});
+ const wsURL = new URL(url, location.href);
+ wsURL.protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
+ const socket = new WebSocket(wsURL);
+ socket.binaryType = 'arraybuffer';
+ const client = new Client(instance.exports, socket);
+ try {
+ await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('Connection timed out')), 10000);
+ socket.onopen = () => { clearTimeout(timer); resolve(); };
+ socket.onerror = () => { clearTimeout(timer); reject(new Error('Cannot connect to 9P bridge')); };
+ });
+ await client.ask('version');
+ const u = encoder.encode(user), a = encoder.encode(tree);
+ client.stage(new Uint8Array([...u, ...a]));
+ await client.ask('attach', u.length, a.length);
+ return client;
+ } catch (error) { client.close(); throw error; }
+ }
+ constructor(wasm, socket) {
+ this.wasm = wasm;
+ this.socket = socket;
+ this.pending = null;
+ this.queue = Promise.resolve();
+ this.closed = false;
+ wasm.init();
+ socket.onmessage = ({ data }) => {
+ if (!this.pending) { this.close(); return; }
+ try {
+ this.stage(new Uint8Array(data));
+ const kind = wasm.receive(data.byteLength);
+ if (!kind) return;
+ const result = { kind, data: this.data(), number: wasm.result_number(), directory: Boolean(wasm.result_directory()) };
+ const pending = this.pending;
+ this.pending = null;
+ clearTimeout(pending.timer);
+ if (kind < 0) {
+ const error = new Error(decoder.decode(result.data));
+ pending.reject(error);
+ if (wasm.is_dead()) this.abort(error);
+ }
+ else pending.resolve(result);
+ } catch (error) { this.abort(error); }
+ };
+ socket.onclose = () => this.abort(new Error('Connection closed'));
+ }
+ data() { return new Uint8Array(this.wasm.memory.buffer, this.wasm.data_ptr(), this.wasm.data_len()).slice(); }
+ stage(bytes) {
+ if (bytes.length > this.wasm.input_capacity()) throw new Error('Input exceeds 64 KiB');
+ new Uint8Array(this.wasm.memory.buffer, this.wasm.input_ptr(), bytes.length).set(bytes);
+ return bytes.length;
+ }
+ ask(operation, ...args) {
+ if (this.closed) return Promise.reject(new Error('Connection closed'));
+ if (this.pending) return Promise.reject(new Error('Request already in flight'));
+ if (this.wasm[operation](...args) < 0) return Promise.reject(new Error(decoder.decode(this.data())));
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => this.abort(new Error('9P request timed out')), 15000);
+ this.pending = { resolve, reject, timer };
+ try {
+ this.socket.send(new Uint8Array(this.wasm.memory.buffer, this.wasm.output_ptr(), this.wasm.output_len()));
+ this.wasm.sent();
+ } catch (error) { this.abort(error); }
+ });
+ }
+ abort(error) {
+ this.closed = true;
+ if (this.pending) { clearTimeout(this.pending.timer); this.pending.reject(error); this.pending = null; }
+ this.socket.close();
+ }
+ close() { this.abort(new Error('Disconnected')); }
+ serial(operation) {
+ const result = this.queue.then(operation);
+ this.queue = result.catch(() => {});
+ return result;
+ }
+ async withFile(path, operation) {
+ const names = path.split('/').filter(Boolean);
+ let live = false;
+ try {
+ // Walk in spec-sized batches, retaining an unopened root fid.
+ for (let index = 0; index < Math.max(1, names.length); index += 16) {
+ const batch = names.slice(index, index + 16);
+ const len = this.stage(encoder.encode(batch.join('/')));
+ const result = await this.ask('walk', index === 0 ? 0 : 1, 1, len);
+ live = live || result.number > 0 || batch.length === 0;
+ if (result.number !== batch.length) throw new Error('Path does not exist');
+ }
+ return await operation();
+ } finally {
+ if (live && !this.closed) await this.ask('clunk', 1);
+ }
+ }
+ readPath(path) {
+ return this.serial(() => this.withFile(path, async () => {
+ const stat = JSON.parse(decoder.decode((await this.ask('stat', 1)).data));
+ const opened = await this.ask('open', 1, 0);
+ const count = Math.min(this.wasm.max_read(), opened.number || Infinity);
+ let offset = 0;
+ const chunks = [], entries = [];
+ while (true) {
+ const { data } = await this.ask('read', 1, BigInt(offset), count);
+ if (!data.length) break;
+ offset += data.length;
+ if (offset > 8 * 1024 * 1024) throw new Error('Browser view is limited to 8 MiB');
+ if (stat.directory) {
+ this.stage(data);
+ if (this.wasm.decode_directory(data.length) < 0) throw new Error(decoder.decode(this.data()));
+ entries.push(...JSON.parse(decoder.decode(this.data())));
+ } else chunks.push(data);
+ }
+ const bytes = new Uint8Array(stat.directory ? 0 : offset);
+ let position = 0;
+ for (const chunk of chunks) { bytes.set(chunk, position); position += chunk.length; }
+ return { stat, entries, bytes };
+ }));
+ }
+ writePath(path, bytes) {
+ if (!(bytes instanceof Uint8Array) || bytes.length > 8 * 1024 * 1024) return Promise.reject(new Error('Write is limited to 8 MiB'));
+ return this.serial(() => this.withFile(path, async () => {
+ const opened = await this.ask('open', 1, 1 | 16); // OWRITE | OTRUNC
+ const count = Math.min(this.wasm.max_write(), opened.number || Infinity);
+ let offset = 0;
+ while (offset < bytes.length) {
+ const len = this.stage(bytes.subarray(offset, offset + count));
+ const result = await this.ask('write', 1, BigInt(offset), len);
+ if (!result.number) throw new Error('Server made no write progress');
+ offset += result.number;
+ }
+ return offset;
+ }));
+ }
+}
diff --git a/app/web/index.html b/app/web/index.html
new file mode 100644
index 0000000..aeca5d3
--- /dev/null
+++ b/app/web/index.html
@@ -0,0 +1,53 @@
+<!doctype html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width,initial-scale=1">
+ <title>cloud9 — files</title>
+ <link rel="stylesheet" href="/_cloud9/style.css">
+ <script type="module" src="/_cloud9/app.mjs"></script>
+</head>
+<body>
+ <header>
+ <a class="brand" href="/" data-path="/">cloud9</a>
+ <div class="description">A web interface to your file server.</div>
+ </header>
+ <nav class="toolbar" aria-label="File actions">
+ <a class="selected" href="/" data-path="/">files</a>
+ <button id="reload" type="button">reload</button>
+ <form id="path-form">
+ <label for="path">path</label>
+ <input id="path" name="path" value="/" aria-label="Path" autocomplete="off" spellcheck="false">
+ <button id="go">go</button>
+ </form>
+ </nav>
+ <main>
+ <nav id="breadcrumbs" aria-label="Breadcrumb"><a href="/" data-path="/">root</a></nav>
+ <div class="summary">
+ <p id="status" role="status" aria-live="polite">Loading files…</p>
+ <a id="up" href="/" data-path="/" hidden>parent directory</a>
+ </div>
+ <section id="directory" aria-label="Directory">
+ <table>
+ <thead><tr><th scope="col">Mode</th><th scope="col">Name</th><th scope="col" class="size">Size</th></tr></thead>
+ <tbody id="entries"></tbody>
+ </table>
+ <p id="empty" hidden>This directory is empty.</p>
+ </section>
+ <section id="file" hidden>
+ <div class="file-heading">
+ <h1 id="filename"></h1>
+ <span id="file-mode"></span><span id="file-size"></span>
+ <button id="download" type="button">download</button>
+ </div>
+ <textarea id="editor" aria-label="File contents" spellcheck="false"></textarea>
+ <p id="binary" hidden>Binary file. Download to view its contents.</p>
+ <div id="edit-actions" class="actions">
+ <button id="save" type="button">save changes</button>
+ <span id="edit-status">No unsaved changes.</span>
+ </div>
+ </section>
+ </main>
+ <footer>served by cloud9</footer>
+</body>
+</html>
diff --git a/app/web/style.css b/app/web/style.css
new file mode 100644
index 0000000..a8605f5
--- /dev/null
+++ b/app/web/style.css
@@ -0,0 +1,87 @@
+:root {
+ font: 14px/1.45 sans-serif;
+ color: #333;
+ background: white;
+ font-synthesis: none;
+}
+* { box-sizing: border-box; }
+body { margin: 0; padding: 22px 3%; }
+a { color: #07539b; text-decoration: none; }
+a:hover { text-decoration: underline; }
+header { padding: 0 8px 17px; }
+.brand { font-size: 30px; font-weight: bold; color: #222; letter-spacing: -1px; }
+.description { color: #777; margin-top: 1px; }
+.toolbar {
+ display: flex;
+ align-items: center;
+ gap: 2px;
+ border-bottom: 3px solid #ccc;
+ padding: 0 7px;
+}
+.toolbar > a, .toolbar > button {
+ padding: 5px 14px;
+ color: #555;
+ background: none;
+ border: 0;
+ font-size: 15px;
+}
+.toolbar .selected { background: #ccc; color: #111; }
+button, input, textarea { font: inherit; color: inherit; }
+button {
+ cursor: pointer;
+ border: 1px solid #aaa;
+ padding: 2px 9px;
+ background: #f5f5f5;
+ border-radius: 0;
+}
+button:hover:enabled { background: #e8e8e8; }
+button:disabled { color: #999; cursor: default; }
+#path-form { margin-left: auto; display: flex; align-items: center; gap: 6px; padding-bottom: 5px; }
+#path-form label { color: #777; font-size: 12px; }
+#path { width: 250px; border: 1px solid #bbb; padding: 2px 5px; font: 13px/1.5 monospace; }
+main { padding: 0 8px; }
+#breadcrumbs { display: flex; flex-wrap: wrap; gap: 7px; padding: 13px 0 5px; font-family: monospace; overflow-wrap: anywhere; }
+#breadcrumbs .separator { color: #999; }
+#breadcrumbs [aria-current] { color: #333; font-weight: bold; }
+.summary { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin: 2px 0 13px; font-size: 12px; }
+#status { color: #777; margin: 0; min-height: 18px; }
+#status.error { color: #a22; }
+#up { white-space: nowrap; }
+table { width: 100%; border-collapse: collapse; text-align: left; }
+th { background: #eee; padding: 4px 8px; font-weight: bold; border-bottom: 1px solid #ccc; }
+td { padding: 3px 8px; vertical-align: top; }
+tbody tr:nth-child(even) { background: #f7f7f7; }
+tbody tr:hover { background: #edf3f8; }
+.mode { width: 120px; color: #777; font: 12px/1.7 monospace; white-space: nowrap; }
+.entry { font-family: monospace; overflow-wrap: anywhere; }
+.entry.directory { font-weight: bold; }
+.size { width: 110px; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
+td.size { color: #666; font-size: 12px; }
+#empty { color: #777; padding: 4px 8px; }
+.file-heading { display: flex; flex-wrap: wrap; align-items: baseline; gap: 16px; padding: 6px 8px; background: #eee; border-bottom: 1px solid #ccc; }
+h1 { font: bold 14px monospace; margin: 0; overflow-wrap: anywhere; }
+#file-mode, #file-size { color: #666; font: 12px monospace; }
+#download { margin-left: auto; font-size: 12px; }
+textarea { display: block; width: 100%; min-height: 420px; resize: vertical; border: 1px solid #ddd; border-top: 0; padding: 10px; font: 13px/1.6 monospace; tab-size: 4; white-space: pre; overflow: auto; }
+#binary { color: #777; padding: 16px 8px; }
+.actions { display: flex; align-items: center; gap: 12px; margin-top: 10px; font-size: 12px; }
+#edit-status { color: #777; }
+footer { margin: 30px 8px 0; padding-top: 7px; border-top: 1px solid #ddd; text-align: right; color: #999; font-size: 11px; }
+a:focus-visible, button:focus-visible { outline: 2px solid #07539b; outline-offset: 2px; }
+[hidden] { display: none !important; }
+@media (max-width: 600px) {
+ body { padding: 14px 10px; }
+ header { padding-left: 3px; }
+ .brand { font-size: 26px; }
+ .toolbar { flex-wrap: wrap; padding: 0; }
+ .toolbar > a, .toolbar > button { padding: 5px 10px; }
+ #path-form { order: -1; width: 100%; margin: 0 0 6px; }
+ #path { width: auto; min-width: 0; flex: 1; }
+ main { padding: 0; }
+ th, td { padding-left: 5px; padding-right: 5px; }
+ .mode { width: 86px; font-size: 10px; }
+ .size { width: 65px; }
+ .summary { align-items: start; }
+ .file-heading { gap: 7px 12px; }
+ textarea { min-height: 350px; }
+}