@tikoci/centrs 0.1.1 → 0.1.2
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/CHANGELOG.md +48 -2
- package/GLOSSARY.txt +16 -0
- package/README.md +1 -1
- package/commands/AGENTS.md +3 -2
- package/commands/api/AGENTS.md +212 -0
- package/commands/api/README.md +159 -0
- package/commands/api/examples.md +325 -0
- package/commands/btest/README.md +56 -31
- package/commands/btest/examples.md +35 -10
- package/commands/execute/README.md +9 -3
- package/commands/retrieve/README.md +1 -1
- package/docs/CONSTITUTION.md +6 -6
- package/docs/MATRIX.md +46 -15
- package/docs/errors/routeros/btest-connection-count-single-stream.md +22 -0
- package/docs/errors/routeros/btest-udp-tx-size-ignored.md +15 -0
- package/docs/errors/usage/invalid-method.md +23 -0
- package/package.json +5 -5
- package/src/api.ts +1555 -0
- package/src/btest.ts +61 -1
- package/src/cli/api.ts +536 -0
- package/src/cli/common.ts +64 -0
- package/src/cli.ts +22 -0
- package/src/core/error-catalog.ts +14 -0
- package/src/core/fanout.ts +160 -0
- package/src/core/inspect.ts +170 -0
- package/src/execute.ts +14 -47
- package/src/index.ts +61 -8
- package/src/protocols/adapter.ts +328 -0
- package/src/protocols/btest-session.ts +279 -46
- package/src/protocols/btest.ts +6 -2
- package/src/protocols/native-api.ts +223 -30
- package/src/retrieve-fanout.ts +13 -141
- package/src/retrieve.ts +30 -87
- package/commands/stream/README.md +0 -70
|
@@ -348,6 +348,32 @@ interface PendingCommand {
|
|
|
348
348
|
reject: (error: CentrsError) => void;
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
+
/**
|
|
352
|
+
* A long-lived `/listen` subscription. Unlike {@link PendingCommand} it never
|
|
353
|
+
* 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.
|
|
356
|
+
*/
|
|
357
|
+
interface StreamingSubscription {
|
|
358
|
+
command: string;
|
|
359
|
+
/** An `!re` change frame arrived. */
|
|
360
|
+
push: (reply: ApiReply) => void;
|
|
361
|
+
/** An `!trap` arrived (an `interrupted` trap is the normal `/cancel` path). */
|
|
362
|
+
trap: (reply: ApiReply) => void;
|
|
363
|
+
/** `!done` arrived — the listen closed. */
|
|
364
|
+
settle: () => void;
|
|
365
|
+
/** `!fatal` or transport closure — the stream cannot continue. */
|
|
366
|
+
fail: (error: CentrsError) => void;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** A `/cancel`'s `interrupted` trap (category 2) is the expected end of a listen, not an error. */
|
|
370
|
+
function isInterruptedTrap(trap: ApiReply): boolean {
|
|
371
|
+
const message = readAttribute(trap, "message") ?? "";
|
|
372
|
+
return (
|
|
373
|
+
/interrupted/i.test(message) || readAttribute(trap, "category") === "2"
|
|
374
|
+
);
|
|
375
|
+
}
|
|
376
|
+
|
|
351
377
|
export interface NativeApiSessionOptions {
|
|
352
378
|
sink: NativeApiByteSink;
|
|
353
379
|
/** Host/port label used in error envelopes. */
|
|
@@ -366,6 +392,7 @@ export class NativeApiSession {
|
|
|
366
392
|
private readonly endpoint: string;
|
|
367
393
|
private readonly reader = new SentenceReader();
|
|
368
394
|
private readonly pending = new Map<string, PendingCommand>();
|
|
395
|
+
private readonly subscriptions = new Map<string, StreamingSubscription>();
|
|
369
396
|
private tagCounter = 0;
|
|
370
397
|
private closed = false;
|
|
371
398
|
private closeError: CentrsError | undefined;
|
|
@@ -439,33 +466,51 @@ export class NativeApiSession {
|
|
|
439
466
|
return; // untagged async/system notice — no waiter to route to
|
|
440
467
|
}
|
|
441
468
|
const waiter = this.pending.get(tag);
|
|
442
|
-
if (waiter
|
|
443
|
-
|
|
469
|
+
if (waiter !== undefined) {
|
|
470
|
+
switch (reply.type) {
|
|
471
|
+
case "!re":
|
|
472
|
+
waiter.records.push(reply);
|
|
473
|
+
return;
|
|
474
|
+
case "!trap":
|
|
475
|
+
waiter.trap = reply;
|
|
476
|
+
return;
|
|
477
|
+
case "!empty":
|
|
478
|
+
return;
|
|
479
|
+
case "!done": {
|
|
480
|
+
this.pending.delete(tag);
|
|
481
|
+
if (waiter.trap) {
|
|
482
|
+
waiter.reject(this.trapToError(waiter.command, waiter.trap));
|
|
483
|
+
return;
|
|
484
|
+
}
|
|
485
|
+
if (Object.keys(reply.attributes).length > 0) {
|
|
486
|
+
waiter.records.push(reply);
|
|
487
|
+
}
|
|
488
|
+
waiter.resolve(waiter.records);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
default:
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
444
494
|
}
|
|
445
495
|
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
496
|
+
const subscription = this.subscriptions.get(tag);
|
|
497
|
+
if (subscription !== undefined) {
|
|
498
|
+
switch (reply.type) {
|
|
499
|
+
case "!re":
|
|
500
|
+
subscription.push(reply);
|
|
501
|
+
return;
|
|
502
|
+
case "!trap":
|
|
503
|
+
subscription.trap(reply);
|
|
504
|
+
return;
|
|
505
|
+
case "!empty":
|
|
506
|
+
return;
|
|
507
|
+
case "!done":
|
|
508
|
+
this.subscriptions.delete(tag);
|
|
509
|
+
subscription.settle();
|
|
510
|
+
return;
|
|
511
|
+
default:
|
|
459
512
|
return;
|
|
460
|
-
}
|
|
461
|
-
if (Object.keys(reply.attributes).length > 0) {
|
|
462
|
-
waiter.records.push(reply);
|
|
463
|
-
}
|
|
464
|
-
waiter.resolve(waiter.records);
|
|
465
|
-
return;
|
|
466
513
|
}
|
|
467
|
-
default:
|
|
468
|
-
return;
|
|
469
514
|
}
|
|
470
515
|
}
|
|
471
516
|
|
|
@@ -503,6 +548,11 @@ export class NativeApiSession {
|
|
|
503
548
|
for (const waiter of waiters) {
|
|
504
549
|
waiter.reject(error);
|
|
505
550
|
}
|
|
551
|
+
const subscriptions = [...this.subscriptions.values()];
|
|
552
|
+
this.subscriptions.clear();
|
|
553
|
+
for (const subscription of subscriptions) {
|
|
554
|
+
subscription.fail(error);
|
|
555
|
+
}
|
|
506
556
|
}
|
|
507
557
|
|
|
508
558
|
private nextTag(): string {
|
|
@@ -573,6 +623,145 @@ export class NativeApiSession {
|
|
|
573
623
|
});
|
|
574
624
|
}
|
|
575
625
|
|
|
626
|
+
/**
|
|
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:
|
|
630
|
+
* - via `options.signal` aborting — the subscription stays registered, so the
|
|
631
|
+
* resulting `interrupted` trap + `!done` are drained and the loop ends
|
|
632
|
+
* without throwing;
|
|
633
|
+
* - via the consumer breaking out of the `for await` — `finally` sends
|
|
634
|
+
* `/cancel` best-effort (the subscription is already torn down, so the
|
|
635
|
+
* 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`.
|
|
638
|
+
*/
|
|
639
|
+
async *listen(
|
|
640
|
+
command: NativeApiCommand,
|
|
641
|
+
options: { signal?: AbortSignal; onListening?: () => void } = {},
|
|
642
|
+
): AsyncGenerator<ApiReply, void, void> {
|
|
643
|
+
if (this.closed) {
|
|
644
|
+
throw (
|
|
645
|
+
this.closeError ??
|
|
646
|
+
new CentrsError({
|
|
647
|
+
code: "transport/connection-closed",
|
|
648
|
+
summary: `The RouterOS API session to ${this.endpoint} is closed.`,
|
|
649
|
+
remediation: "Open a new connection before listening.",
|
|
650
|
+
})
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
const tag = this.nextTag();
|
|
654
|
+
const words = this.buildWords(command, tag);
|
|
655
|
+
|
|
656
|
+
const queue: ApiReply[] = [];
|
|
657
|
+
let wake: (() => void) | undefined;
|
|
658
|
+
let ended = false;
|
|
659
|
+
let failure: CentrsError | undefined;
|
|
660
|
+
let trap: ApiReply | undefined;
|
|
661
|
+
const signalWake = (): void => {
|
|
662
|
+
const resume = wake;
|
|
663
|
+
wake = undefined;
|
|
664
|
+
resume?.();
|
|
665
|
+
};
|
|
666
|
+
|
|
667
|
+
this.subscriptions.set(tag, {
|
|
668
|
+
command: command.command,
|
|
669
|
+
push: (reply) => {
|
|
670
|
+
queue.push(reply);
|
|
671
|
+
signalWake();
|
|
672
|
+
},
|
|
673
|
+
trap: (reply) => {
|
|
674
|
+
trap = reply;
|
|
675
|
+
},
|
|
676
|
+
settle: () => {
|
|
677
|
+
ended = true;
|
|
678
|
+
signalWake();
|
|
679
|
+
},
|
|
680
|
+
fail: (error) => {
|
|
681
|
+
failure = error;
|
|
682
|
+
ended = true;
|
|
683
|
+
signalWake();
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
try {
|
|
688
|
+
this.sink.write(encodeSentence(words));
|
|
689
|
+
} catch (error) {
|
|
690
|
+
this.subscriptions.delete(tag);
|
|
691
|
+
throw error instanceof CentrsError
|
|
692
|
+
? error
|
|
693
|
+
: new CentrsError({
|
|
694
|
+
code: "transport/network",
|
|
695
|
+
summary: `Failed to open a listen on the RouterOS API connection to ${this.endpoint}.`,
|
|
696
|
+
remediation: "Retry; confirm the connection is still open.",
|
|
697
|
+
cause: error,
|
|
698
|
+
});
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// The subscription is on the wire — signal "listening established" so callers
|
|
702
|
+
// can act on a real barrier rather than a blind timer.
|
|
703
|
+
options.onListening?.();
|
|
704
|
+
|
|
705
|
+
const onAbort = (): void => this.cancel(tag);
|
|
706
|
+
if (options.signal) {
|
|
707
|
+
if (options.signal.aborted) {
|
|
708
|
+
this.cancel(tag);
|
|
709
|
+
} else {
|
|
710
|
+
options.signal.addEventListener("abort", onAbort, { once: true });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
try {
|
|
715
|
+
for (;;) {
|
|
716
|
+
if (queue.length > 0) {
|
|
717
|
+
// Drain the whole batch at once: repeated `shift()` is O(n) per frame
|
|
718
|
+
// (it shifts every remaining element), so a high-volume listen would
|
|
719
|
+
// be O(n²). New
|
|
720
|
+
// frames pushed while we yield land in the freshly emptied queue and
|
|
721
|
+
// drain on the next pass.
|
|
722
|
+
const batch = queue.splice(0, queue.length);
|
|
723
|
+
for (const reply of batch) {
|
|
724
|
+
yield reply;
|
|
725
|
+
}
|
|
726
|
+
} else if (ended) {
|
|
727
|
+
break;
|
|
728
|
+
} else {
|
|
729
|
+
await new Promise<void>((resolve) => {
|
|
730
|
+
wake = resolve;
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
if (failure) {
|
|
735
|
+
throw failure;
|
|
736
|
+
}
|
|
737
|
+
if (trap && !isInterruptedTrap(trap)) {
|
|
738
|
+
throw this.trapToError(command.command, trap);
|
|
739
|
+
}
|
|
740
|
+
} finally {
|
|
741
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
742
|
+
this.subscriptions.delete(tag);
|
|
743
|
+
// Consumer broke out early (e.g. --count reached): tell the router to stop.
|
|
744
|
+
if (!ended && !this.closed) {
|
|
745
|
+
this.cancel(tag);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Cancel an in-flight `/listen` by its tag (`/cancel =tag=<tag> .tag=<new>`). */
|
|
751
|
+
private cancel(tag: string): void {
|
|
752
|
+
if (this.closed) {
|
|
753
|
+
return;
|
|
754
|
+
}
|
|
755
|
+
const cancelTag = this.nextTag();
|
|
756
|
+
try {
|
|
757
|
+
this.sink.write(
|
|
758
|
+
encodeSentence(["/cancel", `=tag=${tag}`, `.tag=${cancelTag}`]),
|
|
759
|
+
);
|
|
760
|
+
} catch {
|
|
761
|
+
// Best effort — the connection may already be gone.
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
576
765
|
/**
|
|
577
766
|
* Authenticate. Uses the combined approach: send name+password; if the
|
|
578
767
|
* router answers with a legacy `=ret=` challenge, compute the MD5 response
|
|
@@ -607,16 +796,20 @@ export class NativeApiSession {
|
|
|
607
796
|
try {
|
|
608
797
|
this.sink.close();
|
|
609
798
|
} finally {
|
|
799
|
+
const closeError = new CentrsError({
|
|
800
|
+
code: "transport/connection-closed",
|
|
801
|
+
summary: `The RouterOS API session to ${this.endpoint} was closed by the caller.`,
|
|
802
|
+
remediation: "Open a new connection before sending more commands.",
|
|
803
|
+
});
|
|
610
804
|
const waiters = [...this.pending.values()];
|
|
611
805
|
this.pending.clear();
|
|
612
806
|
for (const waiter of waiters) {
|
|
613
|
-
waiter.reject(
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
);
|
|
807
|
+
waiter.reject(closeError);
|
|
808
|
+
}
|
|
809
|
+
const subscriptions = [...this.subscriptions.values()];
|
|
810
|
+
this.subscriptions.clear();
|
|
811
|
+
for (const subscription of subscriptions) {
|
|
812
|
+
subscription.fail(closeError);
|
|
620
813
|
}
|
|
621
814
|
}
|
|
622
815
|
}
|
package/src/retrieve-fanout.ts
CHANGED
|
@@ -32,6 +32,13 @@ import type {
|
|
|
32
32
|
FanoutData,
|
|
33
33
|
FanoutSummary,
|
|
34
34
|
} from "./core/envelope.ts";
|
|
35
|
+
import {
|
|
36
|
+
defaultFanoutSleep,
|
|
37
|
+
resolveFanoutConcurrency,
|
|
38
|
+
runBoundedPool,
|
|
39
|
+
runWithRetry,
|
|
40
|
+
summarizeFanout,
|
|
41
|
+
} from "./core/fanout.ts";
|
|
35
42
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
36
43
|
import { plannedProtocols, type RouterOsProtocol } from "./protocols/index.ts";
|
|
37
44
|
import {
|
|
@@ -57,27 +64,6 @@ import {
|
|
|
57
64
|
validateRetrieveRequestShape,
|
|
58
65
|
} from "./retrieve.ts";
|
|
59
66
|
|
|
60
|
-
/** Transport-aware default worker-pool size (REST drops parallel POSTs above ~8). */
|
|
61
|
-
export const RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS: Record<
|
|
62
|
-
"rest-api" | "native-api",
|
|
63
|
-
number
|
|
64
|
-
> = {
|
|
65
|
-
"rest-api": 8,
|
|
66
|
-
"native-api": 4,
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
/** Extra attempts after the first try (jittered backoff between them). */
|
|
70
|
-
export const RETRIEVE_FANOUT_MAX_RETRIES = 2;
|
|
71
|
-
|
|
72
|
-
/** Base backoff in milliseconds; doubled per attempt with up to +base jitter. */
|
|
73
|
-
export const RETRIEVE_FANOUT_BACKOFF_BASE_MS = 200;
|
|
74
|
-
|
|
75
|
-
/** RouterOS-style codes that are safe to retry under fanout. */
|
|
76
|
-
export const RETRIEVE_FANOUT_RETRYABLE_CODES = [
|
|
77
|
-
"transport/network",
|
|
78
|
-
"transport/connection-closed",
|
|
79
|
-
] as const;
|
|
80
|
-
|
|
81
67
|
export interface RetrieveFanoutOperationMeta {
|
|
82
68
|
kind: "fanout";
|
|
83
69
|
group: string;
|
|
@@ -176,7 +162,7 @@ export async function retrieveGroup(
|
|
|
176
162
|
}
|
|
177
163
|
|
|
178
164
|
const execute = internals.execute ?? runResolvedRetrieve;
|
|
179
|
-
const sleep = internals.sleep ??
|
|
165
|
+
const sleep = internals.sleep ?? defaultFanoutSleep;
|
|
180
166
|
|
|
181
167
|
const targets = await runBoundedPool(
|
|
182
168
|
expansion.targets,
|
|
@@ -199,7 +185,11 @@ export async function retrieveGroup(
|
|
|
199
185
|
} catch (error) {
|
|
200
186
|
return buildResolveFailureEnvelope(member.resolution, error);
|
|
201
187
|
}
|
|
202
|
-
return
|
|
188
|
+
return runWithRetry<RetrieveEnvelope>(
|
|
189
|
+
() => execute(resolved, member.recordIndex),
|
|
190
|
+
(error) => buildRetrieveErrorEnvelopeFromResolved(resolved, error),
|
|
191
|
+
{ sleep },
|
|
192
|
+
);
|
|
203
193
|
},
|
|
204
194
|
);
|
|
205
195
|
|
|
@@ -251,28 +241,6 @@ export function buildRetrieveFanoutErrorEnvelope(
|
|
|
251
241
|
};
|
|
252
242
|
}
|
|
253
243
|
|
|
254
|
-
/** Resolve and validate the worker-pool size. */
|
|
255
|
-
export function resolveFanoutConcurrency(
|
|
256
|
-
requested: number | undefined,
|
|
257
|
-
via: RouterOsProtocol,
|
|
258
|
-
): number {
|
|
259
|
-
if (requested !== undefined) {
|
|
260
|
-
if (!Number.isInteger(requested) || requested < 1) {
|
|
261
|
-
throw new CentrsError({
|
|
262
|
-
code: "usage/invalid-concurrency",
|
|
263
|
-
summary: `--concurrency must be an integer >= 1. Received: ${requested}`,
|
|
264
|
-
remediation: "Pass a positive integer, e.g. `--concurrency 4`.",
|
|
265
|
-
context: { concurrency: requested },
|
|
266
|
-
});
|
|
267
|
-
}
|
|
268
|
-
return requested;
|
|
269
|
-
}
|
|
270
|
-
if (via === "native-api") {
|
|
271
|
-
return RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS["native-api"];
|
|
272
|
-
}
|
|
273
|
-
return RETRIEVE_FANOUT_CONCURRENCY_DEFAULTS["rest-api"];
|
|
274
|
-
}
|
|
275
|
-
|
|
276
244
|
function resolveFanoutConcurrencyProtocol(
|
|
277
245
|
request: RetrieveRequest,
|
|
278
246
|
env: Record<string, string | undefined>,
|
|
@@ -292,93 +260,6 @@ function resolveFanoutConcurrencyProtocol(
|
|
|
292
260
|
return globalVia;
|
|
293
261
|
}
|
|
294
262
|
|
|
295
|
-
/**
|
|
296
|
-
* Classify a per-target failure as retryable. Only the locked allowlist
|
|
297
|
-
* retries: generic transport/network failures and dropped/closed connections
|
|
298
|
-
* (including REST 5xx mapped to `transport/connection-closed`). Router-side
|
|
299
|
-
* (`routeros/*`), validation, auth, cdb, target, input, usage, timeout, TLS,
|
|
300
|
-
* connection-refused, and DNS failures are deterministic and never retried.
|
|
301
|
-
*/
|
|
302
|
-
export function isRetryableFanoutError(error: unknown): boolean {
|
|
303
|
-
if (!(error instanceof CentrsError)) {
|
|
304
|
-
return false;
|
|
305
|
-
}
|
|
306
|
-
return (RETRIEVE_FANOUT_RETRYABLE_CODES as readonly string[]).includes(
|
|
307
|
-
error.code,
|
|
308
|
-
);
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
export function summarizeFanout(
|
|
312
|
-
targets: readonly RetrieveEnvelope[],
|
|
313
|
-
): FanoutSummary {
|
|
314
|
-
let ok = 0;
|
|
315
|
-
let failed = 0;
|
|
316
|
-
for (const target of targets) {
|
|
317
|
-
if (target.ok) {
|
|
318
|
-
ok += 1;
|
|
319
|
-
} else {
|
|
320
|
-
failed += 1;
|
|
321
|
-
}
|
|
322
|
-
}
|
|
323
|
-
return { total: targets.length, ok, failed };
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
async function runTargetWithRetry(
|
|
327
|
-
resolved: ResolvedRetrieveRequest,
|
|
328
|
-
recordIndex: number,
|
|
329
|
-
execute: (
|
|
330
|
-
resolved: ResolvedRetrieveRequest,
|
|
331
|
-
recordIndex: number,
|
|
332
|
-
) => Promise<RetrieveSuccessEnvelope>,
|
|
333
|
-
sleep: (ms: number) => Promise<void>,
|
|
334
|
-
): Promise<RetrieveEnvelope> {
|
|
335
|
-
let attempt = 0;
|
|
336
|
-
for (;;) {
|
|
337
|
-
try {
|
|
338
|
-
return await execute(resolved, recordIndex);
|
|
339
|
-
} catch (error) {
|
|
340
|
-
if (
|
|
341
|
-
attempt < RETRIEVE_FANOUT_MAX_RETRIES &&
|
|
342
|
-
isRetryableFanoutError(error)
|
|
343
|
-
) {
|
|
344
|
-
attempt += 1;
|
|
345
|
-
await sleep(backoffMs(attempt));
|
|
346
|
-
continue;
|
|
347
|
-
}
|
|
348
|
-
return buildRetrieveErrorEnvelopeFromResolved(resolved, error);
|
|
349
|
-
}
|
|
350
|
-
}
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
/**
|
|
354
|
-
* Ordered bounded worker pool. Runs `worker` over `items` with at most
|
|
355
|
-
* `concurrency` in flight, returning results in INPUT order regardless of
|
|
356
|
-
* completion order.
|
|
357
|
-
*/
|
|
358
|
-
export async function runBoundedPool<I, O>(
|
|
359
|
-
items: readonly I[],
|
|
360
|
-
concurrency: number,
|
|
361
|
-
worker: (item: I, index: number) => Promise<O>,
|
|
362
|
-
): Promise<O[]> {
|
|
363
|
-
const results = new Array<O>(items.length);
|
|
364
|
-
let next = 0;
|
|
365
|
-
const width = Math.max(1, Math.min(concurrency, items.length));
|
|
366
|
-
|
|
367
|
-
async function drain(): Promise<void> {
|
|
368
|
-
for (;;) {
|
|
369
|
-
const index = next;
|
|
370
|
-
next += 1;
|
|
371
|
-
if (index >= items.length) {
|
|
372
|
-
return;
|
|
373
|
-
}
|
|
374
|
-
results[index] = await worker(items[index] as I, index);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
await Promise.all(Array.from({ length: width }, () => drain()));
|
|
379
|
-
return results;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
263
|
function buildResolveFailureEnvelope(
|
|
383
264
|
resolution: CdbResolution,
|
|
384
265
|
error: unknown,
|
|
@@ -460,15 +341,6 @@ function commonVia(
|
|
|
460
341
|
return via ?? null;
|
|
461
342
|
}
|
|
462
343
|
|
|
463
|
-
function backoffMs(attempt: number): number {
|
|
464
|
-
const base = RETRIEVE_FANOUT_BACKOFF_BASE_MS * 2 ** (attempt - 1);
|
|
465
|
-
return base + Math.floor(Math.random() * RETRIEVE_FANOUT_BACKOFF_BASE_MS);
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
function defaultSleep(ms: number): Promise<void> {
|
|
469
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
470
|
-
}
|
|
471
|
-
|
|
472
344
|
/**
|
|
473
345
|
* Render a fanout envelope. `json`/`yaml` serialize the whole envelope (lossless
|
|
474
346
|
* against the JSON shape); `text` renders a fanout summary line plus one line
|
package/src/retrieve.ts
CHANGED
|
@@ -6,6 +6,15 @@ import type {
|
|
|
6
6
|
SettingSource as CoreSettingSource,
|
|
7
7
|
EnvelopeValidationMeta,
|
|
8
8
|
} from "./core/envelope.ts";
|
|
9
|
+
import {
|
|
10
|
+
extractCompletionNames,
|
|
11
|
+
inspectChildren,
|
|
12
|
+
inspectChildrenOrEmpty,
|
|
13
|
+
inspectCompletions,
|
|
14
|
+
isArgumentNode,
|
|
15
|
+
isCommandNode,
|
|
16
|
+
pathTokens,
|
|
17
|
+
} from "./core/inspect.ts";
|
|
9
18
|
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
10
19
|
import {
|
|
11
20
|
createProtocolAdapter,
|
|
@@ -101,20 +110,6 @@ export type RetrieveSuccessEnvelope = CentrsSuccessEnvelope<
|
|
|
101
110
|
>;
|
|
102
111
|
export type RetrieveErrorEnvelope = CentrsErrorEnvelope<RetrieveOperationMeta>;
|
|
103
112
|
|
|
104
|
-
interface InspectChildItem {
|
|
105
|
-
type?: string;
|
|
106
|
-
name?: string;
|
|
107
|
-
"node-type"?: string;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
interface InspectCompletionItem {
|
|
111
|
-
type?: string;
|
|
112
|
-
name?: string;
|
|
113
|
-
completion?: string;
|
|
114
|
-
value?: string;
|
|
115
|
-
text?: string;
|
|
116
|
-
}
|
|
117
|
-
|
|
118
113
|
interface RetrieveInspection {
|
|
119
114
|
command: "get" | "print";
|
|
120
115
|
}
|
|
@@ -876,41 +871,19 @@ function resolveFormat(
|
|
|
876
871
|
}
|
|
877
872
|
|
|
878
873
|
/**
|
|
879
|
-
*
|
|
880
|
-
* {@link
|
|
881
|
-
*
|
|
882
|
-
*
|
|
883
|
-
*
|
|
884
|
-
*
|
|
885
|
-
* surface as-is.
|
|
874
|
+
* Resolve the read command (`print` vs `get`) for a path via a `request=child`
|
|
875
|
+
* existence probe. Uses {@link inspectChildrenOrEmpty} so an invalid path (a
|
|
876
|
+
* native trap, or REST's empty child list) flattens to no children and surfaces
|
|
877
|
+
* as a single `validation/unknown-path` here rather than leaking a transport
|
|
878
|
+
* trap. Attribute/completion discovery does NOT use the swallowing probe — traps
|
|
879
|
+
* there surface as-is.
|
|
886
880
|
*/
|
|
887
|
-
async function inspectChildrenForProbe(
|
|
888
|
-
backend: ProtocolAdapter,
|
|
889
|
-
path: string,
|
|
890
|
-
): Promise<unknown[]> {
|
|
891
|
-
try {
|
|
892
|
-
return await backend.inspect("child", path);
|
|
893
|
-
} catch (error) {
|
|
894
|
-
if (
|
|
895
|
-
error instanceof CentrsError &&
|
|
896
|
-
(error.code === "routeros/api-trap" ||
|
|
897
|
-
error.code === "routeros/unknown-path")
|
|
898
|
-
) {
|
|
899
|
-
return [];
|
|
900
|
-
}
|
|
901
|
-
throw error;
|
|
902
|
-
}
|
|
903
|
-
}
|
|
904
|
-
|
|
905
881
|
async function inspectRetrievePath(
|
|
906
882
|
resolved: ResolvedRetrieveRequest,
|
|
907
883
|
backend: ProtocolAdapter,
|
|
908
884
|
): Promise<RetrieveInspection> {
|
|
909
|
-
const
|
|
910
|
-
const rootChildren =
|
|
911
|
-
backend,
|
|
912
|
-
pathToInspectString(pathTokens),
|
|
913
|
-
)) as InspectChildItem[];
|
|
885
|
+
const tokens = pathTokens(resolved.path);
|
|
886
|
+
const rootChildren = await inspectChildrenOrEmpty(backend, tokens);
|
|
914
887
|
|
|
915
888
|
const supportsPrint = rootChildren.some((child) =>
|
|
916
889
|
isCommandNode(child, "print"),
|
|
@@ -943,21 +916,24 @@ async function inspectAttributes(
|
|
|
943
916
|
inspection: RetrieveInspection,
|
|
944
917
|
backend: ProtocolAdapter,
|
|
945
918
|
): Promise<string[]> {
|
|
946
|
-
const
|
|
919
|
+
const tokens = pathTokens(resolved.path);
|
|
947
920
|
const argument = inspection.command === "get" ? "value-name" : "proplist";
|
|
948
|
-
const completionRows =
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
921
|
+
const completionRows = await inspectCompletions(backend, [
|
|
922
|
+
...tokens,
|
|
923
|
+
inspection.command,
|
|
924
|
+
argument,
|
|
925
|
+
]);
|
|
926
|
+
const completions = [
|
|
927
|
+
...new Set(extractCompletionNames(completionRows)),
|
|
928
|
+
].sort();
|
|
953
929
|
if (completions.length > 0) {
|
|
954
930
|
return completions;
|
|
955
931
|
}
|
|
956
932
|
|
|
957
|
-
const commandChildren =
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
)
|
|
933
|
+
const commandChildren = await inspectChildren(backend, [
|
|
934
|
+
...tokens,
|
|
935
|
+
inspection.command,
|
|
936
|
+
]);
|
|
961
937
|
return commandChildren
|
|
962
938
|
.filter(isArgumentNode)
|
|
963
939
|
.map((child) => child.name)
|
|
@@ -985,35 +961,6 @@ async function executeRetrieve(
|
|
|
985
961
|
});
|
|
986
962
|
}
|
|
987
963
|
|
|
988
|
-
function pathTokensForInspect(path: string): string[] {
|
|
989
|
-
return path.split("/").filter(Boolean);
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
function isCommandNode(
|
|
993
|
-
child: InspectChildItem,
|
|
994
|
-
name: "get" | "print",
|
|
995
|
-
): boolean {
|
|
996
|
-
return (
|
|
997
|
-
child.name === name &&
|
|
998
|
-
(child.type === "cmd" || child["node-type"] === "cmd")
|
|
999
|
-
);
|
|
1000
|
-
}
|
|
1001
|
-
|
|
1002
|
-
function isArgumentNode(child: InspectChildItem): boolean {
|
|
1003
|
-
return child.type === "arg" || child["node-type"] === "arg";
|
|
1004
|
-
}
|
|
1005
|
-
|
|
1006
|
-
function extractCompletionNames(
|
|
1007
|
-
rows: readonly InspectCompletionItem[],
|
|
1008
|
-
): string[] {
|
|
1009
|
-
const names = rows
|
|
1010
|
-
.flatMap((row) => [row.completion, row.name, row.value, row.text])
|
|
1011
|
-
.filter((value): value is string => typeof value === "string")
|
|
1012
|
-
.map((value) => value.replace(/=.*$/, "").trim())
|
|
1013
|
-
.filter((value) => value.length > 0);
|
|
1014
|
-
return [...new Set(names)].sort();
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
964
|
function isKnownSingletonPath(path: string): boolean {
|
|
1018
965
|
return ["/system/resource", "/system/identity"].includes(
|
|
1019
966
|
path.replace(/\/$/, ""),
|
|
@@ -1122,10 +1069,6 @@ function parseOutputFormat(value: string): RetrieveOutputFormat {
|
|
|
1122
1069
|
});
|
|
1123
1070
|
}
|
|
1124
1071
|
|
|
1125
|
-
function pathToInspectString(path: readonly string[]): string {
|
|
1126
|
-
return path.join(",");
|
|
1127
|
-
}
|
|
1128
|
-
|
|
1129
1072
|
function countResultObjects(data: unknown): number {
|
|
1130
1073
|
if (Array.isArray(data)) {
|
|
1131
1074
|
return data.length;
|