feat(lsp): LSP capability seam, generic stdio provider, and lsp tool
Implements the LSP capability seam RFC as three packages: dsh-lsp (the ctx.lsp interface — provider registry by branded id + exclusive extension mapping, per-query order-independent selection, closed request/result vocabulary, LspError taxonomy), dsh-lsp-local (a generic stdio language-server provider — Content-Length JSON-RPC framing, per-(provider, workspace) process single-flight, transient didOpen/query/didClose, an abortable per-instance queue, UTF-16 negotiation, host-namespace source reads outside ctx.fs, and bounded shutdown/kill teardown), and dsh-tool-lsp (the model-facing lsp tool — four operations, one-based UTF-16 cursor conversion, workspace-grouped location rendering, hover capping, a required session workspace, and a timeout budget). Why: an agent had text search and file reads but no way to identify a program symbol — follow an alias, connect an interface to implementations, or read an inferred type — before changing code. Splitting model contract, seam, and local subprocess behavior keeps the four semantic queries stable across future remote or sandbox-native providers without leaking a JSON-RPC escape hatch.
This commit is contained in:
99
packages/lsp/lsp-local/src/framing.ts
Normal file
99
packages/lsp/lsp-local/src/framing.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* LSP base-protocol framing: `Content-Length`-delimited JSON-RPC over a byte stream. The encoder
|
||||
* produces one framed buffer; the decoder buffers incoming bytes and yields complete message bodies,
|
||||
* bounding the header and total message size so a hostile or broken server cannot exhaust memory.
|
||||
* @module @deepseek-ai/dsh-lsp-local/framing
|
||||
*/
|
||||
|
||||
/** The header/body separator in the LSP base protocol. */
|
||||
const HEADER_SEPARATOR = '\r\n\r\n'
|
||||
|
||||
/** Cap on the header section so a server that never sends the separator cannot grow the buffer forever. */
|
||||
const MAX_HEADER_BYTES = 1 << 16
|
||||
|
||||
/**
|
||||
* Encode one JSON-RPC message as a framed LSP buffer (`Content-Length: N\r\n\r\n<utf-8 json>`).
|
||||
* @param message - the JSON-RPC message object to serialize.
|
||||
* @returns the framed bytes ready to write to the server's stdin.
|
||||
*/
|
||||
export function encodeMessage(message: unknown): Buffer {
|
||||
const body = Buffer.from(JSON.stringify(message), 'utf8')
|
||||
const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii')
|
||||
return Buffer.concat([header, body])
|
||||
}
|
||||
|
||||
/**
|
||||
* A streaming decoder for `Content-Length`-framed JSON-RPC. Feed it stdout chunks; it returns any
|
||||
* whole message bodies that completed. It parses only the `Content-Length` header and ignores other
|
||||
* headers (e.g. `Content-Type`), matching the base protocol.
|
||||
*/
|
||||
export class MessageDecoder {
|
||||
private buffer: Buffer = Buffer.alloc(0)
|
||||
private readonly maxMessageBytes: number
|
||||
|
||||
/**
|
||||
* @param maxMessageBytes - reject any single framed body larger than this (guards memory).
|
||||
*/
|
||||
constructor(maxMessageBytes: number) {
|
||||
this.maxMessageBytes = maxMessageBytes
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a chunk and return every message body that is now complete.
|
||||
* @param chunk - raw bytes from the server's stdout.
|
||||
* @returns the parsed JSON bodies, in arrival order (possibly empty).
|
||||
* @throws Error when a header is malformed or a body exceeds `maxMessageBytes`.
|
||||
*/
|
||||
push(chunk: Buffer): unknown[] {
|
||||
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk])
|
||||
const messages: unknown[] = []
|
||||
for (;;) {
|
||||
const step = this.next()
|
||||
if (!step.ready) break
|
||||
messages.push(step.message)
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
/** Parse and consume the next complete message, or report that more bytes are needed. */
|
||||
private next(): { ready: false } | { ready: true; message: unknown } {
|
||||
const separator = this.buffer.indexOf(HEADER_SEPARATOR)
|
||||
if (separator < 0) {
|
||||
if (this.buffer.length > MAX_HEADER_BYTES) {
|
||||
throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`)
|
||||
}
|
||||
return { ready: false }
|
||||
}
|
||||
const headerText = this.buffer.toString('ascii', 0, separator)
|
||||
const contentLength = parseContentLength(headerText)
|
||||
if (contentLength > this.maxMessageBytes) {
|
||||
throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`)
|
||||
}
|
||||
const bodyStart = separator + HEADER_SEPARATOR.length
|
||||
const bodyEnd = bodyStart + contentLength
|
||||
if (this.buffer.length < bodyEnd) return { ready: false }
|
||||
const body = this.buffer.toString('utf8', bodyStart, bodyEnd)
|
||||
this.buffer = this.buffer.subarray(bodyEnd)
|
||||
try {
|
||||
return { ready: true, message: JSON.parse(body) }
|
||||
} catch (error) {
|
||||
/* v8 ignore next -- JSON.parse throws a SyntaxError (an Error); the String() fallback is defensive. */
|
||||
throw new Error(`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the `Content-Length` header value (case-insensitive), rejecting a missing or non-numeric one. */
|
||||
function parseContentLength(headerText: string): number {
|
||||
for (const line of headerText.split('\r\n')) {
|
||||
const colon = line.indexOf(':')
|
||||
if (colon < 0) continue
|
||||
if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue
|
||||
const value = Number(line.slice(colon + 1).trim())
|
||||
if (!Number.isInteger(value) || value < 0) {
|
||||
throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`)
|
||||
}
|
||||
Reference in New Issue
Block a user