@tikoci/centrs 0.1.6 → 0.1.8-next.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +342 -0
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +10 -6
  4. package/SECURITY.md +1 -1
  5. package/commands/AGENTS.md +2 -1
  6. package/commands/api/AGENTS.md +55 -30
  7. package/commands/api/README.md +135 -42
  8. package/commands/api/examples.md +275 -19
  9. package/commands/devices/README.md +21 -4
  10. package/commands/devices/examples.md +33 -2
  11. package/commands/execute/README.md +60 -18
  12. package/commands/execute/examples.md +224 -0
  13. package/commands/explain/README.md +42 -5
  14. package/commands/mcp/README.md +2 -1
  15. package/commands/retrieve/README.md +348 -4
  16. package/commands/retrieve/examples.md +442 -5
  17. package/commands/terminal/README.md +14 -4
  18. package/commands/terminal/examples.md +22 -3
  19. package/commands/transfer/README.md +55 -9
  20. package/commands/transfer/examples.md +106 -7
  21. package/docs/CLI.md +29 -21
  22. package/docs/CONSTITUTION.md +41 -16
  23. package/docs/MATRIX.md +38 -1
  24. package/docs/errors/README.md +3 -1
  25. package/docs/errors/cdb/create-failed.md +20 -0
  26. package/docs/errors/cdb/file-exists.md +23 -0
  27. package/docs/errors/input/invalid-query.md +36 -0
  28. package/docs/errors/input/invalid-target.md +25 -3
  29. package/docs/errors/input/local-destination.md +20 -0
  30. package/docs/errors/input/unsupported-query.md +19 -0
  31. package/docs/errors/internal/byte-string.md +13 -0
  32. package/docs/errors/routeros/invalid-value.md +3 -2
  33. package/docs/errors/settings/invalid-timeout.md +6 -0
  34. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  35. package/docs/errors/transport/stream-overflow.md +21 -0
  36. package/docs/errors/usage/removed-flag.md +20 -0
  37. package/docs/errors/validation/menu-unavailable.md +22 -0
  38. package/docs/errors/validation/not-followable.md +23 -0
  39. package/docs/errors/validation/package-missing.md +29 -0
  40. package/docs/errors/validation/unknown-path.md +16 -4
  41. package/docs/errors/wait/deadline-exceeded.md +14 -0
  42. package/docs/errors/wait/interrupted.md +10 -0
  43. package/package.json +14 -11
  44. package/src/absent-menu.ts +280 -0
  45. package/src/api-fanout.ts +22 -12
  46. package/src/api.ts +603 -194
  47. package/src/btest.ts +19 -17
  48. package/src/cli/api.ts +126 -34
  49. package/src/cli/devices.ts +16 -3
  50. package/src/cli/execute.ts +18 -5
  51. package/src/cli/retrieve.ts +216 -22
  52. package/src/cli/terminal.ts +10 -5
  53. package/src/cli/transfer.ts +23 -8
  54. package/src/core/envelope.ts +14 -0
  55. package/src/core/error-catalog.ts +70 -1
  56. package/src/core/execute-command.ts +313 -6
  57. package/src/core/inspect.ts +65 -27
  58. package/src/core/query.ts +508 -0
  59. package/src/core/routeros-errors.ts +67 -1
  60. package/src/data/winbox-cdb-write.ts +22 -8
  61. package/src/devices.ts +148 -37
  62. package/src/discover.ts +7 -8
  63. package/src/errors.ts +116 -7
  64. package/src/execute-fanout.ts +14 -9
  65. package/src/execute.ts +128 -116
  66. package/src/explain/catalog.ts +64 -16
  67. package/src/explain/menus.ts +27 -6
  68. package/src/explain.ts +6 -6
  69. package/src/index.ts +31 -1
  70. package/src/mcp/tools.ts +10 -15
  71. package/src/protocols/adapter.ts +235 -118
  72. package/src/protocols/btest-session.ts +3 -2
  73. package/src/protocols/endpoint.ts +10 -0
  74. package/src/protocols/mac-telnet-console.ts +12 -7
  75. package/src/protocols/native-api.ts +252 -73
  76. package/src/protocols/sftp.ts +44 -14
  77. package/src/protocols/ssh.ts +42 -14
  78. package/src/resolver/cdb.ts +28 -7
  79. package/src/resolver/index.ts +1 -0
  80. package/src/resolver/target.ts +84 -18
  81. package/src/retrieve-fanout.ts +51 -12
  82. package/src/retrieve-follow.ts +862 -0
  83. package/src/retrieve-sample.ts +244 -0
  84. package/src/retrieve-stream.ts +115 -0
  85. package/src/retrieve-wait.ts +286 -0
  86. package/src/retrieve.ts +741 -109
  87. package/src/settings.ts +15 -15
  88. package/src/transfer-fanout.ts +40 -15
  89. package/src/transfer.ts +234 -32
@@ -42,9 +42,10 @@
42
42
  * (name-level) gate — no `/console/inspect` table parsing needed.
43
43
  */
44
44
 
45
+ import { absentMenuError } from "../absent-menu.ts";
45
46
  import { parseRouterOsPosition } from "../core/routeros-errors.ts";
46
47
  import { routerOsStringLiteral } from "../core/routeros-string.ts";
47
- import { CentrsError } from "../errors.ts";
48
+ import { CentrsError, SYNTAX_REJECTED_REMEDIATION } from "../errors.ts";
48
49
  import {
49
50
  type MacAddress,
50
51
  type MacTelnetDatagramSink,
@@ -627,14 +628,16 @@ export function parseScriptFor(cli: string): string {
627
628
  * with RouterOS's position suffix. All 425 corpus rows of this shape are
628
629
  * the entire return; none appear embedded. Requiring both line-start and
629
630
  * the `(line N column M)` suffix is what separates a real verdict from
630
- * echoed body text.
631
+ * echoed body text. The pinned 7.23.5/7.24.2/7.25beta3 captures add no
632
+ * new heads beyond `syntax error`, `expected …` and `missing …`
633
+ * (`missing closing brace`, `missing value for where`; GH#375).
631
634
  */
632
635
  const WRAPPED_BAD_PARAMETER = /\(evl\s+bad parameter\s+(\S+)/i;
633
636
  const WRAPPED_BAD_COMMAND = /\(<%%\s*bad command name\b/i;
634
637
  const BARE_BAD_PARAMETER =
635
638
  /^[ \t]*bad parameter\s+(\S+)[^\n]*\(line \d+ column \d+\)/im;
636
639
  const BARE_SYNTAX =
637
- /^[ \t]*(?:syntax error|bad command name|expected [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
640
+ /^[ \t]*(?:syntax error|bad command name|(?:expected|missing) [a-z]+(?: [a-z]+)*)[^\n]*\(line \d+ column \d+\)/im;
638
641
 
639
642
  /**
640
643
  * Classify the printed value of `:put [:parse "<cli>"]`. Grounded on CHR 7.23.3
@@ -646,8 +649,8 @@ const BARE_SYNTAX =
646
649
  * RouterOS emits them (see the grammar note above): wrapped as
647
650
  * `(evl bad parameter <name> (line N column M) /path)` /
648
651
  * `(<%% bad command name <name> (line N column M) a;b;c)`, or bare and
649
- * line-initial as `expected … (line N column M)` / `syntax error (line N column
650
- * M)`. A single `:parse` covers both the syntax and the unknown-attribute
652
+ * line-initial as `expected … (line N column M)` / `missing … (line N column
653
+ * M)` / `syntax error (line N column M)`. A single `:parse` covers both the syntax and the unknown-attribute
651
654
  * (name-level) gate on every transport — REST and native API expose the same text
652
655
  * in the `ret` value (HTTP 200 / `as-string`), the console transports print it.
653
656
  *
@@ -679,11 +682,13 @@ export function classifyParseResult(
679
682
  });
680
683
  }
681
684
  if (WRAPPED_BAD_COMMAND.test(output) || BARE_SYNTAX.test(output)) {
685
+ // An absent menu reads as a syntax error; name it instead (GH#361).
686
+ const absent = absentMenuError(cli, output, position, via);
687
+ if (absent) throw absent;
682
688
  throw new CentrsError({
683
689
  code: "validation/syntax",
684
690
  summary: "RouterOS rejected the command syntax while parsing it.",
685
- remediation:
686
- "Fix the RouterOS CLI syntax (quotes, brackets, attribute form), then retry.",
691
+ remediation: SYNTAX_REJECTED_REMEDIATION,
687
692
  context: {
688
693
  command: cli,
689
694
  validationSource: `:put [:parse ...] over ${via}`,
@@ -9,7 +9,7 @@
9
9
  * 2. A **session** (`NativeApiSession`) driven over an injectable byte stream
10
10
  * (`NativeApiByteSink`), so scripted server bytes can drive it in tests and
11
11
  * a real socket drives it in integration.
12
- * 3. A thin **TCP/TLS adapter** (`connectNativeApi`) built on `Bun.connect`.
12
+ * 3. A thin **TCP/TLS adapter** (`connectNativeApi`) built on Bun's built-in `node:net` / `node:tls` sockets.
13
13
  *
14
14
  * Wire format grounded against the MikroTik wiki API page and the canonical
15
15
  * `go-routeros` and `librouteros` implementations. Default ports: 8728 (plain),
@@ -20,8 +20,11 @@
20
20
  */
21
21
 
22
22
  import { createHash } from "node:crypto";
23
+ import { createConnection, isIP } from "node:net";
24
+ import { connect as connectTls } from "node:tls";
23
25
  import { mapRouterOsError } from "../core/routeros-errors.ts";
24
26
  import { CentrsError } from "../errors.ts";
27
+ import { formatEndpoint } from "./endpoint.ts";
25
28
 
26
29
  const utf8Encoder = new TextEncoder();
27
30
  const utf8Decoder = new TextDecoder();
@@ -144,9 +147,32 @@ export function decodeLength(
144
147
  return { value: value >>> 0, size };
145
148
  }
146
149
 
147
- /** Encode a single word: length prefix followed by its UTF-8 bytes. */
148
- export function encodeWord(word: string): Uint8Array {
149
- const payload = utf8Encoder.encode(word);
150
+ /**
151
+ * The bytes of a *byte string*: a string whose code units are each one byte
152
+ * (0–255), the shape `binary` commands carry file contents in. A code unit
153
+ * above 0xff has no byte, and `Buffer`'s latin1 encoder would silently keep
154
+ * only its low byte, so it is refused instead.
155
+ */
156
+ export function byteStringBytes(value: string): Buffer {
157
+ for (let index = 0; index < value.length; index += 1) {
158
+ if (value.charCodeAt(index) > 0xff) {
159
+ throw new CentrsError({
160
+ code: "internal/byte-string",
161
+ summary: `A binary RouterOS word holds a character above U+00FF at offset ${index}.`,
162
+ remediation:
163
+ 'This is a centrs bug; a binary command must carry each byte as one code unit (Buffer#toString("latin1")).',
164
+ });
165
+ }
166
+ }
167
+ return Buffer.from(value, "latin1");
168
+ }
169
+
170
+ /**
171
+ * Encode a single word: length prefix followed by its UTF-8 bytes, or, for a
172
+ * `binary` word, its byte-string bytes (see {@link byteStringBytes}).
173
+ */
174
+ export function encodeWord(word: string, binary = false): Uint8Array {
175
+ const payload = binary ? byteStringBytes(word) : utf8Encoder.encode(word);
150
176
  const prefix = encodeLength(payload.length);
151
177
  const out = new Uint8Array(prefix.length + payload.length);
152
178
  out.set(prefix, 0);
@@ -155,8 +181,11 @@ export function encodeWord(word: string): Uint8Array {
155
181
  }
156
182
 
157
183
  /** Encode a sentence: each word framed, terminated by a zero-length word. */
158
- export function encodeSentence(words: readonly string[]): Uint8Array {
159
- const parts = words.map(encodeWord);
184
+ export function encodeSentence(
185
+ words: readonly string[],
186
+ binary = false,
187
+ ): Uint8Array {
188
+ const parts = words.map((word) => encodeWord(word, binary));
160
189
  const total = parts.reduce((sum, part) => sum + part.length, 0) + 1;
161
190
  const out = new Uint8Array(total);
162
191
  let cursor = 0;
@@ -168,17 +197,42 @@ export function encodeSentence(words: readonly string[]): Uint8Array {
168
197
  return out;
169
198
  }
170
199
 
200
+ /**
201
+ * Decode one received word. RouterOS sends words as raw bytes: UTF-8 for the
202
+ * text centrs reads, but `/file` contents are arbitrary bytes, which a UTF-8
203
+ * decode replaces with U+FFFD (#404). A `binary` word decodes as a byte string
204
+ * through `Buffer`'s latin1, never WHATWG `TextDecoder("latin1")`, which is
205
+ * windows-1252 and maps 0x80 to U+20AC.
206
+ */
207
+ export function decodeWord(word: Uint8Array, binary = false): string {
208
+ return binary
209
+ ? Buffer.from(word.buffer, word.byteOffset, word.byteLength).toString(
210
+ "latin1",
211
+ )
212
+ : utf8Decoder.decode(word);
213
+ }
214
+
171
215
  /**
172
216
  * Incremental sentence reader. Accumulates TCP chunks and yields whole
173
- * sentences (arrays of decoded words) as they complete. Partial words and
174
- * partial length prefixes are buffered until the rest of the bytes arrive.
217
+ * sentences as they complete. Partial words and partial length prefixes are
218
+ * buffered until the rest of the bytes arrive.
175
219
  */
176
220
  export class SentenceReader {
177
221
  private buffer: Uint8Array = new Uint8Array(0);
178
- private current: string[] = [];
222
+ private current: Uint8Array[] = [];
179
223
 
180
- /** Push a chunk and return any sentences completed by it. */
224
+ /** Push a chunk and return any sentences completed by it, UTF-8 decoded. */
181
225
  push(chunk: Uint8Array): string[][] {
226
+ return this.pushRaw(chunk).map((words) =>
227
+ words.map((word) => decodeWord(word)),
228
+ );
229
+ }
230
+
231
+ /**
232
+ * Push a chunk and return any completed sentences as raw word bytes, so the
233
+ * caller picks each sentence's decoding (see {@link decodeWord}).
234
+ */
235
+ pushRaw(chunk: Uint8Array): Uint8Array[][] {
182
236
  if (chunk.length > 0) {
183
237
  const next = new Uint8Array(this.buffer.length + chunk.length);
184
238
  next.set(this.buffer, 0);
@@ -186,7 +240,7 @@ export class SentenceReader {
186
240
  this.buffer = next;
187
241
  }
188
242
 
189
- const sentences: string[][] = [];
243
+ const sentences: Uint8Array[][] = [];
190
244
  let offset = 0;
191
245
  for (;;) {
192
246
  const header = decodeLength(this.buffer, offset);
@@ -204,9 +258,8 @@ export class SentenceReader {
204
258
  this.current = [];
205
259
  continue;
206
260
  }
207
- this.current.push(
208
- utf8Decoder.decode(this.buffer.subarray(wordStart, wordEnd)),
209
- );
261
+ // Copy: the buffer is re-sliced and replaced as chunks arrive.
262
+ this.current.push(this.buffer.slice(wordStart, wordEnd));
210
263
  }
211
264
 
212
265
  if (offset > 0) {
@@ -232,6 +285,11 @@ export interface ApiReply {
232
285
  tag?: string;
233
286
  /** Raw words exactly as received, including the leading reply word. */
234
287
  words: readonly string[];
288
+ /**
289
+ * Set by `listen()` on a reply that ARRIVED after its signal aborted (the
290
+ * replies a `/cancel` drains), however long it then waited in the queue.
291
+ */
292
+ afterStop?: true;
235
293
  }
236
294
 
237
295
  /** Parse a raw sentence (array of words) into a structured reply. */
@@ -263,6 +321,19 @@ export function parseReply(words: readonly string[]): ApiReply {
263
321
  return { type, attributes, tag, words };
264
322
  }
265
323
 
324
+ const TAG_PREFIX = utf8Encoder.encode(".tag=");
325
+
326
+ /** The `.tag=` value of a raw sentence; ASCII, so readable before decoding. */
327
+ function sentenceTag(words: readonly Uint8Array[]): string | undefined {
328
+ for (let index = 1; index < words.length; index += 1) {
329
+ const word = words[index] as Uint8Array;
330
+ if (TAG_PREFIX.every((byte, at) => word[at] === byte)) {
331
+ return decodeWord(word.subarray(TAG_PREFIX.length));
332
+ }
333
+ }
334
+ return undefined;
335
+ }
336
+
266
337
  /** Read an attribute by name (variable key avoids index-signature pitfalls). */
267
338
  export function readAttribute(
268
339
  reply: ApiReply,
@@ -338,10 +409,18 @@ export interface NativeApiCommand {
338
409
  queries?: readonly string[];
339
410
  /** `.proplist` restriction (comma-joined when an array). */
340
411
  proplist?: readonly string[] | string;
412
+ /**
413
+ * Every word, sent and received, is a byte string (see
414
+ * {@link byteStringBytes}), not UTF-8 text. Only `transfer`'s `/file`
415
+ * contents need it (#404); a non-ASCII value such as a file name must be
416
+ * passed as its UTF-8 byte string.
417
+ */
418
+ binary?: boolean;
341
419
  }
342
420
 
343
421
  interface PendingCommand {
344
422
  command: string;
423
+ binary: boolean;
345
424
  records: ApiReply[];
346
425
  trap?: ApiReply;
347
426
  resolve: (replies: ApiReply[]) => void;
@@ -349,19 +428,18 @@ interface PendingCommand {
349
428
  }
350
429
 
351
430
  /**
352
- * A long-lived `/listen` subscription. Unlike {@link PendingCommand} it never
431
+ * An incremental command subscription. Unlike {@link PendingCommand} it never
353
432
  * buffers to a single resolve — each `!re` is pushed to the consumer as it
354
- * arrives, and the stream ends on `!done` (after a `/cancel`'s `interrupted`
355
- * trap) or fails on `!fatal`/transport closure.
433
+ * arrives, and ends on natural `!done`, acknowledged cancellation, or failure.
356
434
  */
357
435
  interface StreamingSubscription {
358
436
  command: string;
359
- /** An `!re` change frame arrived. */
437
+ /** An `!re` row or an `!empty` reply arrived. */
360
438
  push: (reply: ApiReply) => void;
361
439
  /** An `!trap` arrived (an `interrupted` trap is the normal `/cancel` path). */
362
440
  trap: (reply: ApiReply) => void;
363
441
  /** `!done` arrived — the listen closed. */
364
- settle: () => void;
442
+ settle: (reply: ApiReply) => void;
365
443
  /** `!fatal` or transport closure — the stream cannot continue. */
366
444
  fail: (error: CentrsError) => void;
367
445
  }
@@ -404,9 +482,9 @@ export class NativeApiSession {
404
482
 
405
483
  /** Feed inbound transport bytes into the parser. */
406
484
  handleData(chunk: Uint8Array): void {
407
- let sentences: string[][];
485
+ let sentences: Uint8Array[][];
408
486
  try {
409
- sentences = this.reader.push(chunk);
487
+ sentences = this.reader.pushRaw(chunk);
410
488
  } catch (error) {
411
489
  this.failAll(
412
490
  error instanceof CentrsError
@@ -425,10 +503,21 @@ export class NativeApiSession {
425
503
  if (words.length === 0) {
426
504
  continue;
427
505
  }
428
- this.dispatch(parseReply(words));
506
+ const binary = this.isBinaryReply(words);
507
+ this.dispatch(parseReply(words.map((word) => decodeWord(word, binary))));
429
508
  }
430
509
  }
431
510
 
511
+ /** A binary command's data replies; its `!trap` message stays UTF-8 text. */
512
+ private isBinaryReply(words: readonly Uint8Array[]): boolean {
513
+ const tag = sentenceTag(words);
514
+ return (
515
+ tag !== undefined &&
516
+ this.pending.get(tag)?.binary === true &&
517
+ decodeWord(words[0] as Uint8Array) !== "!trap"
518
+ );
519
+ }
520
+
432
521
  /** Notify the session the transport closed (optionally with an error). */
433
522
  handleClose(error?: Error): void {
434
523
  if (this.closed) {
@@ -458,6 +547,9 @@ export class NativeApiSession {
458
547
  context: { attributes: reply.attributes },
459
548
  });
460
549
  this.failAll(this.closeError);
550
+ // Fatal ends the session locally too: a peer that sends !fatal but
551
+ // leaves TCP open must not keep the CLI alive after its error summary.
552
+ this.sink.close();
461
553
  return;
462
554
  }
463
555
 
@@ -496,17 +588,18 @@ export class NativeApiSession {
496
588
  const subscription = this.subscriptions.get(tag);
497
589
  if (subscription !== undefined) {
498
590
  switch (reply.type) {
591
+ // `!empty` is a reply in its own right (a zero-row tick, an empty
592
+ // finite read), so a stream consumer sees it like a row (#402).
499
593
  case "!re":
594
+ case "!empty":
500
595
  subscription.push(reply);
501
596
  return;
502
597
  case "!trap":
503
598
  subscription.trap(reply);
504
599
  return;
505
- case "!empty":
506
- return;
507
600
  case "!done":
508
601
  this.subscriptions.delete(tag);
509
- subscription.settle();
602
+ subscription.settle(reply);
510
603
  return;
511
604
  default:
512
605
  return;
@@ -601,12 +694,13 @@ export class NativeApiSession {
601
694
  return new Promise<ApiReply[]>((resolve, reject) => {
602
695
  this.pending.set(tag, {
603
696
  command: command.command,
697
+ binary: command.binary === true,
604
698
  records: [],
605
699
  resolve,
606
700
  reject,
607
701
  });
608
702
  try {
609
- this.sink.write(encodeSentence(words));
703
+ this.sink.write(encodeSentence(words, command.binary));
610
704
  } catch (error) {
611
705
  this.pending.delete(tag);
612
706
  reject(
@@ -624,22 +718,44 @@ export class NativeApiSession {
624
718
  }
625
719
 
626
720
  /**
627
- * Open a `/listen` subscription and yield each `!re` change frame as it
628
- * arrives (the open-ended follow path; the listen command never sends `!done`
629
- * on its own). The generator ends cleanly when the listen is cancelled:
721
+ * Stream a native command and yield each `!re` and `!empty` as it arrives. Finite commands
722
+ * end on `!done`; `/listen` needs cancellation. The generator ends cleanly:
630
723
  * - via `options.signal` aborting — the subscription stays registered, so the
631
724
  * resulting `interrupted` trap + `!done` are drained and the loop ends
632
725
  * without throwing;
633
726
  * - via the consumer breaking out of the `for await` — `finally` sends
634
727
  * `/cancel` best-effort (the subscription is already torn down, so the
635
728
  * trap/`!done` are not awaited) and the generator returns.
636
- * A non-`interrupted` trap (e.g. a bad path) or `!fatal`/transport closure
637
- * throws. CHR-grounded (7.23.1): see `commands/api/AGENTS.md`.
729
+ * A trap throws unless it acknowledges our cancellation; unsolicited
730
+ * interruption, `!fatal`, and transport closure are failures. CHR grounding
731
+ * lives in `commands/api/AGENTS.md` and the command examples.
732
+ *
733
+ * After an abort, `cancelGraceMs` bounds the wait for that acknowledgement: a
734
+ * peer that never answers `/cancel` would otherwise hold the generator open
735
+ * forever (#385). On expiry the generator ends without the router's
736
+ * `!done` and `onCancelUnacknowledged` fires, so the caller can close the
737
+ * session and report that the stop was local, not acknowledged.
638
738
  */
639
739
  async *listen(
640
740
  command: NativeApiCommand,
641
- options: { signal?: AbortSignal; onListening?: () => void } = {},
741
+ options: {
742
+ signal?: AbortSignal;
743
+ onListening?: () => void;
744
+ cancelGraceMs?: number;
745
+ onCancelUnacknowledged?: () => void;
746
+ /** Terminal attributes, distinct from the streamed `!re` rows. */
747
+ onDone?: (attributes: Record<string, string>) => void;
748
+ /**
749
+ * Every reply for this tag (`!re`, `!empty`, `!trap`, `!done`), called
750
+ * synchronously as it is routed, before the generator yields it. Taps
751
+ * on several concurrent commands see one connection's wire order.
752
+ */
753
+ onReply?: (reply: ApiReply) => void;
754
+ } = {},
642
755
  ): AsyncGenerator<ApiReply, void, void> {
756
+ // A cancelled caller must not dispatch a command, especially now that
757
+ // this path streams writes as well as read-only change subscriptions.
758
+ if (options.signal?.aborted) return;
643
759
  if (this.closed) {
644
760
  throw (
645
761
  this.closeError ??
@@ -658,6 +774,13 @@ export class NativeApiSession {
658
774
  let ended = false;
659
775
  let failure: CentrsError | undefined;
660
776
  let trap: ApiReply | undefined;
777
+ let trapAcknowledgesCancel = false;
778
+ const assertNoFailure = (): void => {
779
+ if (failure) throw failure;
780
+ if (trap && !trapAcknowledgesCancel) {
781
+ throw this.trapToError(command.command, trap);
782
+ }
783
+ };
661
784
  const signalWake = (): void => {
662
785
  const resume = wake;
663
786
  wake = undefined;
@@ -667,13 +790,21 @@ export class NativeApiSession {
667
790
  this.subscriptions.set(tag, {
668
791
  command: command.command,
669
792
  push: (reply) => {
670
- queue.push(reply);
793
+ options.onReply?.(reply);
794
+ queue.push(
795
+ options.signal?.aborted ? { ...reply, afterStop: true } : reply,
796
+ );
671
797
  signalWake();
672
798
  },
673
799
  trap: (reply) => {
800
+ options.onReply?.(reply);
674
801
  trap = reply;
802
+ trapAcknowledgesCancel =
803
+ isInterruptedTrap(reply) && !!options.signal?.aborted;
675
804
  },
676
- settle: () => {
805
+ settle: (reply) => {
806
+ options.onReply?.(reply);
807
+ options.onDone?.(reply.attributes);
677
808
  ended = true;
678
809
  signalWake();
679
810
  },
@@ -702,10 +833,24 @@ export class NativeApiSession {
702
833
  // can act on a real barrier rather than a blind timer.
703
834
  options.onListening?.();
704
835
 
705
- const onAbort = (): void => this.cancel(tag);
836
+ let graceTimer: ReturnType<typeof setTimeout> | undefined;
837
+ const onAbort = (): void => {
838
+ this.cancel(tag);
839
+ if (options.cancelGraceMs === undefined) return;
840
+ graceTimer = setTimeout(() => {
841
+ if (ended) return;
842
+ // Stop routing this tag and drop anything queued: a peer that ignores
843
+ // `/cancel` but keeps sending frames must not keep the loop draining.
844
+ this.subscriptions.delete(tag);
845
+ queue.length = 0;
846
+ ended = true;
847
+ options.onCancelUnacknowledged?.();
848
+ signalWake();
849
+ }, options.cancelGraceMs);
850
+ };
706
851
  if (options.signal) {
707
852
  if (options.signal.aborted) {
708
- this.cancel(tag);
853
+ onAbort();
709
854
  } else {
710
855
  options.signal.addEventListener("abort", onAbort, { once: true });
711
856
  }
@@ -731,19 +876,17 @@ export class NativeApiSession {
731
876
  });
732
877
  }
733
878
  }
734
- if (failure) {
735
- throw failure;
736
- }
737
- if (trap && !isInterruptedTrap(trap)) {
738
- throw this.trapToError(command.command, trap);
739
- }
740
879
  } finally {
880
+ clearTimeout(graceTimer);
741
881
  options.signal?.removeEventListener("abort", onAbort);
742
882
  this.subscriptions.delete(tag);
743
883
  // Consumer broke out early (e.g. --count reached): tell the router to stop.
744
884
  if (!ended && !this.closed) {
745
885
  this.cancel(tag);
746
886
  }
887
+ // Consumer return (including --count) must not hide a trap/close
888
+ // already received alongside the last row in the same TCP chunk.
889
+ assertNoFailure();
747
890
  }
748
891
  }
749
892
 
@@ -826,6 +969,8 @@ export interface ConnectNativeApiOptions {
826
969
  rejectUnauthorized?: boolean;
827
970
  /** Connection timeout in milliseconds. */
828
971
  timeoutMs?: number;
972
+ /** Cancel TCP/TLS connection and login before dispatching any command. */
973
+ signal?: AbortSignal;
829
974
  }
830
975
 
831
976
  interface ConnectedNativeApi {
@@ -841,58 +986,83 @@ export async function connectNativeApi(
841
986
  ): Promise<ConnectedNativeApi> {
842
987
  const port =
843
988
  options.port ?? (options.tls ? NATIVE_API_TLS_PORT : NATIVE_API_PORT);
844
- const endpoint = `${options.host}:${port}`;
989
+ const endpoint = formatEndpoint(options.host, port);
845
990
  const timeoutMs = options.timeoutMs ?? 15_000;
991
+ options.signal?.throwIfAborted();
846
992
 
847
- let session: NativeApiSession | undefined;
848
- const connectPromise = Bun.connect({
849
- hostname: options.host,
850
- port,
851
- ...(options.tls
852
- ? {
853
- tls: {
854
- rejectUnauthorized: options.rejectUnauthorized ?? false,
855
- },
856
- }
857
- : {}),
858
- socket: {
859
- data(_socket, data) {
860
- session?.handleData(new Uint8Array(data));
861
- },
862
- close() {
863
- session?.handleClose();
864
- },
865
- error(_socket, error) {
866
- session?.handleClose(error);
867
- },
868
- },
869
- }).catch((error: unknown) => {
870
- throw mapConnectError(error, endpoint);
871
- });
872
-
873
- const socket = await withTimeout(connectPromise, timeoutMs, endpoint);
874
- session = new NativeApiSession({
993
+ // Own the socket before TCP/TLS completes: aborting only the connect promise
994
+ // leaves a pending connection alive and can prevent the CLI from exiting.
995
+ const socket = options.tls
996
+ ? connectTls({
997
+ host: options.host,
998
+ port,
999
+ rejectUnauthorized: options.rejectUnauthorized ?? false,
1000
+ ...(isIP(options.host) ? {} : { servername: options.host }),
1001
+ })
1002
+ : createConnection({ host: options.host, port });
1003
+ socket.setNoDelay(true);
1004
+ const session = new NativeApiSession({
875
1005
  endpoint,
876
1006
  sink: {
877
1007
  write(bytes) {
878
1008
  socket.write(bytes);
879
1009
  },
880
1010
  close() {
881
- socket.end();
1011
+ socket.destroy();
882
1012
  },
883
1013
  },
884
1014
  });
1015
+ socket.on("data", (data: Buffer) => session.handleData(data));
1016
+ socket.on("close", () => session.handleClose());
1017
+ socket.on("error", (error) => session.handleClose(error));
885
1018
 
1019
+ const readyEvent = options.tls ? "secureConnect" : "connect";
1020
+ const connectPromise = new Promise<void>((resolve, reject) => {
1021
+ const cleanup = () => {
1022
+ socket.removeListener(readyEvent, onConnect);
1023
+ socket.removeListener("error", onError);
1024
+ socket.removeListener("close", onClose);
1025
+ };
1026
+ const onConnect = () => {
1027
+ cleanup();
1028
+ resolve();
1029
+ };
1030
+ const onError = (error: Error) => {
1031
+ cleanup();
1032
+ reject(mapConnectError(error, endpoint));
1033
+ };
1034
+ const onClose = () => {
1035
+ cleanup();
1036
+ reject(
1037
+ new CentrsError({
1038
+ code: "transport/connection-closed",
1039
+ summary: `The RouterOS API connection to ${endpoint} closed before it was ready.`,
1040
+ remediation:
1041
+ "Confirm the API service and network path are available.",
1042
+ }),
1043
+ );
1044
+ };
1045
+ socket.once(readyEvent, onConnect);
1046
+ socket.once("error", onError);
1047
+ socket.once("close", onClose);
1048
+ });
1049
+ const onAbort = (): void => session.close();
1050
+ options.signal?.addEventListener("abort", onAbort, { once: true });
886
1051
  try {
1052
+ options.signal?.throwIfAborted();
1053
+ await withTimeout(connectPromise, timeoutMs, endpoint, options.signal);
887
1054
  await withTimeout(
888
1055
  session.login(options.username, options.password),
889
1056
  timeoutMs,
890
1057
  endpoint,
1058
+ options.signal,
891
1059
  );
892
1060
  } catch (error) {
893
- // Never leave a half-open socket when login times out or is rejected.
1061
+ // Destroy even a pending connection when its deadline or login fails.
894
1062
  session.close();
895
1063
  throw error;
1064
+ } finally {
1065
+ options.signal?.removeEventListener("abort", onAbort);
896
1066
  }
897
1067
  return { session };
898
1068
  }
@@ -901,9 +1071,17 @@ async function withTimeout<T>(
901
1071
  promise: Promise<T>,
902
1072
  timeoutMs: number,
903
1073
  endpoint: string,
1074
+ signal?: AbortSignal,
904
1075
  ): Promise<T> {
905
1076
  let handle: ReturnType<typeof setTimeout> | undefined;
1077
+ let onAbort: (() => void) | undefined;
906
1078
  const timeout = new Promise<never>((_resolve, reject) => {
1079
+ onAbort = () =>
1080
+ reject(
1081
+ signal?.reason ?? new DOMException("Operation cancelled", "AbortError"),
1082
+ );
1083
+ if (signal?.aborted) onAbort();
1084
+ else signal?.addEventListener("abort", onAbort, { once: true });
907
1085
  handle = setTimeout(() => {
908
1086
  reject(
909
1087
  new CentrsError({
@@ -918,6 +1096,7 @@ async function withTimeout<T>(
918
1096
  try {
919
1097
  return await Promise.race([promise, timeout]);
920
1098
  } finally {
1099
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
921
1100
  if (handle !== undefined) {
922
1101
  clearTimeout(handle);
923
1102
  }