@banbolee/dsh-lsp-diagnostics 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +297 -0
- package/collector.js +220 -0
- package/coordinator.js +686 -0
- package/cordis.patch.yml +4 -0
- package/framing.js +170 -0
- package/index.js +581 -0
- package/package.json +70 -0
- package/render.js +468 -0
- package/runtime.js +1613 -0
- package/tool.js +496 -0
package/framing.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-Length JSON-RPC framing for the diagnostics runtime.
|
|
3
|
+
*
|
|
4
|
+
* The encoder produces one framed buffer per message; the decoder buffers
|
|
5
|
+
* incoming bytes and yields complete parsed message bodies. Bounds are fixed
|
|
6
|
+
* protocol invariants: one header block (header text plus the CRLFCRLF
|
|
7
|
+
* terminator) is capped at `MAX_HEADER_BYTES`, enforced identically whether
|
|
8
|
+
* the block arrives whole or split across chunks; the declared body length is
|
|
9
|
+
* capped at the decoder's `maxMessageBytes`; and the retained undecoded
|
|
10
|
+
* buffer can never exceed the header cap plus the body cap because a full
|
|
11
|
+
* separator and a bounded declared length are both required before any body
|
|
12
|
+
* is buffered. Any violation — oversized header, oversized or missing
|
|
13
|
+
* Content-Length, malformed JSON — throws and is a fatal transport failure for
|
|
14
|
+
* the consuming session.
|
|
15
|
+
*
|
|
16
|
+
* @module @banbolee/dsh-lsp-diagnostics/framing
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/** The fixed cap on one header block (text plus CRLFCRLF), in bytes. */
|
|
20
|
+
export const MAX_HEADER_BYTES = 8192
|
|
21
|
+
|
|
22
|
+
/** The header/body separator of the LSP base protocol. */
|
|
23
|
+
const HEADER_SEPARATOR = '\r\n\r\n'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Encode a JSON-RPC message into a Content-Length framed buffer.
|
|
27
|
+
* @param {unknown} message - the JSON-RPC message to serialize.
|
|
28
|
+
* @returns {Buffer} the framed bytes ready to write to the server's stdin.
|
|
29
|
+
*/
|
|
30
|
+
export function encodeMessage(message) {
|
|
31
|
+
const body = Buffer.from(JSON.stringify(message), 'utf8')
|
|
32
|
+
const header = Buffer.from(`Content-Length: ${body.length}\r\n\r\n`, 'ascii')
|
|
33
|
+
return Buffer.concat([header, body])
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A streaming decoder for `Content-Length`-framed JSON-RPC. Feed it stdout
|
|
38
|
+
* chunks; it returns every whole message body that completed. It parses only
|
|
39
|
+
* the `Content-Length` header (case-insensitive) and ignores other headers.
|
|
40
|
+
*/
|
|
41
|
+
export class MessageDecoder {
|
|
42
|
+
/**
|
|
43
|
+
* @param {number} maxMessageBytes - hard cap on a single declared body length.
|
|
44
|
+
*/
|
|
45
|
+
constructor(maxMessageBytes) {
|
|
46
|
+
/** @type {Buffer} */
|
|
47
|
+
this.buffer = Buffer.alloc(0)
|
|
48
|
+
/** @type {number} */
|
|
49
|
+
this.maxMessageBytes = maxMessageBytes
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Append a chunk and return every message body that is now complete.
|
|
54
|
+
* @param {Buffer} chunk - raw bytes from the server's stdout.
|
|
55
|
+
* @returns {unknown[]} the parsed JSON bodies, in arrival order (possibly empty).
|
|
56
|
+
* @throws {Error} when a header is malformed, oversized, or a body exceeds bounds.
|
|
57
|
+
*/
|
|
58
|
+
push(chunk) {
|
|
59
|
+
this.buffer = this.buffer.length === 0 ? chunk : Buffer.concat([this.buffer, chunk])
|
|
60
|
+
/** @type {unknown[]} */
|
|
61
|
+
const messages = []
|
|
62
|
+
try {
|
|
63
|
+
for (;;) {
|
|
64
|
+
const step = this.next()
|
|
65
|
+
if (!step.ready) break
|
|
66
|
+
messages.push(step.message)
|
|
67
|
+
}
|
|
68
|
+
} catch (error) {
|
|
69
|
+
this.buffer = Buffer.alloc(0)
|
|
70
|
+
throw error
|
|
71
|
+
}
|
|
72
|
+
return messages
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse and consume the next complete message, or report that more bytes are needed.
|
|
77
|
+
* @returns {{ ready: false } | { ready: true, message: unknown }}
|
|
78
|
+
*/
|
|
79
|
+
next() {
|
|
80
|
+
const separator = this.buffer.indexOf(HEADER_SEPARATOR)
|
|
81
|
+
if (separator < 0) {
|
|
82
|
+
if (this.buffer.length > MAX_HEADER_BYTES) {
|
|
83
|
+
throw new Error(`LSP header exceeded ${MAX_HEADER_BYTES} bytes without a terminator`)
|
|
84
|
+
}
|
|
85
|
+
return { ready: false }
|
|
86
|
+
}
|
|
87
|
+
// The cap covers the whole header block: header text plus the CRLFCRLF
|
|
88
|
+
// terminator. The separator offset points at the start of the terminator,
|
|
89
|
+
// so the block is `separator + HEADER_SEPARATOR.length` bytes. Checking
|
|
90
|
+
// the offset alone would accept a 8192-byte text plus a 4-byte terminator
|
|
91
|
+
// when the frame arrives whole but reject the identical header when it is
|
|
92
|
+
// split after the 8192 header bytes; comparing the full block keeps both
|
|
93
|
+
// chunkings identical.
|
|
94
|
+
if (separator + HEADER_SEPARATOR.length > MAX_HEADER_BYTES) {
|
|
95
|
+
throw new Error(`LSP header block exceeded ${MAX_HEADER_BYTES} bytes`)
|
|
96
|
+
}
|
|
97
|
+
const headerText = this.buffer.toString('ascii', 0, separator)
|
|
98
|
+
const contentLength = parseContentLength(headerText)
|
|
99
|
+
if (contentLength > this.maxMessageBytes) {
|
|
100
|
+
throw new Error(`LSP message length ${contentLength} exceeds the ${this.maxMessageBytes}-byte limit`)
|
|
101
|
+
}
|
|
102
|
+
const bodyStart = separator + HEADER_SEPARATOR.length
|
|
103
|
+
const bodyEnd = bodyStart + contentLength
|
|
104
|
+
if (this.buffer.length < bodyEnd) {
|
|
105
|
+
return { ready: false }
|
|
106
|
+
}
|
|
107
|
+
const bodyBytes = this.buffer.subarray(bodyStart, bodyEnd)
|
|
108
|
+
// Copy the remaining tail into a fresh exact-size buffer: a subarray would
|
|
109
|
+
// keep sharing the (potentially multi-megabyte) chunk's ArrayBuffer, and
|
|
110
|
+
// the shared pool slab is still larger than the protocol bound. The
|
|
111
|
+
// retained buffer is therefore physically bounded by its own logical
|
|
112
|
+
// length (<= MAX_HEADER_BYTES + maxMessageBytes by construction) and never
|
|
113
|
+
// leaks the backing store of a large coalesced chunk.
|
|
114
|
+
const remaining = this.buffer.length - bodyEnd
|
|
115
|
+
if (remaining > 0) {
|
|
116
|
+
const rest = Buffer.allocUnsafeSlow(remaining)
|
|
117
|
+
this.buffer.copy(rest, 0, bodyEnd)
|
|
118
|
+
this.buffer = rest
|
|
119
|
+
} else {
|
|
120
|
+
this.buffer = Buffer.alloc(0)
|
|
121
|
+
}
|
|
122
|
+
let body
|
|
123
|
+
try {
|
|
124
|
+
body = new TextDecoder('utf-8', { fatal: true }).decode(bodyBytes)
|
|
125
|
+
} catch (error) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`LSP message body was not valid UTF-8: ${error instanceof Error ? error.message : String(error)}`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
try {
|
|
131
|
+
return { ready: true, message: JSON.parse(body) }
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new Error(
|
|
134
|
+
`LSP message body was not valid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
135
|
+
)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Read the `Content-Length` header value (case-insensitive), rejecting a
|
|
142
|
+
* missing or non-numeric one.
|
|
143
|
+
* @param {string} headerText - the header block before the terminator.
|
|
144
|
+
* @returns {number} the declared body length in bytes.
|
|
145
|
+
*/
|
|
146
|
+
function parseContentLength(headerText) {
|
|
147
|
+
/** @type {number | undefined} */
|
|
148
|
+
let contentLength
|
|
149
|
+
for (const line of headerText.split('\r\n')) {
|
|
150
|
+
const colon = line.indexOf(':')
|
|
151
|
+
if (colon < 0) continue
|
|
152
|
+
if (line.slice(0, colon).trim().toLowerCase() !== 'content-length') continue
|
|
153
|
+
if (contentLength !== undefined) {
|
|
154
|
+
throw new Error('LSP header block contains duplicate Content-Length fields')
|
|
155
|
+
}
|
|
156
|
+
const rawValue = line.slice(colon + 1).trim()
|
|
157
|
+
if (!/^[0-9]+$/.test(rawValue)) {
|
|
158
|
+
throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`)
|
|
159
|
+
}
|
|
160
|
+
const value = Number(rawValue)
|
|
161
|
+
if (!Number.isSafeInteger(value)) {
|
|
162
|
+
throw new Error(`invalid Content-Length header: ${JSON.stringify(line)}`)
|
|
163
|
+
}
|
|
164
|
+
contentLength = value
|
|
165
|
+
}
|
|
166
|
+
if (contentLength === undefined) {
|
|
167
|
+
throw new Error(`LSP header block missing Content-Length: ${JSON.stringify(headerText)}`)
|
|
168
|
+
}
|
|
169
|
+
return contentLength
|
|
170
|
+
}
|