@sagentlab/navarch-runtime 0.1.112 → 0.1.117
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 +56 -18
- package/bin/muse-mcp-bridge.cjs +3 -0
- package/dist/adapters/index.cjs +6 -1
- package/dist/adapters/muse.cjs +433 -0
- package/dist/agent-auth.cjs +2 -1
- package/dist/cli.cjs +4 -1
- package/dist/config.cjs +9 -1
- package/dist/conversation/capability.cjs +1 -1
- package/dist/conversation/loop.cjs +16 -0
- package/dist/conversation/mirror.cjs +225 -0
- package/dist/conversation/workspace.cjs +11 -4
- package/dist/exit-conditions.cjs +1 -1
- package/dist/muse-mcp-bridge.cjs +167 -0
- package/dist/session.cjs +30 -8
- package/dist/worktree-guard.cjs +137 -3
- package/node_modules/@muse-code/sdk/LICENSE +21 -0
- package/node_modules/@muse-code/sdk/README.md +88 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/connection.d.ts +149 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/connection.js +581 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/spawn.d.ts +188 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/spawn.js +496 -0
- package/node_modules/@muse-code/sdk/dist/src/errors.d.ts +71 -0
- package/node_modules/@muse-code/sdk/dist/src/errors.js +76 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/approval.d.ts +96 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/approval.js +293 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/client.d.ts +171 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/client.js +442 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/discarded.d.ts +36 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/discarded.js +36 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.d.ts +80 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.js +388 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-death.d.ts +139 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-death.js +96 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-start.d.ts +62 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-start.js +81 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/session.d.ts +309 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/session.js +654 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.d.ts +156 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.js +271 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.d.ts +72 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.js +183 -0
- package/node_modules/@muse-code/sdk/dist/src/fingerprint.d.ts +37 -0
- package/node_modules/@muse-code/sdk/dist/src/fingerprint.js +54 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/item-store.d.ts +110 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/item-store.js +169 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.d.ts +536 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.js +427 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/state-store.d.ts +68 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/state-store.js +89 -0
- package/node_modules/@muse-code/sdk/dist/src/index.d.ts +65 -0
- package/node_modules/@muse-code/sdk/dist/src/index.js +57 -0
- package/node_modules/@muse-code/sdk/dist/src/msp.d.ts +3380 -0
- package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.d.ts +361 -0
- package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.js +468 -0
- package/node_modules/@muse-code/sdk/package.json +47 -0
- package/package.json +8 -4
|
@@ -0,0 +1,581 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duplex-agnostic MSP NDJSON connection (spec 14990 FR-012/014/015/016).
|
|
3
|
+
*
|
|
4
|
+
* The transport supplies decoded UTF-8 chunks. Chunks may be empty and may
|
|
5
|
+
* split anywhere — including mid surrogate pair: the byte accounting below
|
|
6
|
+
* reconciles split pairs (FR-012(a)), so transports need not align chunks
|
|
7
|
+
* to character boundaries. The Node child-process binding — which sets
|
|
8
|
+
* stdout's encoding before implementing this interface — is one
|
|
9
|
+
* well-behaved transport, not the general guarantee.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
const DEFAULT_FRAME_LIMIT_BYTES = 10_485_760;
|
|
13
|
+
/**
|
|
14
|
+
* Module-private friend seam (never on the package barrel, like spawn.ts's
|
|
15
|
+
* `ownedTransport`): the submission tail a process-owning transport adopts as
|
|
16
|
+
* its DEFAULT `flushed`, so a close routed around this class — e.g. the
|
|
17
|
+
* public `child.close()` — gets the same step 0 (PR #22819 round 4).
|
|
18
|
+
*/
|
|
19
|
+
export const submissionTail = Symbol("Connection.submissionTail");
|
|
20
|
+
/**
|
|
21
|
+
* Module-private friend seam, like {@link submissionTail}: a request whose
|
|
22
|
+
* promise also yields the exact response frame it settled on, so a caller
|
|
23
|
+
* that refuses a successful result (the handshake's schema-fingerprint
|
|
24
|
+
* check) can put the whole refused frame on `ProtocolError.line`, the same
|
|
25
|
+
* value every other refusal carries (#50588 review).
|
|
26
|
+
*/
|
|
27
|
+
export const requestWithFrame = Symbol("Connection.requestWithFrame");
|
|
28
|
+
/** The one typed error family consumers branch on (INV-012). */
|
|
29
|
+
export class MspError extends Error {
|
|
30
|
+
code;
|
|
31
|
+
kind;
|
|
32
|
+
data;
|
|
33
|
+
retryable;
|
|
34
|
+
constructor(error) {
|
|
35
|
+
super(error.message);
|
|
36
|
+
this.name = "MspError";
|
|
37
|
+
this.code = error.code;
|
|
38
|
+
this.kind = error.data?.kind ?? "unknown";
|
|
39
|
+
this.data = error.data === undefined ? {} : { ...error.data };
|
|
40
|
+
this.retryable = error.data?.retryable;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** A local framing/correlation violation, never a server-authored MSP error. */
|
|
44
|
+
export class ProtocolError extends Error {
|
|
45
|
+
/**
|
|
46
|
+
* The inbound frame (wire line) the violation was detected on, when there
|
|
47
|
+
* is one — the whole line, e.g. the response a pending request refused;
|
|
48
|
+
* an oversized frame is cut to the frame limit. Absent for local state
|
|
49
|
+
* errors (closed connection, EOF) that no frame caused.
|
|
50
|
+
*/
|
|
51
|
+
line;
|
|
52
|
+
constructor(message, line) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.name = "ProtocolError";
|
|
55
|
+
this.line = line;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function requestKey(id) {
|
|
59
|
+
return `${typeof id === "number" ? "number" : "string"}:${String(id)}`;
|
|
60
|
+
}
|
|
61
|
+
function utf8ByteLength(text) {
|
|
62
|
+
let bytes = 0;
|
|
63
|
+
for (const scalar of text) {
|
|
64
|
+
const point = scalar.codePointAt(0);
|
|
65
|
+
bytes += point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
|
|
66
|
+
}
|
|
67
|
+
return bytes;
|
|
68
|
+
}
|
|
69
|
+
/** FM-007: error evidence is bounded in UTF-8 BYTES, never code units. */
|
|
70
|
+
function truncateToUtf8Bytes(text, limitBytes) {
|
|
71
|
+
let bytes = 0;
|
|
72
|
+
let end = 0;
|
|
73
|
+
for (const scalar of text) {
|
|
74
|
+
const point = scalar.codePointAt(0);
|
|
75
|
+
const width = point <= 0x7f ? 1 : point <= 0x7ff ? 2 : point <= 0xffff ? 3 : 4;
|
|
76
|
+
if (bytes + width > limitBytes)
|
|
77
|
+
break;
|
|
78
|
+
bytes += width;
|
|
79
|
+
end += scalar.length;
|
|
80
|
+
}
|
|
81
|
+
return text.slice(0, end);
|
|
82
|
+
}
|
|
83
|
+
function canonical(value) {
|
|
84
|
+
if (Array.isArray(value))
|
|
85
|
+
return `[${value.map(canonical).join(",")}]`;
|
|
86
|
+
if (value !== null && typeof value === "object") {
|
|
87
|
+
const object = value;
|
|
88
|
+
return `{${Object.keys(object)
|
|
89
|
+
.sort()
|
|
90
|
+
.map((key) => `${JSON.stringify(key)}:${canonical(object[key])}`)
|
|
91
|
+
.join(",")}}`;
|
|
92
|
+
}
|
|
93
|
+
return JSON.stringify(value);
|
|
94
|
+
}
|
|
95
|
+
// FR-45137-1: the dedup guards compare fingerprints, so a remembered command
|
|
96
|
+
// costs 64 hex chars no matter how large its payload (or ack) was. The hash
|
|
97
|
+
// guards a correctness check, so it is SHA-256, not a faster non-crypto hash
|
|
98
|
+
// whose collision would silently accept a different payload.
|
|
99
|
+
function fingerprint(canonicalForm) {
|
|
100
|
+
return createHash("sha256").update(canonicalForm, "utf8").digest("hex");
|
|
101
|
+
}
|
|
102
|
+
/** Create the SDK-owned UUIDv7 command-id source for a connection/client. */
|
|
103
|
+
export function createUuidV7Mint() {
|
|
104
|
+
let lastMs = -1;
|
|
105
|
+
let sequence = 0;
|
|
106
|
+
return () => {
|
|
107
|
+
let now = Date.now();
|
|
108
|
+
if (now <= lastMs) {
|
|
109
|
+
now = lastMs;
|
|
110
|
+
sequence += 1;
|
|
111
|
+
if (sequence > 0x0fff) {
|
|
112
|
+
now += 1;
|
|
113
|
+
sequence = 0;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
sequence = 0;
|
|
118
|
+
}
|
|
119
|
+
lastMs = now;
|
|
120
|
+
const bytes = new Uint8Array(16);
|
|
121
|
+
globalThis.crypto.getRandomValues(bytes);
|
|
122
|
+
let timestamp = now;
|
|
123
|
+
for (let index = 5; index >= 0; index -= 1) {
|
|
124
|
+
bytes[index] = timestamp & 0xff;
|
|
125
|
+
timestamp = Math.floor(timestamp / 256);
|
|
126
|
+
}
|
|
127
|
+
bytes[6] = 0x70 | ((sequence >> 8) & 0x0f);
|
|
128
|
+
bytes[7] = sequence & 0xff;
|
|
129
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
130
|
+
const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
131
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
async function defaultRetryDelay(attempt) {
|
|
135
|
+
const ceilingMs = Math.min(2_000, 50 * 2 ** Math.max(0, attempt - 1));
|
|
136
|
+
const delayMs = Math.floor(Math.random() * (ceilingMs + 1));
|
|
137
|
+
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
|
138
|
+
}
|
|
139
|
+
function isObject(value) {
|
|
140
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* One MSP connection over newline-delimited JSON.
|
|
144
|
+
*
|
|
145
|
+
* The connection sends your requests and commands, correlates their
|
|
146
|
+
* responses, enforces the frame-size limit, and hands inbound notifications
|
|
147
|
+
* and server requests to the handlers you register. It is transport-agnostic:
|
|
148
|
+
* pair it with any `DuplexTransport`. Most applications never build one
|
|
149
|
+
* directly — `MuseClient.spawn` or `spawnMspConnection` does it for you;
|
|
150
|
+
* reach for a bare `Connection` when you need the raw notification feed or
|
|
151
|
+
* your own transport.
|
|
152
|
+
*/
|
|
153
|
+
export class Connection {
|
|
154
|
+
#transport;
|
|
155
|
+
#frameLimitBytes;
|
|
156
|
+
#mintCommandId;
|
|
157
|
+
#mintRequestId;
|
|
158
|
+
#pending = new Map();
|
|
159
|
+
#commands = new Map();
|
|
160
|
+
#nextRequestId = 1;
|
|
161
|
+
#serverRequestHandler;
|
|
162
|
+
#notificationHandler;
|
|
163
|
+
#protocolErrorHandler;
|
|
164
|
+
#writeTail = Promise.resolve();
|
|
165
|
+
// Settles when every accepted frame has been HANDED to the transport, as
|
|
166
|
+
// opposed to #writeTail, which settles when the peer has taken it.
|
|
167
|
+
#submitTail = Promise.resolve();
|
|
168
|
+
#writeFailure;
|
|
169
|
+
#buffer = "";
|
|
170
|
+
#bufferBytes = 0; // FR-012: incremental accounting — never a full-buffer rescan per chunk
|
|
171
|
+
// FR-012: the last UTF-16 unit of #buffer (-1 when empty), maintained from each
|
|
172
|
+
// fresh chunk so the surrogate-pair reconciliation below never READS #buffer on
|
|
173
|
+
// the hot no-newline path — reading it (charCodeAt/indexOf/regex) flattens the
|
|
174
|
+
// V8 rope and copies the whole buffer per chunk, the O(bytes^2) this FR bans.
|
|
175
|
+
#bufferTailCode = -1;
|
|
176
|
+
#droppingOversized = false;
|
|
177
|
+
#finished = false;
|
|
178
|
+
#closedPromise;
|
|
179
|
+
#resolveClosed;
|
|
180
|
+
constructor(transport, options) {
|
|
181
|
+
this.#transport = transport;
|
|
182
|
+
this.#frameLimitBytes = options?.frameLimitBytes ?? DEFAULT_FRAME_LIMIT_BYTES;
|
|
183
|
+
this.#mintCommandId = options?.mintCommandId ?? createUuidV7Mint();
|
|
184
|
+
this.#mintRequestId = options?.mintRequestId ?? (() => {
|
|
185
|
+
const id = this.#nextRequestId;
|
|
186
|
+
this.#nextRequestId += 1;
|
|
187
|
+
return id;
|
|
188
|
+
});
|
|
189
|
+
this.#closedPromise = new Promise((resolve) => {
|
|
190
|
+
this.#resolveClosed = resolve;
|
|
191
|
+
});
|
|
192
|
+
void this.#readLoop();
|
|
193
|
+
}
|
|
194
|
+
get closed() {
|
|
195
|
+
return this.#closedPromise;
|
|
196
|
+
}
|
|
197
|
+
/** See {@link submissionTail}. */
|
|
198
|
+
[submissionTail]() {
|
|
199
|
+
return this.#submitTail;
|
|
200
|
+
}
|
|
201
|
+
request(method, params) {
|
|
202
|
+
return this.#request(method, params).then((framed) => framed.result);
|
|
203
|
+
}
|
|
204
|
+
/** See {@link requestWithFrame}. */
|
|
205
|
+
[requestWithFrame](method, params) {
|
|
206
|
+
return this.#request(method, params);
|
|
207
|
+
}
|
|
208
|
+
#request(method, params) {
|
|
209
|
+
if (this.#finished)
|
|
210
|
+
return Promise.reject(new ProtocolError("connection is closed"));
|
|
211
|
+
const id = this.#mintRequestId();
|
|
212
|
+
if (typeof id !== "string" && (typeof id !== "number" || !Number.isInteger(id))) {
|
|
213
|
+
return Promise.reject(new ProtocolError("request id must be a string or integer"));
|
|
214
|
+
}
|
|
215
|
+
if (this.#pending.has(requestKey(id))) {
|
|
216
|
+
return Promise.reject(new ProtocolError(`request id ${String(id)} is already in flight`));
|
|
217
|
+
}
|
|
218
|
+
const request = {
|
|
219
|
+
jsonrpc: "2.0",
|
|
220
|
+
id,
|
|
221
|
+
method,
|
|
222
|
+
...(params === undefined ? {} : { params }),
|
|
223
|
+
};
|
|
224
|
+
return new Promise((resolve, reject) => {
|
|
225
|
+
this.#pending.set(requestKey(id), {
|
|
226
|
+
resolve: (result, line) => resolve({ result, line }),
|
|
227
|
+
reject,
|
|
228
|
+
});
|
|
229
|
+
void this.#write(request).catch((error) => {
|
|
230
|
+
this.#pending.delete(requestKey(id));
|
|
231
|
+
reject(error);
|
|
232
|
+
});
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Take one id from THIS connection's single `commandId` minter (INV-013).
|
|
237
|
+
*
|
|
238
|
+
* Exposed for the callers that need the id BEFORE the ack: `sendUserTurn`
|
|
239
|
+
* records its optimistic SS4.13 entry under it, and an entry created only on
|
|
240
|
+
* the ack is invisible for exactly the window it exists to cover. The
|
|
241
|
+
* alternative was a facade-side mint, which is the second minter INV-013
|
|
242
|
+
* forbids — delegating here keeps the single-minter property intact and
|
|
243
|
+
* preserves the injected `mintCommandId` seam for deterministic transcripts.
|
|
244
|
+
*
|
|
245
|
+
* Pair it with `command(..., { commandId })`, which reuses the id instead of
|
|
246
|
+
* minting a second one.
|
|
247
|
+
*/
|
|
248
|
+
mintCommandId() {
|
|
249
|
+
return this.#mintCommandId();
|
|
250
|
+
}
|
|
251
|
+
notify(method, params) {
|
|
252
|
+
const notification = {
|
|
253
|
+
jsonrpc: "2.0",
|
|
254
|
+
method,
|
|
255
|
+
...(params === undefined ? {} : { params }),
|
|
256
|
+
};
|
|
257
|
+
void this.#write(notification).catch((error) => this.#finish(error));
|
|
258
|
+
}
|
|
259
|
+
onServerRequest(handler) {
|
|
260
|
+
this.#serverRequestHandler = handler;
|
|
261
|
+
}
|
|
262
|
+
onNotification(handler) {
|
|
263
|
+
this.#notificationHandler = handler;
|
|
264
|
+
}
|
|
265
|
+
onProtocolError(handler) {
|
|
266
|
+
this.#protocolErrorHandler = handler;
|
|
267
|
+
}
|
|
268
|
+
async flush() {
|
|
269
|
+
await this.#writeTail;
|
|
270
|
+
if (this.#writeFailure !== undefined)
|
|
271
|
+
throw this.#writeFailure;
|
|
272
|
+
}
|
|
273
|
+
async command(method, params, options) {
|
|
274
|
+
const commandId = options?.commandId ?? this.#mintCommandId();
|
|
275
|
+
const commandParams = { ...params, commandId };
|
|
276
|
+
const signature = fingerprint(canonical({ method, params: commandParams }));
|
|
277
|
+
const memory = this.#commands.get(commandId);
|
|
278
|
+
if (memory !== undefined && memory.signature !== signature) {
|
|
279
|
+
throw new ProtocolError(`commandId ${commandId} was reused with a different payload`);
|
|
280
|
+
}
|
|
281
|
+
const remembered = memory ?? { signature };
|
|
282
|
+
this.#commands.set(commandId, remembered);
|
|
283
|
+
const maxAttempts = Math.max(1, options?.maxAttempts ?? 3);
|
|
284
|
+
const retryDelay = options?.retryDelay ?? defaultRetryDelay;
|
|
285
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
286
|
+
try {
|
|
287
|
+
const ack = await this.request(method, commandParams);
|
|
288
|
+
// session/start's and session/resume's result schemas omit the
|
|
289
|
+
// redundant commandId echo (the two omitting families recorded in
|
|
290
|
+
// specs/14990-muse-sdk/contracts/sdk-surface.md; approval/decide
|
|
291
|
+
// DOES echo it). When a result carries it, preserve the strict echo
|
|
292
|
+
// check (TEST-018).
|
|
293
|
+
if (ack["commandId"] !== undefined && ack["commandId"] !== commandId) {
|
|
294
|
+
throw new ProtocolError(`${method} ack commandId ${String(ack["commandId"])} did not echo ${commandId}`);
|
|
295
|
+
}
|
|
296
|
+
const ackFingerprint = fingerprint(canonical(ack));
|
|
297
|
+
if (remembered.ack !== undefined && remembered.ack !== ackFingerprint) {
|
|
298
|
+
throw new ProtocolError(`${method} replay for commandId ${commandId} did not return a value-identical ack`);
|
|
299
|
+
}
|
|
300
|
+
remembered.ack = ackFingerprint;
|
|
301
|
+
return ack;
|
|
302
|
+
}
|
|
303
|
+
catch (error) {
|
|
304
|
+
const retryableNothingAdmitted = error instanceof MspError &&
|
|
305
|
+
((error.code === -32001 && error.kind === "overloaded") ||
|
|
306
|
+
(error.code === -32031 && error.kind === "backpressured"));
|
|
307
|
+
if (!retryableNothingAdmitted || attempt === maxAttempts)
|
|
308
|
+
throw error;
|
|
309
|
+
await retryDelay(attempt, error);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
throw new ProtocolError("unreachable command retry state");
|
|
313
|
+
}
|
|
314
|
+
async close() {
|
|
315
|
+
// COMPLETION must not gate teardown — that is the #15943 hang, since a
|
|
316
|
+
// peer that stopped reading never completes a write. SUBMISSION must,
|
|
317
|
+
// though: `#write` hands the frame to the transport on a later
|
|
318
|
+
// microtask, so ending the peer's input synchronously here dropped a
|
|
319
|
+
// just-accepted `notify()` and rejected close() with a raw
|
|
320
|
+
// ERR_STREAM_WRITE_AFTER_END on a HEALTHY peer (PR #22819 round 3).
|
|
321
|
+
// The submission promise goes TO the transport, which waits for it
|
|
322
|
+
// inside its own shutdown budget, so this class still knows nothing
|
|
323
|
+
// about processes, signals, or timeouts.
|
|
324
|
+
//
|
|
325
|
+
// The guarantee is SCOPED to transports that implement close() (PR
|
|
326
|
+
// #22819 round 4). A close-less transport has no budget to bound the
|
|
327
|
+
// wait with, and submission of frame N+1 only happens once frame N
|
|
328
|
+
// COMPLETES — exactly what a wedged peer withholds — so awaiting it here
|
|
329
|
+
// would hang teardown outright, where previously `#finish` at least
|
|
330
|
+
// rejected in-flight requests and settled `closed`. Finishing
|
|
331
|
+
// immediately is the honest behaviour for a transport that never told us
|
|
332
|
+
// how long it may take.
|
|
333
|
+
const tail = this.#writeTail;
|
|
334
|
+
if (this.#transport.close !== undefined) {
|
|
335
|
+
await this.#transport.close(this.#submitTail);
|
|
336
|
+
// Bounded by the transport's own shutdown: the peer is gone by now, so
|
|
337
|
+
// a pending write has errored and the tail records `#writeFailure`.
|
|
338
|
+
await tail;
|
|
339
|
+
}
|
|
340
|
+
else {
|
|
341
|
+
// Nothing here may wait on the peer: a stuck write's tail would never
|
|
342
|
+
// settle either. `#finish` has already rejected every in-flight
|
|
343
|
+
// request and settled `closed`; `flush()` still surfaces a later
|
|
344
|
+
// write failure.
|
|
345
|
+
this.#finish(new ProtocolError("connection closed"));
|
|
346
|
+
}
|
|
347
|
+
await this.#closedPromise;
|
|
348
|
+
}
|
|
349
|
+
async #write(frame) {
|
|
350
|
+
if (this.#finished)
|
|
351
|
+
throw new ProtocolError("connection is closed");
|
|
352
|
+
const line = `${JSON.stringify(frame)}\n`;
|
|
353
|
+
let markSubmitted;
|
|
354
|
+
const submitted = new Promise((resolve) => {
|
|
355
|
+
markSubmitted = resolve;
|
|
356
|
+
});
|
|
357
|
+
this.#submitTail = this.#submitTail.then(() => submitted);
|
|
358
|
+
const write = this.#writeTail.then(async () => {
|
|
359
|
+
try {
|
|
360
|
+
if (this.#finished)
|
|
361
|
+
throw new ProtocolError("connection is closed");
|
|
362
|
+
// Invoking write() IS submission: the transport's synchronous prefix
|
|
363
|
+
// has reached the underlying stream by the time the call returns.
|
|
364
|
+
const inFlight = this.#transport.write(line);
|
|
365
|
+
markSubmitted();
|
|
366
|
+
await inFlight;
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
// A frame that can never be submitted must not wedge close() either.
|
|
370
|
+
markSubmitted();
|
|
371
|
+
throw error;
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
this.#writeTail = write.catch((error) => {
|
|
375
|
+
this.#writeFailure = error;
|
|
376
|
+
this.#finish(error);
|
|
377
|
+
});
|
|
378
|
+
await write;
|
|
379
|
+
}
|
|
380
|
+
async #readLoop() {
|
|
381
|
+
try {
|
|
382
|
+
for await (const chunk of this.#transport.incoming)
|
|
383
|
+
this.#ingest(chunk);
|
|
384
|
+
if (!this.#droppingOversized && this.#buffer.length > 0) {
|
|
385
|
+
this.#protocolError(new ProtocolError("inbound frame ended without a newline", this.#buffer));
|
|
386
|
+
}
|
|
387
|
+
this.#finish(new ProtocolError("connection reached EOF"));
|
|
388
|
+
}
|
|
389
|
+
catch (error) {
|
|
390
|
+
this.#finish(error);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
#ingest(chunk) {
|
|
394
|
+
let remaining = chunk;
|
|
395
|
+
if (this.#droppingOversized) {
|
|
396
|
+
const newline = remaining.indexOf("\n");
|
|
397
|
+
if (newline < 0)
|
|
398
|
+
return;
|
|
399
|
+
this.#droppingOversized = false;
|
|
400
|
+
remaining = remaining.slice(newline + 1);
|
|
401
|
+
}
|
|
402
|
+
// FR-012: a chunk boundary can split a surrogate pair; the lone halves
|
|
403
|
+
// counted 3+3 UTF-8 bytes as they arrived, but the joined pair counts 4 at
|
|
404
|
+
// line-extract. Reconcile the +2 residue now, or legal frames accumulate
|
|
405
|
+
// drift until one false-trips the frame limit.
|
|
406
|
+
if (remaining.length === 0)
|
|
407
|
+
return;
|
|
408
|
+
const chunkHead = remaining.charCodeAt(0);
|
|
409
|
+
if (this.#bufferTailCode >= 0xd800 &&
|
|
410
|
+
this.#bufferTailCode <= 0xdbff &&
|
|
411
|
+
chunkHead >= 0xdc00 &&
|
|
412
|
+
chunkHead <= 0xdfff) {
|
|
413
|
+
this.#bufferBytes -= 2;
|
|
414
|
+
}
|
|
415
|
+
this.#buffer += remaining;
|
|
416
|
+
this.#bufferBytes += utf8ByteLength(remaining);
|
|
417
|
+
this.#bufferTailCode = remaining.charCodeAt(remaining.length - 1);
|
|
418
|
+
// FR-012: the retained buffer is newline-free on entry (the drain below runs
|
|
419
|
+
// to exhaustion), so only the fresh chunk can carry a newline. Gating the
|
|
420
|
+
// drain here keeps ingest O(bytes) instead of rescanning the whole buffer
|
|
421
|
+
// from offset 0 on every newline-less chunk (O(bytes^2)).
|
|
422
|
+
if (remaining.includes("\n")) {
|
|
423
|
+
while (true) {
|
|
424
|
+
const newline = this.#buffer.indexOf("\n");
|
|
425
|
+
if (newline < 0)
|
|
426
|
+
break;
|
|
427
|
+
const line = this.#buffer.slice(0, newline);
|
|
428
|
+
this.#buffer = this.#buffer.slice(newline + 1);
|
|
429
|
+
this.#bufferBytes -= utf8ByteLength(line) + 1;
|
|
430
|
+
this.#line(line);
|
|
431
|
+
}
|
|
432
|
+
// No post-drain tail refresh needed: the retained buffer's last unit IS
|
|
433
|
+
// the chunk's last unit (already recorded above) — or the chunk ended in
|
|
434
|
+
// "\n", which can never pass the high-surrogate check that is this
|
|
435
|
+
// field's only reader. Pinned by TEST-017's interleaved-chunk arm.
|
|
436
|
+
}
|
|
437
|
+
if (this.#bufferBytes > this.#frameLimitBytes) {
|
|
438
|
+
this.#protocolError(new ProtocolError(`inbound frame exceeds ${this.#frameLimitBytes} bytes`, truncateToUtf8Bytes(this.#buffer, this.#frameLimitBytes)));
|
|
439
|
+
this.#buffer = "";
|
|
440
|
+
this.#bufferBytes = 0;
|
|
441
|
+
this.#bufferTailCode = -1;
|
|
442
|
+
this.#droppingOversized = true;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
#line(raw) {
|
|
446
|
+
const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw;
|
|
447
|
+
if (line.trim().length === 0)
|
|
448
|
+
return;
|
|
449
|
+
if (utf8ByteLength(line) > this.#frameLimitBytes) {
|
|
450
|
+
this.#protocolError(new ProtocolError(`inbound frame exceeds ${this.#frameLimitBytes} bytes`, truncateToUtf8Bytes(line, this.#frameLimitBytes)));
|
|
451
|
+
return;
|
|
452
|
+
}
|
|
453
|
+
let parsed;
|
|
454
|
+
try {
|
|
455
|
+
parsed = JSON.parse(line);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
this.#protocolError(new ProtocolError("inbound frame is not valid JSON", line));
|
|
459
|
+
return;
|
|
460
|
+
}
|
|
461
|
+
if (!isObject(parsed) || parsed["jsonrpc"] !== "2.0") {
|
|
462
|
+
this.#protocolError(new ProtocolError("inbound frame is not a JSON-RPC 2.0 object", line));
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
const method = parsed["method"];
|
|
466
|
+
const id = parsed["id"];
|
|
467
|
+
if (typeof method === "string") {
|
|
468
|
+
if (typeof id === "string" || typeof id === "number") {
|
|
469
|
+
if (typeof id !== "number" || !Number.isInteger(id) || id <= 0) {
|
|
470
|
+
this.#protocolError(new ProtocolError("server request id must be a positive integer", line));
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
void this.#serverRequest(parsed).catch((error) => {
|
|
474
|
+
this.#finish(error);
|
|
475
|
+
});
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
this.#notificationHandler?.(parsed);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (typeof id !== "string" && typeof id !== "number") {
|
|
482
|
+
this.#protocolError(new ProtocolError("response has no usable id", line));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
this.#response(parsed, id, line);
|
|
486
|
+
}
|
|
487
|
+
#response(frame, id, line) {
|
|
488
|
+
const pending = this.#pending.get(requestKey(id));
|
|
489
|
+
if (pending === undefined) {
|
|
490
|
+
this.#protocolError(new ProtocolError(`response for unknown request id ${String(id)}`, line));
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const hasResult = Object.hasOwn(frame, "result");
|
|
494
|
+
const hasError = Object.hasOwn(frame, "error");
|
|
495
|
+
if (hasResult === hasError) {
|
|
496
|
+
// FR-014: the MATCHED caller must settle — with no local timeout
|
|
497
|
+
// (INV-006) an unrejected caller would hang until EOF.
|
|
498
|
+
const violation = new ProtocolError("response must carry exactly one of result or error", line);
|
|
499
|
+
this.#pending.delete(requestKey(id));
|
|
500
|
+
pending.reject(violation);
|
|
501
|
+
this.#protocolError(violation);
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
this.#pending.delete(requestKey(id));
|
|
505
|
+
if (hasResult) {
|
|
506
|
+
const result = frame["result"];
|
|
507
|
+
if (!isObject(result)) {
|
|
508
|
+
pending.reject(new ProtocolError("response result must be an object", line));
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
pending.resolve(result, line);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
const error = frame["error"];
|
|
515
|
+
if (!isObject(error) ||
|
|
516
|
+
typeof error["code"] !== "number" ||
|
|
517
|
+
!Number.isInteger(error["code"]) ||
|
|
518
|
+
typeof error["message"] !== "string") {
|
|
519
|
+
pending.reject(new ProtocolError("error response has an invalid error object", line));
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
const data = error["data"];
|
|
523
|
+
if (!isObject(data) || typeof data["kind"] !== "string") {
|
|
524
|
+
pending.reject(new ProtocolError("MSP error response has no data.kind", line));
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
pending.reject(new MspError({
|
|
528
|
+
code: error["code"],
|
|
529
|
+
message: error["message"],
|
|
530
|
+
data: data,
|
|
531
|
+
}));
|
|
532
|
+
}
|
|
533
|
+
async #serverRequest(request) {
|
|
534
|
+
const id = request.id;
|
|
535
|
+
try {
|
|
536
|
+
if (this.#serverRequestHandler === undefined) {
|
|
537
|
+
throw new MspError({
|
|
538
|
+
code: -32601,
|
|
539
|
+
message: `method not found: ${request.method}`,
|
|
540
|
+
data: { kind: "methodNotFound", retryable: false },
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
const result = await this.#serverRequestHandler(request);
|
|
544
|
+
// FM-009: a failed reply write finishes the connection; it must not
|
|
545
|
+
// escape this fire-and-forget task as an unhandled rejection.
|
|
546
|
+
await this.#write({ jsonrpc: "2.0", id, result }).catch((writeError) => this.#finish(writeError));
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
const typed = error instanceof MspError
|
|
550
|
+
? error
|
|
551
|
+
: new MspError({
|
|
552
|
+
code: -32603,
|
|
553
|
+
message: error instanceof Error ? error.message : "server request handler failed",
|
|
554
|
+
data: { kind: "internal" },
|
|
555
|
+
});
|
|
556
|
+
await this.#write({
|
|
557
|
+
jsonrpc: "2.0",
|
|
558
|
+
id,
|
|
559
|
+
error: {
|
|
560
|
+
code: typed.code,
|
|
561
|
+
message: typed.message,
|
|
562
|
+
data: typed.data,
|
|
563
|
+
},
|
|
564
|
+
}).catch((writeError) => this.#finish(writeError)); // FM-009
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
#protocolError(error) {
|
|
568
|
+
this.#protocolErrorHandler?.(error);
|
|
569
|
+
}
|
|
570
|
+
#finish(reason) {
|
|
571
|
+
if (this.#finished)
|
|
572
|
+
return;
|
|
573
|
+
this.#finished = true;
|
|
574
|
+
const error = reason instanceof Error ? reason : new ProtocolError(String(reason));
|
|
575
|
+
for (const pending of this.#pending.values())
|
|
576
|
+
pending.reject(error);
|
|
577
|
+
this.#pending.clear();
|
|
578
|
+
this.#resolveClosed?.();
|
|
579
|
+
this.#resolveClosed = undefined;
|
|
580
|
+
}
|
|
581
|
+
}
|