@aikoql/client 0.0.0-stage → 0.2.4

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.
@@ -0,0 +1,496 @@
1
+ // Command sdk-conformance is the TypeScript adapter for the shared
2
+ // conformance runner (D-13, §7/§23). It executes the language-neutral
3
+ // vectors from tests/sdk-conformance/ (the §23 canonical workload + the §7
4
+ // category dirs) and protocol/test-vectors/ against a real aikoql-mcp
5
+ // server through this SDK, then checks every assert/assert_any/expect_error.
6
+ // The expected results are the vectors themselves — every SDK produces the
7
+ // same transcript. Mirrors the Rust adapter (crates/sdk/rust/src/bin/
8
+ // sdk-conformance.rs) arm for arm.
9
+ //
10
+ // Run through scripts/sdk-conformance.sh, or directly:
11
+ //
12
+ // node src/sdk-conformance.ts --bin <aikoql-mcp> \
13
+ // --vectors <dir> --protocol <dir> --token <token>
14
+
15
+ import { spawn, type ChildProcess } from "node:child_process";
16
+ import fs from "node:fs";
17
+ import net from "node:net";
18
+ import os from "node:os";
19
+ import path from "node:path";
20
+ import { Client, McpError, Tx, type StagedOp } from "./index.ts";
21
+
22
+ interface VectorFile {
23
+ name: string;
24
+ operations: unknown[];
25
+ }
26
+
27
+ type Var = { kind: "koid"; koid: string } | { kind: "tx"; tx: Tx };
28
+
29
+ function sleep(ms: number): Promise<void> {
30
+ return new Promise((r) => setTimeout(r, ms));
31
+ }
32
+
33
+ /** Numeric-aware equality: 3 and 3.0 compare equal, like Python's == and
34
+ * unlike serde_json's Number PartialEq (Go never meets mixed int/float —
35
+ * everything decodes to float64 — but Python does, and the vectors were
36
+ * frozen against both). JS numbers unify int/float already. */
37
+ function jsonEq(a: unknown, b: unknown): boolean {
38
+ if (Array.isArray(a) && Array.isArray(b)) {
39
+ return a.length === b.length && a.every((v, i) => jsonEq(v, b[i]));
40
+ }
41
+ if (
42
+ a !== null &&
43
+ b !== null &&
44
+ typeof a === "object" &&
45
+ typeof b === "object" &&
46
+ !Array.isArray(a) &&
47
+ !Array.isArray(b)
48
+ ) {
49
+ const ka = Object.keys(a);
50
+ const kb = Object.keys(b);
51
+ return (
52
+ ka.length === kb.length &&
53
+ ka.every(
54
+ (k) =>
55
+ k in b && jsonEq((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]),
56
+ )
57
+ );
58
+ }
59
+ return a === b;
60
+ }
61
+
62
+ function dotGet(obj: unknown, path: string): unknown {
63
+ let cur = obj;
64
+ for (const part of path.split(".")) {
65
+ if (cur === null || typeof cur !== "object") {
66
+ throw new Error(`cannot descend into ${JSON.stringify(cur)} at ${JSON.stringify(part)}`);
67
+ }
68
+ if (Array.isArray(cur)) {
69
+ const idx = Number(part);
70
+ if (!Number.isInteger(idx)) throw new Error(`no index ${JSON.stringify(part)}`);
71
+ cur = cur[idx];
72
+ } else {
73
+ cur = (cur as Record<string, unknown>)[part];
74
+ if (cur === undefined) throw new Error(`no key ${JSON.stringify(part)}`);
75
+ }
76
+ }
77
+ return cur;
78
+ }
79
+
80
+ /** Wire surfaces → SDK-012 codes: the server's -32001 token rejection is
81
+ * AUTHENTICATION_FAILED to the caller. */
82
+ function mapCode(code: string): string {
83
+ return code === "-32001" ? "AUTHENTICATION_FAILED" : code;
84
+ }
85
+
86
+ function props(v: unknown): Record<string, unknown> | undefined {
87
+ return v !== null && typeof v === "object" && !Array.isArray(v)
88
+ ? (v as Record<string, unknown>)
89
+ : undefined;
90
+ }
91
+
92
+ function or(a: string, b: string): string {
93
+ return a === "" ? b : a;
94
+ }
95
+
96
+ class Runner {
97
+ addr: string;
98
+ token: string;
99
+ client: Client | null = null;
100
+ vars = new Map<string, Var>();
101
+ last = "";
102
+
103
+ constructor(addr: string, token: string) {
104
+ this.addr = addr;
105
+ this.token = token;
106
+ }
107
+
108
+ private current(): Client {
109
+ if (this.client === null) throw new McpError("INTERNAL", "vector must connect first");
110
+ return this.client;
111
+ }
112
+
113
+ /** ref resolves "$name" against the var map; anything else passes
114
+ * through. */
115
+ private refKoid(v: unknown): string {
116
+ const s = typeof v === "string" ? v : "";
117
+ if (s.startsWith("$")) {
118
+ const k = this.vars.get(s.slice(1));
119
+ if (k?.kind === "koid") return k.koid;
120
+ }
121
+ return s;
122
+ }
123
+
124
+ private txVar(op: Record<string, unknown>): Tx {
125
+ const name = (typeof op["txn"] === "string" ? op["txn"] : "").replace(/^\$/, "");
126
+ const v = this.vars.get(name);
127
+ if (v?.kind === "tx") return v.tx;
128
+ throw new McpError("INTERNAL", `var ${JSON.stringify(name)} is not an open txn`);
129
+ }
130
+
131
+ async connect(token: string): Promise<void> {
132
+ const c = await Client.dial(this.addr);
133
+ c.withToken(token);
134
+ try {
135
+ await c.initialize();
136
+ } catch (e) {
137
+ await c.close().catch(() => {});
138
+ throw e;
139
+ }
140
+ this.client = c;
141
+ }
142
+
143
+ async runOp(op: Record<string, unknown>): Promise<unknown> {
144
+ switch (op["op"]) {
145
+ case "connect": {
146
+ const tok = typeof op["token"] === "string" ? op["token"] : this.token;
147
+ await this.connect(tok);
148
+ return {};
149
+ }
150
+ case "close": {
151
+ if (this.client === null) throw new McpError("INTERNAL", "no client to close");
152
+ await this.client.close();
153
+ // The closed client stays current: a call on it is UNAVAILABLE
154
+ // (§3.3), which is what the cancellation vector expects.
155
+ return {};
156
+ }
157
+ case "health":
158
+ return this.current().health();
159
+ case "metrics":
160
+ return this.current().metrics();
161
+ case "remember": {
162
+ const rem = await this.current().remember({
163
+ type_name: (op["type"] as string) ?? "",
164
+ ...(props(op["properties"]) !== undefined
165
+ ? { properties: props(op["properties"]) }
166
+ : {}),
167
+ });
168
+ this.last = rem.koid;
169
+ return rem;
170
+ }
171
+ case "update": {
172
+ const koid = or(this.refKoid(op["koid"]), this.last);
173
+ const rem = await this.current().remember({
174
+ type_name: (op["type"] as string) ?? "",
175
+ koid,
176
+ ...(props(op["properties"]) !== undefined
177
+ ? { properties: props(op["properties"]) }
178
+ : {}),
179
+ });
180
+ this.last = rem.koid;
181
+ return rem;
182
+ }
183
+ case "get": {
184
+ const koid = or(this.refKoid(op["koid"]), this.last);
185
+ return this.current().get(koid, "");
186
+ }
187
+ case "delete": {
188
+ const koid = or(this.refKoid(op["koid"]), this.last);
189
+ const m = (await this.current().forget(koid, "tombstone", "")) as {
190
+ koid?: string;
191
+ };
192
+ if (typeof m.koid === "string") this.last = m.koid;
193
+ return m;
194
+ }
195
+ case "query": {
196
+ if (op["stream"] === true) {
197
+ const stream = this.current().queryStream((op["query"] as string) ?? "", "");
198
+ const chunks: unknown[] = [];
199
+ for await (const chunk of stream) chunks.push(chunk);
200
+ return { chunks };
201
+ }
202
+ return this.current().aikoql((op["query"] as string) ?? "", "");
203
+ }
204
+ case "relate": {
205
+ const m = (await this.current().relate(
206
+ this.refKoid(op["from"]),
207
+ this.refKoid(op["to"]),
208
+ (op["rel_type"] as string) ?? "",
209
+ "",
210
+ )) as { koid?: string };
211
+ if (typeof m.koid === "string") this.last = m.koid;
212
+ return m;
213
+ }
214
+ case "traverse": {
215
+ const depth = typeof op["depth"] === "number" ? op["depth"] : 1;
216
+ return this.current().traverse(
217
+ this.refKoid(op["koid"]),
218
+ (op["rel_type"] as string) ?? "",
219
+ "",
220
+ depth,
221
+ );
222
+ }
223
+ case "find_similar": {
224
+ const p = {
225
+ ...(typeof op["text"] === "string" ? { text: op["text"] } : {}),
226
+ ...(typeof op["wait_for_freshness_ms"] === "number"
227
+ ? { wait_for_freshness_ms: op["wait_for_freshness_ms"] }
228
+ : {}),
229
+ };
230
+ const hits = await this.current().findSimilar(p);
231
+ return { results: hits };
232
+ }
233
+ case "begin": {
234
+ const tx = await this.current().begin();
235
+ const id = tx.id();
236
+ this.vars.set((op["as"] as string) ?? "", { kind: "tx", tx });
237
+ return { txn_id: id };
238
+ }
239
+ case "execute": {
240
+ const staged: StagedOp = {
241
+ action: (op["action"] as string) ?? "",
242
+ ...((op["type"] as string) !== "" && op["type"] !== undefined
243
+ ? { type_name: op["type"] as string }
244
+ : {}),
245
+ ...(props(op["properties"]) !== undefined
246
+ ? { properties: props(op["properties"]) }
247
+ : {}),
248
+ };
249
+ await this.txVar(op).execute(staged);
250
+ return {};
251
+ }
252
+ case "commit":
253
+ return this.txVar(op).commit();
254
+ case "rollback":
255
+ await this.txVar(op).rollback();
256
+ return { rolled_back: true };
257
+ case "explain": {
258
+ const koid = this.refKoid(op["koid"]);
259
+ return this.current().explain(koid, "");
260
+ }
261
+ case "trace": {
262
+ const koid = this.refKoid(op["koid"]);
263
+ return this.current().trace(koid, "");
264
+ }
265
+ case "discover_schema":
266
+ return this.current().discoverSchema();
267
+ default:
268
+ throw new McpError("INTERNAL", `op ${JSON.stringify(op["op"])} has no adapter arm`);
269
+ }
270
+ }
271
+
272
+ private capture(op: Record<string, unknown>, result: unknown): void {
273
+ const asName = typeof op["as"] === "string" ? op["as"] : "";
274
+ if (asName === "") return;
275
+ const r = result as { koid?: string; results?: Array<{ koid?: string }> };
276
+ if (typeof r.koid === "string") {
277
+ this.vars.set(asName, { kind: "koid", koid: r.koid });
278
+ return;
279
+ }
280
+ if (typeof r.results?.[0]?.koid === "string") {
281
+ this.vars.set(asName, { kind: "koid", koid: r.results[0]!.koid! });
282
+ }
283
+ }
284
+
285
+ /** "$name" in an expected value resolves against the var map. */
286
+ private deref(want: unknown): unknown {
287
+ if (typeof want === "string" && want.startsWith("$")) {
288
+ const k = this.vars.get(want.slice(1));
289
+ if (k?.kind === "koid") return k.koid;
290
+ }
291
+ return want;
292
+ }
293
+
294
+ private check(op: Record<string, unknown>, result: unknown): void {
295
+ const asserts = props(op["assert"]);
296
+ if (asserts !== undefined) {
297
+ for (const [pathName, want] of Object.entries(asserts)) {
298
+ const got = dotGet(result, pathName);
299
+ const wantD = this.deref(want);
300
+ if (!jsonEq(got, wantD)) {
301
+ throw new Error(`assert ${pathName}: expected ${JSON.stringify(wantD)}, got ${JSON.stringify(got)}`);
302
+ }
303
+ }
304
+ }
305
+ const aa = props(op["assert_any"]);
306
+ if (aa !== undefined) {
307
+ const pathName = (aa["path"] as string) ?? "";
308
+ const items = dotGet(result, pathName);
309
+ if (!Array.isArray(items)) {
310
+ throw new Error(`assert_any ${pathName}: not a list`);
311
+ }
312
+ const m = aa["match"];
313
+ if (m === undefined) throw new Error("assert_any: no match");
314
+ let found = false;
315
+ if (props(m) !== undefined) {
316
+ const wantMap = props(m)!;
317
+ for (const e of items) {
318
+ const em = props(e);
319
+ if (em === undefined) continue;
320
+ let all = true;
321
+ for (const [p, v] of Object.entries(wantMap)) {
322
+ try {
323
+ if (!jsonEq(dotGet(em, p), v)) {
324
+ all = false;
325
+ break;
326
+ }
327
+ } catch {
328
+ all = false;
329
+ break;
330
+ }
331
+ }
332
+ if (all) {
333
+ found = true;
334
+ break;
335
+ }
336
+ }
337
+ } else {
338
+ found = items.some((e) => jsonEq(e, m));
339
+ }
340
+ if (!found) {
341
+ throw new Error(`assert_any ${pathName}: no element matches ${JSON.stringify(m)}`);
342
+ }
343
+ }
344
+ }
345
+
346
+ async runVector(ops: unknown[]): Promise<void> {
347
+ await this.connect(this.token);
348
+ this.vars.clear();
349
+ this.last = "";
350
+ for (const [i, opv] of ops.entries()) {
351
+ const op = props(opv);
352
+ if (op === undefined) throw new Error(`op ${i}: not an object`);
353
+ const expect = typeof op["expect_error"] === "string" ? op["expect_error"] : "";
354
+ try {
355
+ const result = await this.runOp(op);
356
+ if (expect !== "") {
357
+ throw new Error(`op ${i} ${JSON.stringify(op["op"])}: expected error ${expect}, none raised`);
358
+ }
359
+ this.capture(op, result);
360
+ try {
361
+ this.check(op, result);
362
+ } catch (e) {
363
+ throw new Error(`op ${i} ${JSON.stringify(op["op"])}: ${(e as Error).message}`);
364
+ }
365
+ } catch (e) {
366
+ const code = e instanceof McpError ? mapCode(e.code) : (e as Error).message;
367
+ if (expect === code) continue;
368
+ if (expect !== "") {
369
+ throw new Error(`op ${i} ${JSON.stringify(op["op"])}: expected error ${expect}, got ${code}: ${(e as Error).message}`);
370
+ }
371
+ throw e;
372
+ }
373
+ }
374
+ }
375
+ }
376
+
377
+ function loadVectors(dir: string): VectorFile[] {
378
+ const paths: string[] = [];
379
+ const stack = [dir];
380
+ while (stack.length > 0) {
381
+ const d = stack.pop()!;
382
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
383
+ const p = path.join(d, entry.name);
384
+ if (entry.isDirectory()) stack.push(p);
385
+ else if (p.endsWith(".json")) paths.push(p);
386
+ }
387
+ }
388
+ paths.sort();
389
+ return paths.map((p) => {
390
+ const raw = fs.readFileSync(p, "utf8");
391
+ try {
392
+ return JSON.parse(raw) as VectorFile;
393
+ } catch (e) {
394
+ throw new Error(`${p}: ${(e as Error).message}`);
395
+ }
396
+ });
397
+ }
398
+
399
+ /** The integration_test pattern: a probed free port and a db path that
400
+ * does not exist (the server auto-creates it as aikoql-v2). */
401
+ async function spawnServer(
402
+ bin: string,
403
+ token: string,
404
+ ): Promise<{ child: ChildProcess; addr: string; dir: string }> {
405
+ const probe = net.createServer();
406
+ await new Promise<void>((resolve, reject) => {
407
+ probe.once("error", reject);
408
+ probe.listen(0, "127.0.0.1", resolve);
409
+ });
410
+ const port = (probe.address() as net.AddressInfo).port;
411
+ await new Promise<void>((resolve) => probe.close(() => resolve()));
412
+
413
+ const dir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "conformance-"));
414
+ const db = path.join(dir, "db.aikoql"); // does not exist → auto-create (aikoql-v2)
415
+ const addr = `127.0.0.1:${port}`;
416
+ // Stdio stays ignore: an inherited pipe would let an orphaned server
417
+ // hold a pipe-capturing parent open on failure.
418
+ const child = spawn(bin, ["serve", db, "--listen", addr, "--tcp-token", `${token}::admin`], {
419
+ stdio: "ignore",
420
+ windowsHide: true,
421
+ });
422
+ const deadline = Date.now() + 15_000;
423
+ for (;;) {
424
+ const up = await new Promise<boolean>((resolve) => {
425
+ const s = net.connect(port, "127.0.0.1");
426
+ s.once("connect", () => {
427
+ s.destroy();
428
+ resolve(true);
429
+ });
430
+ s.once("error", () => resolve(false));
431
+ });
432
+ if (up) return { child, addr, dir };
433
+ if (Date.now() >= deadline) {
434
+ child.kill();
435
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
436
+ throw new Error(`server did not come up on ${addr}`);
437
+ }
438
+ await sleep(50);
439
+ }
440
+ }
441
+
442
+ async function run(
443
+ bin: string,
444
+ vectorsDir: string,
445
+ protocolDir: string,
446
+ token: string,
447
+ ): Promise<void> {
448
+ const { child, addr, dir } = await spawnServer(bin, token);
449
+ try {
450
+ const r = new Runner(addr, token);
451
+ let vectorsRun = 0;
452
+ let opsRun = 0;
453
+ for (const dirName of [protocolDir, vectorsDir]) {
454
+ for (const vf of loadVectors(dirName)) {
455
+ await r.runVector(vf.operations).catch((e: Error) => {
456
+ throw new Error(`${vf.name}: ${e.message}`);
457
+ });
458
+ vectorsRun += 1;
459
+ opsRun += vf.operations.length;
460
+ console.log(` ok ${vf.name} (${vf.operations.length} ops)`);
461
+ }
462
+ }
463
+ console.log(`sdk-conformance (typescript): ${vectorsRun} vectors, ${opsRun} ops — all passed`);
464
+ } finally {
465
+ // Kills the server and sweeps its temp dir on EVERY exit path.
466
+ child.kill();
467
+ await new Promise<void>((resolve) => child.once("exit", () => resolve()));
468
+ await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
469
+ }
470
+ }
471
+
472
+ async function main(): Promise<void> {
473
+ let bin = "";
474
+ let vectorsDir = "";
475
+ let protocolDir = "";
476
+ let token = "conformance";
477
+ for (let i = 2; i < process.argv.length; i++) {
478
+ const a = process.argv[i]!;
479
+ if (a === "--bin") bin = process.argv[++i] ?? "";
480
+ else if (a === "--vectors") vectorsDir = process.argv[++i] ?? "";
481
+ else if (a === "--protocol") protocolDir = process.argv[++i] ?? "";
482
+ else if (a === "--token") token = process.argv[++i] ?? "";
483
+ else {
484
+ process.stderr.write(`unknown arg: ${a}\n`);
485
+ process.exit(1);
486
+ }
487
+ }
488
+ try {
489
+ await run(bin, vectorsDir, protocolDir, token);
490
+ } catch (e) {
491
+ process.stderr.write(`sdk-conformance (typescript): ${(e as Error).message}\n`);
492
+ process.exit(1);
493
+ }
494
+ }
495
+
496
+ void main();
package/src/tcp.ts ADDED
@@ -0,0 +1,147 @@
1
+ // One line-framed MCP connection. The browser transport ("where
2
+ // applicable", §25) implements the same Transport surface over WebSocket
3
+ // (or a fetch duplex); nothing above this file is Node-specific — the
4
+ // D-15 native protocol replacement likewise swaps only the Transport.
5
+
6
+ import net from "node:net";
7
+ import tls from "node:tls";
8
+ import { McpError } from "./error.ts";
9
+
10
+ /** §19: a malicious server cannot cause unbounded client memory — an
11
+ * unterminated line past this cap is refused mid-accumulation
12
+ * (FRAME_TOO_LARGE) and the transport is poisoned. */
13
+ export const MAX_FRAME = 1024 * 1024;
14
+
15
+ /** One newline-framed byte pipe: write a frame, read lines in order. */
16
+ export interface Transport {
17
+ write(line: string): void;
18
+ /**
19
+ * The next line, or null when the peer closed. An aborted signal rejects
20
+ * with the frozen retryable TIMEOUT and leaves the stream undisturbed —
21
+ * the abandoned line (if any) still lands in the queue for the next
22
+ * reader (id correlation skips it: self-healing).
23
+ */
24
+ readLine(signal?: AbortSignal): Promise<string | null>;
25
+ /** Cancels the one pending read (a dropped stream); the next line queues. */
26
+ cancelPendingRead(): void;
27
+ close(): void;
28
+ }
29
+
30
+ export class TcpTransport implements Transport {
31
+ private socket: net.Socket;
32
+ private buffer = "";
33
+ private lines: string[] = [];
34
+ // Exactly one reader at a time (the client serializes calls on a lock).
35
+ private current: { resolve: (line: string | null) => void; reject: (e: McpError) => void } | null = null;
36
+ private closed = false;
37
+
38
+ private constructor(socket: net.Socket) {
39
+ this.socket = socket;
40
+ socket.on("data", (chunk: Buffer) => {
41
+ this.buffer += chunk.toString("utf8");
42
+ if (this.buffer.length > MAX_FRAME) {
43
+ // §19: the cap trips mid-accumulation, before an unterminated line
44
+ // can grow the buffer past the bound. The stream is desynced —
45
+ // fail the pending read and poison the transport.
46
+ this.closed = true;
47
+ this.socket.destroy();
48
+ if (this.current) {
49
+ const waiter = this.current;
50
+ this.current = null;
51
+ waiter.reject(McpError.frameTooLarge());
52
+ }
53
+ return;
54
+ }
55
+ let idx: number;
56
+ while ((idx = this.buffer.indexOf("\n")) >= 0) {
57
+ const line = this.buffer.slice(0, idx);
58
+ this.buffer = this.buffer.slice(idx + 1);
59
+ this.deliver(line);
60
+ }
61
+ });
62
+ socket.on("close", () => {
63
+ this.closed = true;
64
+ if (this.current) {
65
+ this.current.resolve(null);
66
+ this.current = null;
67
+ }
68
+ });
69
+ // Errors surface as close (readLine → null → "connection closed").
70
+ socket.on("error", () => {});
71
+ }
72
+
73
+ /** Connects, bounding the dial by the frozen TIMEOUT (the Go/Rust shape).
74
+ * With `tlsOptions` (a server started with --tls-cert/--tls-key) the
75
+ * handshake verifies the certificate for `host` — an IP against the SAN,
76
+ * a DNS name also sent as SNI — before resolving, so the token is never
77
+ * sent to an unverified peer. */
78
+ static connect(addr: string, timeoutMs: number, tlsOptions?: tls.ConnectionOptions): Promise<TcpTransport> {
79
+ return new Promise((resolve, reject) => {
80
+ const [host, port] = addr.split(":");
81
+ const socket = tlsOptions
82
+ ? tls.connect({
83
+ host,
84
+ port: Number(port),
85
+ ...(net.isIP(host!) ? {} : { servername: host }),
86
+ ...tlsOptions,
87
+ })
88
+ : net.connect(Number(port), host!);
89
+ const timer = setTimeout(() => {
90
+ socket.destroy();
91
+ reject(McpError.deadline());
92
+ }, timeoutMs);
93
+ socket.once(tlsOptions ? "secureConnect" : "connect", () => {
94
+ clearTimeout(timer);
95
+ resolve(new TcpTransport(socket));
96
+ });
97
+ socket.once("error", (err: Error) => {
98
+ clearTimeout(timer);
99
+ reject(McpError.io(err.message));
100
+ });
101
+ });
102
+ }
103
+
104
+ private deliver(line: string): void {
105
+ if (this.current) {
106
+ const waiter = this.current;
107
+ this.current = null;
108
+ waiter.resolve(line);
109
+ } else {
110
+ this.lines.push(line);
111
+ }
112
+ }
113
+
114
+ write(line: string): void {
115
+ this.socket.write(line + "\n");
116
+ }
117
+
118
+ readLine(signal?: AbortSignal): Promise<string | null> {
119
+ if (this.lines.length > 0) return Promise.resolve(this.lines.shift()!);
120
+ if (this.closed) return Promise.resolve(null);
121
+ if (signal?.aborted) return Promise.reject(McpError.deadline());
122
+ if (this.current) throw new Error("aikoql: concurrent readers on one transport");
123
+ return new Promise((resolve, reject) => {
124
+ const entry = { resolve, reject };
125
+ const onAbort = () => {
126
+ if (this.current === entry) {
127
+ this.current = null;
128
+ reject(McpError.deadline());
129
+ }
130
+ signal!.removeEventListener("abort", onAbort);
131
+ };
132
+ if (signal) signal.addEventListener("abort", onAbort, { once: true });
133
+ this.current = entry;
134
+ });
135
+ }
136
+
137
+ cancelPendingRead(): void {
138
+ if (this.current) {
139
+ this.current.resolve(null);
140
+ this.current = null;
141
+ }
142
+ }
143
+
144
+ close(): void {
145
+ this.socket.destroy();
146
+ }
147
+ }