// 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; })); } }