diff options
Diffstat (limited to 'web/static/client.mjs')
| -rw-r--r-- | web/static/client.mjs | 138 |
1 files changed, 138 insertions, 0 deletions
diff --git a/web/static/client.mjs b/web/static/client.mjs new file mode 100644 index 0000000..69f44d2 --- /dev/null +++ b/web/static/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; + })); + } +} |
