@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/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
+ }