summaryrefslogtreecommitdiff
path: root/app/web/client.mjs
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-14 14:28:15 -0300
committerGabriel Schneider <[email protected]>2026-09-16 11:18:48 -0300
commitae310a207534b33b7321dd2b9f423a73b1969159 (patch)
treeb4c2e85091a4ff6657e0a04ba1b2ef030d588ac8 /app/web/client.mjs
parent5f24c4a2284a0af84fb9d116f7a39f6a58e42ae9 (diff)
downloadcloud9-ae310a207534b33b7321dd2b9f423a73b1969159.tar.gz
cloud9-ae310a207534b33b7321dd2b9f423a73b1969159.zip
Add reusable HTTP transport, serial gateway, and WASM file browser
Diffstat (limited to 'app/web/client.mjs')
-rw-r--r--app/web/client.mjs138
1 files changed, 138 insertions, 0 deletions
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;
+ }));
+ }
+}