135 lines
6.6 KiB
JavaScript
135 lines
6.6 KiB
JavaScript
'use strict'
|
|
const fs = require('node:fs')
|
|
const { randomUUID, createHash } = require('node:crypto')
|
|
const prefix = '\x1eYCODE_WORKSPACE_READ_V1 '
|
|
let selected = false
|
|
function error(code, message) { return Object.assign(new Error(message), { code }) }
|
|
function frame(operation, args, id) { return '\n' + prefix + JSON.stringify({ version: 1, requestId: id, operation, args }) + '\n' }
|
|
function send(operation, args, id) {
|
|
const bytes = Buffer.from(frame(operation, args, id))
|
|
let offset = 0
|
|
while (offset < bytes.length) offset += fs.writeSync(1, bytes, offset, bytes.length - offset)
|
|
}
|
|
function unpack(line, id) {
|
|
let response
|
|
try { response = JSON.parse(line) } catch { throw error('EPROTO', 'Invalid file bridge reply') }
|
|
if (response.version !== 1 || response.requestId !== id || typeof response.ok !== 'boolean') throw error('EPROTO', 'Unexpected file bridge reply')
|
|
if (!response.ok) throw error(response.error?.code || 'EACCES', response.error?.message || 'Workspace read denied')
|
|
return response.value
|
|
}
|
|
function claim() {
|
|
if (selected) throw error('EBUSY', 'Choose one bridge client and mode per process; stdin belongs to that client')
|
|
selected = true
|
|
}
|
|
function decode(part, initial, offset) {
|
|
if (!part || part.snapshotId !== initial.snapshotId || part.sha256 !== initial.sha256 || part.size !== initial.size || part.offset !== offset || typeof part.data !== 'string') throw error('EPROTO', 'Inconsistent read snapshot')
|
|
const bytes = Buffer.from(part.data, 'base64')
|
|
if (bytes.length > 16384 || !bytes.length && offset < initial.size || offset + bytes.length > initial.size || part.eof !== (offset + bytes.length === initial.size)) throw error('EPROTO', 'Invalid read chunk')
|
|
return bytes
|
|
}
|
|
function complete(chunks, initial, encoding) {
|
|
const bytes = Buffer.concat(chunks)
|
|
if (bytes.length !== initial.size || createHash('sha256').update(bytes).digest('hex') !== initial.sha256) throw error('EPROTO', 'Read snapshot digest mismatch')
|
|
return encoding ? bytes.toString(encoding) : bytes
|
|
}
|
|
function connectSync() {
|
|
claim()
|
|
let buffered = Buffer.alloc(0), closed = false
|
|
function call(operation, args, id = randomUUID()) {
|
|
if (closed) throw error('EPIPE', 'File bridge client closed')
|
|
send(operation, args, id)
|
|
for (;;) {
|
|
const end = buffered.indexOf(10)
|
|
if (end >= 0) {
|
|
const line = buffered.subarray(0, end).toString('utf8'); buffered = buffered.subarray(end + 1)
|
|
return unpack(line, id)
|
|
}
|
|
if (buffered.length > 32768) throw error('EPROTO', 'File bridge reply exceeds limit')
|
|
const bytes = Buffer.alloc(32768), length = fs.readSync(0, bytes, 0, bytes.length, null)
|
|
if (!length) { closed = true; throw error('EPIPE', 'File bridge disconnected') }
|
|
buffered = Buffer.concat([buffered, bytes.subarray(0, length)])
|
|
}
|
|
}
|
|
return {
|
|
call,
|
|
readFileSync(path, encoding) {
|
|
const initial = call('workspace.readFile', { path }), chunks = []
|
|
try {
|
|
let part = initial, offset = 0
|
|
for (;;) {
|
|
const bytes = decode(part, initial, offset); chunks.push(bytes); offset += bytes.length
|
|
if (part.eof) return complete(chunks, initial, encoding)
|
|
part = call('workspace.readChunk', { snapshotId: initial.snapshotId, offset })
|
|
}
|
|
} finally { call('workspace.closeRead', { snapshotId: initial.snapshotId }) }
|
|
},
|
|
statSync: path => call('workspace.stat', { path }),
|
|
readdirSync: path => call('workspace.readdir', { path }).entries,
|
|
querySync: requestId => call('operation.query', { requestId }),
|
|
close() { closed = true }
|
|
}
|
|
}
|
|
function connect() {
|
|
claim()
|
|
const waiting = new Map(), queue = []
|
|
let buffered = Buffer.alloc(0), closed = false, failure
|
|
function shutdown(reason) {
|
|
if (closed) return
|
|
closed = true; failure = reason
|
|
process.stdin.off('data', receive); process.stdin.off('end', ended); process.stdin.off('error', failed); process.stdin.pause()
|
|
process.stdin.destroy()
|
|
for (const item of [...waiting.values(), ...queue]) item.reject(reason)
|
|
waiting.clear(); queue.length = 0
|
|
}
|
|
function flush() {
|
|
while (!closed && waiting.size < 8 && queue.length) {
|
|
const item = queue.shift(); waiting.set(item.id, item)
|
|
try { send(item.operation, item.args, item.id) } catch (reason) { shutdown(reason) }
|
|
}
|
|
if (waiting.size) { process.stdin.ref?.(); process.stdin.resume() }
|
|
else { process.stdin.pause(); process.stdin.unref?.() }
|
|
}
|
|
function receive(bytes) {
|
|
buffered = Buffer.concat([buffered, bytes])
|
|
for (;;) {
|
|
const end = buffered.indexOf(10)
|
|
if (end < 0) { if (buffered.length > 32768) shutdown(error('EPROTO', 'File bridge reply exceeds limit')); return }
|
|
const line = buffered.subarray(0, end).toString('utf8'); buffered = buffered.subarray(end + 1)
|
|
let id
|
|
try { id = JSON.parse(line).requestId } catch { shutdown(error('EPROTO', 'Invalid file bridge reply')); return }
|
|
const item = waiting.get(id)
|
|
if (!item) { shutdown(error('EPROTO', 'Unexpected file bridge reply')); return }
|
|
waiting.delete(id)
|
|
try { item.resolve(unpack(line, id)) } catch (reason) { item.reject(reason) }
|
|
flush()
|
|
}
|
|
}
|
|
const ended = () => shutdown(error('EPIPE', 'File bridge disconnected'))
|
|
const failed = reason => shutdown(reason)
|
|
process.stdin.on('data', receive); process.stdin.on('end', ended); process.stdin.on('error', failed); process.stdin.pause()
|
|
function call(operation, args, id = randomUUID()) {
|
|
if (closed) return Promise.reject(failure || error('EPIPE', 'File bridge client closed'))
|
|
if (queue.length + waiting.size >= 32) return Promise.reject(error('EBUSY', 'File bridge client queue full'))
|
|
return new Promise((resolve, reject) => { queue.push({ id, operation, args, resolve, reject }); flush() })
|
|
}
|
|
return {
|
|
call,
|
|
async readFile(path, encoding) {
|
|
const initial = await call('workspace.readFile', { path }), chunks = []
|
|
try {
|
|
let part = initial, offset = 0
|
|
for (;;) {
|
|
const bytes = decode(part, initial, offset); chunks.push(bytes); offset += bytes.length
|
|
if (part.eof) return complete(chunks, initial, encoding)
|
|
part = await call('workspace.readChunk', { snapshotId: initial.snapshotId, offset })
|
|
}
|
|
} finally { await call('workspace.closeRead', { snapshotId: initial.snapshotId }) }
|
|
},
|
|
stat: path => call('workspace.stat', { path }),
|
|
readdir: async path => (await call('workspace.readdir', { path })).entries,
|
|
query: requestId => call('operation.query', { requestId }),
|
|
close() { shutdown(error('EPIPE', 'File bridge client closed')) }
|
|
}
|
|
}
|
|
module.exports = { connect, connectSync }
|