@anureo/adapter-ahp 0.2.0 → 0.3.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/src/wire.js ADDED
@@ -0,0 +1,288 @@
1
+ /** Owns JSON-RPC correlation, bounded framing and connection cleanup for remote AHP peers. */
2
+ import { HarnessSdkError } from "@anureo/sdk";
3
+ import { spawnHarnessProcess } from "@anureo/sdk/process";
4
+ /**
5
+ * Exchange JSON-RPC requests and serialized notifications over one owned transport. AhpAdapter
6
+ * uses this for its handshake, session operations and replay, and validates the returned payloads.
7
+ * Direct consumers likewise own payload validation and must await close to observe cleanup failure.
8
+ */
9
+ export class AhpWire {
10
+ transport;
11
+ options;
12
+ /** Take ownership of the transport and start reading immediately. Install callbacks before sending requests. */
13
+ constructor(transport, options = {}) {
14
+ this.transport = transport;
15
+ this.options = options;
16
+ // Background cleanup rejects through the shared promise returned by close().
17
+ void this.read().catch(() => { });
18
+ }
19
+ /**
20
+ * Receives notifications serially without blocking reply reads. AhpAdapter.initializeConnection
21
+ * registers its notification router here so replay can await RPC replies. A throw or rejection
22
+ * starts cleanup. The queue looks up the current callback at delivery time and keeps processing
23
+ * frames already queued when close starts.
24
+ */
25
+ onNotification;
26
+ /**
27
+ * Invoked once with the first close cause, concurrently with transport cleanup.
28
+ * AhpAdapter.initializeConnection registers failConnection to fail session sinks and interaction
29
+ * waiters. A throw or rejection becomes a resource-release failure reported by close.
30
+ * The callback must return independently of close, which awaits its completion.
31
+ */
32
+ onClose;
33
+ /**
34
+ * Send a request and resolve with its correlated result. Callers validate the result schema.
35
+ * Serialization errors throw synchronously. Remote errors, malformed replies, admission limits,
36
+ * timeouts and connection loss reject the promise. The reply deadline includes sending. Timeouts
37
+ * and asynchronous send failures reject with outcomeUnknown and leave the wire open. A synchronous
38
+ * transport send exception rejects as supplied and leaves correlation until reply, timeout or close.
39
+ * Callers own retry decisions. Use close to stop waiting for outstanding replies.
40
+ */
41
+ request(method, params) {
42
+ if (this.closing)
43
+ return Promise.reject(connectionClosedError());
44
+ if (this.pending.size >= (this.options.maxPending ?? 256)) {
45
+ return Promise.reject(new HarnessSdkError("BACKPRESSURE_EXCEEDED"));
46
+ }
47
+ const id = this.nextId++;
48
+ const frame = JSON.stringify({ jsonrpc: "2.0", id, method, params });
49
+ if (exceedsFrameLimit(frame, this.options.maxFrameBytes)) {
50
+ return Promise.reject(new HarnessSdkError("FRAME_TOO_LARGE"));
51
+ }
52
+ return this.sendRequest({ id, method, frame });
53
+ }
54
+ /**
55
+ * Send a notification directly through transport.send, which controls admission and completion
56
+ * and receives the frame regardless of the wire's request and frame limits.
57
+ * Omitted or undefined params leave the field out. Serialization can throw synchronously and
58
+ * transport failures propagate as supplied.
59
+ * AhpAdapter.initializeConnection awaits this for initialized before reporting readiness.
60
+ */
61
+ notify(method, params) {
62
+ return this.transport.send(JSON.stringify({ jsonrpc: "2.0", method, ...(params === undefined ? {} : { params }) }));
63
+ }
64
+ /**
65
+ * Stop admitting requests and reject outstanding ones with RPC_CONNECTION_CLOSED immediately.
66
+ * Await onClose and transport.close concurrently. Repeated calls share the same promise, including
67
+ * after background failure. It settles when both release operations finish and rejects with
68
+ * RESOURCE_RELEASE_FAILED if either fails. The reader and notification queue run independently.
69
+ * AhpAdapter.dispose awaits this after the SDK has released its session handles.
70
+ */
71
+ close() {
72
+ return this.beginClose(new HarnessSdkError("RUNTIME_DISPOSED"));
73
+ }
74
+ nextId = 1;
75
+ pending = new Map();
76
+ closing = false;
77
+ closePromise;
78
+ notificationTail = Promise.resolve();
79
+ queuedNotificationCount = 0;
80
+ /**
81
+ * Register correlation and its deadline before dispatch so a fast reply can settle the request.
82
+ * Asynchronous send rejection retires any still-pending entry and reports an unknown outcome.
83
+ */
84
+ sendRequest({ id, method, frame }) {
85
+ return new Promise((resolve, reject) => {
86
+ const timer = setTimeout(() => {
87
+ this.pending.delete(id);
88
+ reject(new HarnessSdkError("RPC_TIMEOUT", "RPC_TIMEOUT", false, true, { method }));
89
+ }, this.options.timeoutMs ?? 30_000);
90
+ this.pending.set(id, { resolve, reject, timer });
91
+ void this.transport.send(frame).catch(() => {
92
+ this.takePending(id)?.reject(connectionClosedError());
93
+ });
94
+ });
95
+ }
96
+ /** Read until EOF or failure and initiate cleanup unless it has already started. */
97
+ async read() {
98
+ let failure = connectionClosedError();
99
+ try {
100
+ for await (const frame of this.transport.messages) {
101
+ if (this.closing)
102
+ break;
103
+ const reply = this.dispatchMessage(parseFrame(frame, this.options.maxFrameBytes));
104
+ // Only replies to peer requests block reads; notification handlers may themselves await RPCs.
105
+ if (reply)
106
+ await reply;
107
+ }
108
+ }
109
+ catch (cause) {
110
+ failure = cause;
111
+ }
112
+ if (!this.closing)
113
+ await this.beginClose(failure);
114
+ }
115
+ /** Return the method-not-found write for read to await. Correlation and notification admission run inline. */
116
+ dispatchMessage(message) {
117
+ if (typeof message.method !== "string") {
118
+ this.settleResponse(message);
119
+ return;
120
+ }
121
+ if (message.id !== undefined) {
122
+ return this.transport.send(JSON.stringify({
123
+ jsonrpc: "2.0",
124
+ id: message.id,
125
+ error: { code: -32601, message: "Method not found" },
126
+ }));
127
+ }
128
+ this.enqueueNotification(message.method, message.params);
129
+ }
130
+ /** Consume matching correlation once. A malformed reply rejects that caller with PROTOCOL_ERROR. */
131
+ settleResponse(message) {
132
+ if (typeof message.id !== "number")
133
+ return;
134
+ const pending = this.takePending(message.id);
135
+ if (!pending)
136
+ return;
137
+ if (message.error && typeof message.error === "object") {
138
+ pending.reject(remoteErrorToSdkError(message.error));
139
+ }
140
+ else if (Object.hasOwn(message, "result")) {
141
+ pending.resolve(message.result);
142
+ }
143
+ else {
144
+ pending.reject(new HarnessSdkError("PROTOCOL_ERROR"));
145
+ }
146
+ }
147
+ /** Remove live correlation and clear its deadline so a late response or send failure cannot settle it again. */
148
+ takePending(id) {
149
+ const pending = this.pending.get(id);
150
+ if (pending) {
151
+ clearTimeout(pending.timer);
152
+ this.pending.delete(id);
153
+ }
154
+ return pending;
155
+ }
156
+ /** Serialize callback delivery in frame order. Backlog overflow throws to read and starts cleanup. */
157
+ enqueueNotification(method, params) {
158
+ if (++this.queuedNotificationCount > (this.options.maxNotifications ?? 2048)) {
159
+ throw new HarnessSdkError("BACKPRESSURE_EXCEEDED");
160
+ }
161
+ this.notificationTail = this.notificationTail
162
+ .then(() => this.onNotification?.(method, params))
163
+ .catch(cause => this.beginClose(cause).catch(() => { }))
164
+ .finally(() => this.queuedNotificationCount--);
165
+ }
166
+ /** Retire pending requests immediately and cache first-cause cleanup before invoking callbacks. */
167
+ beginClose(cause) {
168
+ if (this.closePromise)
169
+ return this.closePromise;
170
+ this.closing = true;
171
+ for (const pending of this.pending.values()) {
172
+ clearTimeout(pending.timer);
173
+ pending.reject(connectionClosedError());
174
+ }
175
+ this.pending.clear();
176
+ this.closePromise = Promise.resolve().then(() => this.releaseConnection(cause));
177
+ return this.closePromise;
178
+ }
179
+ /** Run both releases and report their stable failure codes in RESOURCE_RELEASE_FAILED, without retaining private causes. */
180
+ async releaseConnection(cause) {
181
+ try {
182
+ this.options.diagnostic?.(cause instanceof HarnessSdkError ? cause.code : "PROTOCOL_ERROR");
183
+ }
184
+ catch {
185
+ // A diagnostic callback cannot prevent either release operation.
186
+ }
187
+ const [closeHandlerResult, transportCloseResult] = await Promise.allSettled([
188
+ Promise.resolve().then(() => this.onClose?.(cause)),
189
+ Promise.resolve().then(() => this.transport.close()),
190
+ ]);
191
+ const errors = [];
192
+ if (closeHandlerResult.status === "rejected")
193
+ errors.push({ code: "AHP_CLOSE_HANDLER_FAILED" });
194
+ if (transportCloseResult.status === "rejected")
195
+ errors.push({ code: "AHP_TRANSPORT_CLOSE_FAILED" });
196
+ if (errors.length) {
197
+ throw new HarnessSdkError("RESOURCE_RELEASE_FAILED", "AHP connection cleanup failed", false, true, { errors });
198
+ }
199
+ }
200
+ }
201
+ /**
202
+ * Acquire a process and expose newline-delimited UTF-8 frames as soon as process startup succeeds.
203
+ * Startup failures reject. Reads accept CRLF, skip empty lines and reject oversized frames or
204
+ * an unterminated nonblank tail. The transport drains stderr privately. Send appends a newline
205
+ * and completes with process.write. Close awaits process.dispose and follows its ownership policy.
206
+ * AhpAdapter.initializeConnection transfers the transport to AhpWire or closes it if disposal
207
+ * occurred during acquisition. Direct transport users own close, including after EOF.
208
+ */
209
+ export async function createStdioTransport(options) {
210
+ const process = await (options.processFactory ?? spawnHarnessProcess)(options);
211
+ const maxFrameBytes = options.maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES;
212
+ void drainStderr(process).catch(() => { });
213
+ return {
214
+ messages: readStdioFrames(process, maxFrameBytes),
215
+ send: message => process.write(message + "\n"),
216
+ close: () => process.dispose(),
217
+ };
218
+ }
219
+ const DEFAULT_MAX_FRAME_BYTES = 8 * 1024 * 1024;
220
+ /** A lost connection cannot establish whether the peer applied an outstanding operation. */
221
+ function connectionClosedError() {
222
+ return new HarnessSdkError("RPC_CONNECTION_CLOSED", "RPC_CONNECTION_CLOSED", false, true);
223
+ }
224
+ /** Measure the raw frame in UTF-8 bytes so non-ASCII text counts against the same budget. */
225
+ function exceedsFrameLimit(frame, maxFrameBytes) {
226
+ return new TextEncoder().encode(frame).byteLength > (maxFrameBytes ?? DEFAULT_MAX_FRAME_BYTES);
227
+ }
228
+ /** Decode a size-checked JSON-RPC 2.0 object. Throws FRAME_TOO_LARGE or PROTOCOL_ERROR for read to close the wire. */
229
+ function parseFrame(frame, maxFrameBytes) {
230
+ if (exceedsFrameLimit(frame, maxFrameBytes))
231
+ throw new HarnessSdkError("FRAME_TOO_LARGE");
232
+ let value;
233
+ try {
234
+ value = JSON.parse(frame);
235
+ }
236
+ catch {
237
+ throw new HarnessSdkError("PROTOCOL_ERROR");
238
+ }
239
+ if (!value || typeof value !== "object" || Array.isArray(value))
240
+ throw new HarnessSdkError("PROTOCOL_ERROR");
241
+ const message = value;
242
+ if (message.jsonrpc !== "2.0")
243
+ throw new HarnessSdkError("PROTOCOL_ERROR");
244
+ return message;
245
+ }
246
+ /** Preserve explicit remote outcome/retry metadata while discarding private remote error text. */
247
+ function remoteErrorToSdkError(error) {
248
+ const data = error.data && typeof error.data === "object" ? error.data : {};
249
+ let code = "AHP_RPC_ERROR";
250
+ if (typeof data.code === "string") {
251
+ code = data.code;
252
+ }
253
+ else if (error.code === -32601) {
254
+ code = "CAPABILITY_UNSUPPORTED";
255
+ }
256
+ const retryable = data.retryable === true;
257
+ const outcomeUnknown = data.outcomeUnknown === true;
258
+ return new HarnessSdkError(code, "AHP_RPC_ERROR", retryable, outcomeUnknown);
259
+ }
260
+ /** Keep stderr flowing without retaining private output. createStdioTransport ignores drain failure. */
261
+ async function drainStderr(process) {
262
+ for await (const _ of process.stderr) { }
263
+ }
264
+ /** Decode stdout incrementally and enforce the byte budget on complete and unfinished lines. */
265
+ async function* readStdioFrames(process, maxFrameBytes) {
266
+ const decoder = new TextDecoder();
267
+ let buffer = "";
268
+ for await (const chunk of process.stdout) {
269
+ buffer += decoder.decode(chunk, { stream: true });
270
+ for (;;) {
271
+ const newlineIndex = buffer.indexOf("\n");
272
+ if (newlineIndex < 0)
273
+ break;
274
+ const line = buffer.slice(0, newlineIndex).replace(/\r$/, "");
275
+ buffer = buffer.slice(newlineIndex + 1);
276
+ if (exceedsFrameLimit(line, maxFrameBytes))
277
+ throw new HarnessSdkError("FRAME_TOO_LARGE");
278
+ if (line)
279
+ yield line;
280
+ }
281
+ // Bound the unfinished frame even before its newline arrives.
282
+ if (exceedsFrameLimit(buffer, maxFrameBytes))
283
+ throw new HarnessSdkError("FRAME_TOO_LARGE");
284
+ }
285
+ buffer += decoder.decode();
286
+ if (buffer.trim())
287
+ throw new HarnessSdkError("PROTOCOL_ERROR", "Unterminated AHP frame");
288
+ }
package/dist/index.d.ts DELETED
@@ -1,31 +0,0 @@
1
- import { type HarnessAdapter, type BackendSession, type SessionEventSink } from "@anureo/sdk";
2
- import { type InitializeParams, type InitializeResult, type SessionOpenParams } from "@anureo/protocol";
3
- import { createStdioTransport, type AhpTransport, type WireOptions } from "./wire.js";
4
- export { AhpWire, createStdioTransport, type AhpTransport } from "./wire.js";
5
- export { createWebSocketTransport } from "./websocket.js";
6
- export interface AhpAdapterOptions extends WireOptions {
7
- transport?: AhpTransport | (() => Promise<AhpTransport>);
8
- command?: string;
9
- args?: string[];
10
- env?: Record<string, string | undefined>;
11
- cwd?: string;
12
- url?: string;
13
- connect?: (url: string) => Promise<AhpTransport>;
14
- processFactory?: Parameters<typeof createStdioTransport>[0]["processFactory"];
15
- }
16
- export declare class AhpAdapter implements HarnessAdapter {
17
- private readonly options;
18
- private wire?;
19
- private initialized?;
20
- private result?;
21
- private readonly sessions;
22
- private readonly responding;
23
- private disposed;
24
- constructor(options: AhpAdapterOptions);
25
- initialize(params: InitializeParams): Promise<InitializeResult>;
26
- private notification;
27
- private deliver;
28
- openSession(params: SessionOpenParams, sink: SessionEventSink): Promise<BackendSession>;
29
- dispose(): Promise<void>;
30
- }
31
- export declare function createAhpAdapter(options: AhpAdapterOptions): HarnessAdapter;