@anureo/adapter-ahp 0.0.0-stage → 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.d.ts ADDED
@@ -0,0 +1,119 @@
1
+ import { type HarnessProcess, type HarnessProcessOptions } from "@anureo/sdk/process";
2
+ /**
3
+ * Supply framed text I/O to AhpWire or through AhpAdapterOptions.transport/connect.
4
+ * Ownership transfers to the wire or adapter. Implementations report source failure by
5
+ * rejecting iteration. Direct wire users await AhpWire.close to release the connection.
6
+ */
7
+ export interface AhpTransport {
8
+ /** Yield one complete JSON-RPC text frame per value. AhpWire claims the reader immediately. */
9
+ messages: AsyncIterable<string>;
10
+ /** Submit one frame and resolve when accepted locally. Reject write or admission failures; sends can overlap. */
11
+ send: (message: string) => Promise<void>;
12
+ /** Release the connection and unblock a waiting reader. Report local cleanup failures by rejecting. */
13
+ close: () => Promise<void>;
14
+ }
15
+ /** Configure AhpWire directly or through the fields inherited by AhpAdapterOptions. */
16
+ export interface WireOptions {
17
+ /** Per-request reply deadline in milliseconds, starting before send; defaults to 30,000. */
18
+ timeoutMs?: number;
19
+ /** Maximum UTF-8 bytes per incoming frame or outgoing request. Defaults to 8 MiB. */
20
+ maxFrameBytes?: number;
21
+ /** Maximum requests awaiting settlement, including during send. Defaults to 256. */
22
+ maxPending?: number;
23
+ /** Maximum queued and running notification callbacks; defaults to 2048. */
24
+ maxNotifications?: number;
25
+ /**
26
+ * Called once at cleanup with the first cause's SDK error code, or PROTOCOL_ERROR for other
27
+ * causes. Throws are ignored.
28
+ */
29
+ diagnostic?: (code: string) => void;
30
+ }
31
+ /**
32
+ * Exchange JSON-RPC requests and serialized notifications over one owned transport. AhpAdapter
33
+ * uses this for its handshake, session operations and replay, and validates the returned payloads.
34
+ * Direct consumers likewise own payload validation and must await close to observe cleanup failure.
35
+ */
36
+ export declare class AhpWire {
37
+ private readonly transport;
38
+ private readonly options;
39
+ /** Take ownership of the transport and start reading immediately. Install callbacks before sending requests. */
40
+ constructor(transport: AhpTransport, options?: WireOptions);
41
+ /**
42
+ * Receives notifications serially without blocking reply reads. AhpAdapter.initializeConnection
43
+ * registers its notification router here so replay can await RPC replies. A throw or rejection
44
+ * starts cleanup. The queue looks up the current callback at delivery time and keeps processing
45
+ * frames already queued when close starts.
46
+ */
47
+ onNotification?: (method: string, params: unknown) => Promise<void> | void;
48
+ /**
49
+ * Invoked once with the first close cause, concurrently with transport cleanup.
50
+ * AhpAdapter.initializeConnection registers failConnection to fail session sinks and interaction
51
+ * waiters. A throw or rejection becomes a resource-release failure reported by close.
52
+ * The callback must return independently of close, which awaits its completion.
53
+ */
54
+ onClose?: (cause: unknown) => Promise<void> | void;
55
+ /**
56
+ * Send a request and resolve with its correlated result. Callers validate the result schema.
57
+ * Serialization errors throw synchronously. Remote errors, malformed replies, admission limits,
58
+ * timeouts and connection loss reject the promise. The reply deadline includes sending. Timeouts
59
+ * and asynchronous send failures reject with outcomeUnknown and leave the wire open. A synchronous
60
+ * transport send exception rejects as supplied and leaves correlation until reply, timeout or close.
61
+ * Callers own retry decisions. Use close to stop waiting for outstanding replies.
62
+ */
63
+ request(method: string, params: unknown): Promise<unknown>;
64
+ /**
65
+ * Send a notification directly through transport.send, which controls admission and completion
66
+ * and receives the frame regardless of the wire's request and frame limits.
67
+ * Omitted or undefined params leave the field out. Serialization can throw synchronously and
68
+ * transport failures propagate as supplied.
69
+ * AhpAdapter.initializeConnection awaits this for initialized before reporting readiness.
70
+ */
71
+ notify(method: string, params?: unknown): Promise<void>;
72
+ /**
73
+ * Stop admitting requests and reject outstanding ones with RPC_CONNECTION_CLOSED immediately.
74
+ * Await onClose and transport.close concurrently. Repeated calls share the same promise, including
75
+ * after background failure. It settles when both release operations finish and rejects with
76
+ * RESOURCE_RELEASE_FAILED if either fails. The reader and notification queue run independently.
77
+ * AhpAdapter.dispose awaits this after the SDK has released its session handles.
78
+ */
79
+ close(): Promise<void>;
80
+ private nextId;
81
+ private readonly pending;
82
+ private closing;
83
+ private closePromise?;
84
+ private notificationTail;
85
+ private queuedNotificationCount;
86
+ /**
87
+ * Register correlation and its deadline before dispatch so a fast reply can settle the request.
88
+ * Asynchronous send rejection retires any still-pending entry and reports an unknown outcome.
89
+ */
90
+ private sendRequest;
91
+ /** Read until EOF or failure and initiate cleanup unless it has already started. */
92
+ private read;
93
+ /** Return the method-not-found write for read to await. Correlation and notification admission run inline. */
94
+ private dispatchMessage;
95
+ /** Consume matching correlation once. A malformed reply rejects that caller with PROTOCOL_ERROR. */
96
+ private settleResponse;
97
+ /** Remove live correlation and clear its deadline so a late response or send failure cannot settle it again. */
98
+ private takePending;
99
+ /** Serialize callback delivery in frame order. Backlog overflow throws to read and starts cleanup. */
100
+ private enqueueNotification;
101
+ /** Retire pending requests immediately and cache first-cause cleanup before invoking callbacks. */
102
+ private beginClose;
103
+ /** Run both releases and report their stable failure codes in RESOURCE_RELEASE_FAILED, without retaining private causes. */
104
+ private releaseConnection;
105
+ }
106
+ /**
107
+ * Acquire a process and expose newline-delimited UTF-8 frames as soon as process startup succeeds.
108
+ * Startup failures reject. Reads accept CRLF, skip empty lines and reject oversized frames or
109
+ * an unterminated nonblank tail. The transport drains stderr privately. Send appends a newline
110
+ * and completes with process.write. Close awaits process.dispose and follows its ownership policy.
111
+ * AhpAdapter.initializeConnection transfers the transport to AhpWire or closes it if disposal
112
+ * occurred during acquisition. Direct transport users own close, including after EOF.
113
+ */
114
+ export declare function createStdioTransport(options: HarnessProcessOptions & {
115
+ /** Acquire a process from the supplied options. The transport reads its output and disposes it on close. */
116
+ processFactory?: (p: HarnessProcessOptions) => Promise<HarnessProcess>;
117
+ /** Incoming decoded UTF-8 frame budget, including unfinished lines; defaults to 8 MiB. */
118
+ maxFrameBytes?: number;
119
+ }): Promise<AhpTransport>;
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
+ }