rpc-bastion 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/dist/bin.cjs ADDED
@@ -0,0 +1,760 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ var fs = require('fs');
5
+ var kit = require('@solana/kit');
6
+ var sender = require('@rpc-bastion/sender');
7
+ var core = require('@rpc-bastion/core');
8
+ var testkit = require('@rpc-bastion/testkit');
9
+
10
+ var __defProp = Object.defineProperty;
11
+ var __getOwnPropNames = Object.getOwnPropertyNames;
12
+ var __esm = (fn, res) => function __init() {
13
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
14
+ };
15
+ var __export = (target, all) => {
16
+ for (var name in all)
17
+ __defProp(target, name, { get: all[name], enumerable: true });
18
+ };
19
+
20
+ // src/data/args.ts
21
+ var args_exports = {};
22
+ __export(args_exports, {
23
+ flagBool: () => flagBool,
24
+ flagInt: () => flagInt,
25
+ flagString: () => flagString,
26
+ parseArgs: () => parseArgs
27
+ });
28
+ function parseArgs(argv) {
29
+ const flags = {};
30
+ const positionals = [];
31
+ let command = null;
32
+ for (let i = 0; i < argv.length; i++) {
33
+ const tok = argv[i];
34
+ if (tok.startsWith("--")) {
35
+ const body = tok.slice(2);
36
+ const eq = body.indexOf("=");
37
+ if (eq >= 0) {
38
+ flags[body.slice(0, eq)] = body.slice(eq + 1);
39
+ } else {
40
+ const next = argv[i + 1];
41
+ if (next !== void 0 && !next.startsWith("--")) {
42
+ flags[body] = next;
43
+ i++;
44
+ } else {
45
+ flags[body] = true;
46
+ }
47
+ }
48
+ } else if (command === null) {
49
+ command = tok;
50
+ } else {
51
+ positionals.push(tok);
52
+ }
53
+ }
54
+ return { command, positionals, flags };
55
+ }
56
+ function flagString(args, name) {
57
+ const v = args.flags[name];
58
+ return typeof v === "string" ? v : void 0;
59
+ }
60
+ function flagBool(args, name) {
61
+ return args.flags[name] === true || args.flags[name] === "true";
62
+ }
63
+ function flagInt(args, name, fallback) {
64
+ const v = args.flags[name];
65
+ if (typeof v !== "string") return fallback;
66
+ const n = Number.parseInt(v, 10);
67
+ return Number.isFinite(n) && n > 0 ? n : fallback;
68
+ }
69
+ var init_args = __esm({
70
+ "src/data/args.ts"() {
71
+ }
72
+ });
73
+
74
+ // src/cli.ts
75
+ var cli_exports = {};
76
+ __export(cli_exports, {
77
+ COMMANDS: () => COMMANDS,
78
+ route: () => route
79
+ });
80
+ function route(args) {
81
+ if (args.flags["help"] === true || args.flags["version"] === true) return "help";
82
+ const c = args.command;
83
+ if (c && COMMANDS.includes(c)) return c;
84
+ return "help";
85
+ }
86
+ var COMMANDS;
87
+ var init_cli = __esm({
88
+ "src/cli.ts"() {
89
+ COMMANDS = ["doctor", "monitor", "watch-tx", "simulate", "help"];
90
+ }
91
+ });
92
+ function median(values) {
93
+ if (values.length === 0) return null;
94
+ const sorted = [...values].sort((a, b) => a - b);
95
+ const mid = Math.floor(sorted.length / 2);
96
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
97
+ }
98
+ function describeProbeError(e) {
99
+ if (e instanceof TypeError) {
100
+ const stack = e.stack ?? "";
101
+ if (stack.includes("getHumanReadableErrorMessage") || stack.includes("getSolanaErrorFromJsonRpcError") || stack.includes("@solana/errors")) {
102
+ return "endpoint returned a JSON-RPC error Solana Kit could not format (often a keyless/unauthorized gateway requiring an API key)";
103
+ }
104
+ }
105
+ return e instanceof Error ? e.message : String(e);
106
+ }
107
+ async function probeEndpoint(url, options) {
108
+ const { probes, rpc, clock, signal } = options;
109
+ const latencies = [];
110
+ let slot = null;
111
+ let error = null;
112
+ for (let i = 0; i < probes; i++) {
113
+ const start = clock.now();
114
+ try {
115
+ const s = await rpc.getSlot().send(signal ? { abortSignal: signal } : void 0);
116
+ latencies.push(clock.now() - start);
117
+ slot = s;
118
+ } catch (e) {
119
+ error ??= describeProbeError(e);
120
+ }
121
+ }
122
+ let health = null;
123
+ let version = null;
124
+ if (latencies.length > 0) {
125
+ try {
126
+ health = await rpc.getHealth().send(signal ? { abortSignal: signal } : void 0);
127
+ } catch (e) {
128
+ error ??= describeProbeError(e);
129
+ }
130
+ try {
131
+ const v = await rpc.getVersion().send(signal ? { abortSignal: signal } : void 0);
132
+ version = v["solana-core"] ?? null;
133
+ } catch (e) {
134
+ error ??= describeProbeError(e);
135
+ }
136
+ }
137
+ const reachable = latencies.length > 0;
138
+ return {
139
+ url,
140
+ reachable,
141
+ latencyP50Ms: median(latencies),
142
+ health,
143
+ slot,
144
+ slotLag: null,
145
+ // filled in once the cluster max is known
146
+ version,
147
+ error
148
+ };
149
+ }
150
+ async function runDoctor(urls, options) {
151
+ const probes = options.probes ?? 5;
152
+ const clock = options.clock ?? core.systemClock;
153
+ const diagnostics = await Promise.all(
154
+ urls.map(
155
+ (url) => probeEndpoint(url, {
156
+ probes,
157
+ rpc: options.rpcFactory(url),
158
+ clock,
159
+ ...options.signal ? { signal: options.signal } : {}
160
+ })
161
+ )
162
+ );
163
+ let clusterMaxSlot = null;
164
+ for (const d of diagnostics) {
165
+ if (d.slot !== null && (clusterMaxSlot === null || d.slot > clusterMaxSlot)) clusterMaxSlot = d.slot;
166
+ }
167
+ for (const d of diagnostics) {
168
+ d.slotLag = d.slot !== null && clusterMaxSlot !== null ? Number(clusterMaxSlot - d.slot) : null;
169
+ }
170
+ const reachable = diagnostics.filter((d) => d.reachable).length;
171
+ return { endpoints: diagnostics, clusterMaxSlot, reachable, total: urls.length };
172
+ }
173
+ function doctorReportToJson(report) {
174
+ return JSON.stringify(
175
+ report,
176
+ (_key, value) => typeof value === "bigint" ? value.toString() : value,
177
+ 2
178
+ );
179
+ }
180
+
181
+ // src/data/config.ts
182
+ function parseConfig(text) {
183
+ let raw;
184
+ try {
185
+ raw = JSON.parse(text);
186
+ } catch (e) {
187
+ throw new Error(`invalid bastion.config.json: ${e instanceof Error ? e.message : String(e)}`);
188
+ }
189
+ if (typeof raw !== "object" || raw === null) throw new Error("bastion.config.json must be an object");
190
+ const endpoints = raw.endpoints;
191
+ if (!Array.isArray(endpoints) || endpoints.length === 0 || !endpoints.every((e) => typeof e === "string")) {
192
+ throw new Error('bastion.config.json must have a non-empty "endpoints" string array');
193
+ }
194
+ const jito = raw.jito;
195
+ const config = { endpoints };
196
+ if (jito && typeof jito === "object") {
197
+ const j = jito;
198
+ config.jito = {
199
+ ...typeof j.region === "string" ? { region: j.region } : {},
200
+ ...typeof j.baseUrl === "string" ? { baseUrl: j.baseUrl } : {}
201
+ };
202
+ }
203
+ return config;
204
+ }
205
+ function resolveEndpoints(opts) {
206
+ if (opts.endpoints) {
207
+ const list = opts.endpoints.split(",").map((s) => s.trim()).filter(Boolean);
208
+ if (list.length === 0) throw new Error("--endpoints provided but empty");
209
+ return list;
210
+ }
211
+ if (opts.config) return opts.config.endpoints;
212
+ throw new Error("no endpoints: pass --endpoints <urls> or --config <bastion.config.json>");
213
+ }
214
+
215
+ // src/data/monitor.ts
216
+ function statusDot(health) {
217
+ if (health.breaker === "open" || health.status === "broken") return "red";
218
+ if (health.breaker === "half-open" || health.status === "degraded" || health.errorRate > 0.1 || (health.slotLag ?? 0) > 50)
219
+ return "yellow";
220
+ return "green";
221
+ }
222
+ function summarizeSnapshot(snapshot) {
223
+ const rows = snapshot.map((h) => ({
224
+ url: h.url,
225
+ dot: statusDot(h),
226
+ latencyMs: Math.round(h.latencyEwmaMs),
227
+ errorRatePct: Math.round(h.errorRate * 100),
228
+ slotLag: h.slotLag,
229
+ breaker: h.breaker,
230
+ inFlight: h.inFlight
231
+ }));
232
+ const healthy = rows.filter((r) => r.dot === "green").length;
233
+ let maxSlotLag = null;
234
+ for (const r of rows) {
235
+ if (r.slotLag !== null && (maxSlotLag === null || r.slotLag > maxSlotLag)) maxSlotLag = r.slotLag;
236
+ }
237
+ return { rows, healthy, total: rows.length, maxSlotLag };
238
+ }
239
+ function pushSample(history, sample, size = 30) {
240
+ const next = [...history, sample];
241
+ return next.length > size ? next.slice(next.length - size) : next;
242
+ }
243
+ var SCENARIOS = {
244
+ "live-outage": {
245
+ name: "live-outage",
246
+ description: "The movie: 3 healthy endpoints \u2192 the primary blacks out (breaker opens, traffic reroutes) \u2192 it recovers (half-open trial \u2192 closed). Zero failed user calls.",
247
+ endpoints: ["rpc-a", "rpc-b", "rpc-c"],
248
+ plans: {
249
+ // Healthy (mild latency) → total blackout from 1.5s → recovery from 4s.
250
+ "rpc-a": [
251
+ { atMs: 0, set: { latency: { minMs: 18, maxMs: 45 } } },
252
+ { atMs: 1500, set: { dropRate: 1 } },
253
+ { atMs: 4e3, set: { dropRate: 0, latency: { minMs: 22, maxMs: 50 } } }
254
+ ],
255
+ "rpc-b": [{ atMs: 0, set: { latency: { minMs: 25, maxMs: 70 } } }],
256
+ "rpc-c": [{ atMs: 0, set: { latency: { minMs: 30, maxMs: 85 } } }]
257
+ },
258
+ ticks: 14,
259
+ // Round-robin keeps probing rpc-a during the outage so its failures pile up
260
+ // and the breaker actually opens; the burst gets there within one tick.
261
+ strategy: "round-robin",
262
+ callsPerTick: 3,
263
+ breaker: { failureThreshold: 3, windowMs: 5e3, cooldownMs: 1e3 }
264
+ },
265
+ "flaky-endpoint": {
266
+ name: "flaky-endpoint",
267
+ description: "One endpoint degrades (latency + 500s) mid-run; the pool routes away from it.",
268
+ endpoints: ["rpc-a", "rpc-b"],
269
+ plans: {
270
+ "rpc-a": [
271
+ { atMs: 0, set: {} },
272
+ { atMs: 2e3, set: { latency: { minMs: 400, maxMs: 800 }, errorRate: { http500: 0.5 } } }
273
+ ],
274
+ "rpc-b": []
275
+ },
276
+ ticks: 10
277
+ },
278
+ "cascading-failure": {
279
+ name: "cascading-failure",
280
+ description: "Endpoints fail one after another; the breaker opens and probes recover them.",
281
+ endpoints: ["rpc-a", "rpc-b", "rpc-c"],
282
+ plans: {
283
+ "rpc-a": [{ atMs: 1e3, set: { dropRate: 1 } }],
284
+ "rpc-b": [{ atMs: 3e3, set: { dropRate: 1 } }],
285
+ "rpc-c": []
286
+ },
287
+ ticks: 12
288
+ },
289
+ "rate-limited": {
290
+ name: "rate-limited",
291
+ description: "All endpoints throw 429s intermittently; retries with backoff absorb them.",
292
+ endpoints: ["rpc-a", "rpc-b"],
293
+ plans: {
294
+ "rpc-a": [{ atMs: 0, set: { errorRate: { http429: 0.4 } } }],
295
+ "rpc-b": [{ atMs: 0, set: { errorRate: { http429: 0.4 } } }]
296
+ },
297
+ ticks: 10
298
+ }
299
+ };
300
+ function listScenarios() {
301
+ return Object.values(SCENARIOS).map((s) => ({ name: s.name, description: s.description }));
302
+ }
303
+ async function runSimulation(scenario, options) {
304
+ const tickMs = options.tickMs ?? 500;
305
+ const clock = { now: options.now, sleep: async () => void 0 };
306
+ const bus = core.createEventBus();
307
+ const server = testkit.createMockRpcServer({ slot: 1e3, blockHeight: 1e3 });
308
+ const { pool, rpc } = core.createResilientRpc(
309
+ scenario.endpoints.map((url) => ({ url })),
310
+ {
311
+ bus,
312
+ clock,
313
+ ...scenario.strategy ? { strategy: scenario.strategy } : {},
314
+ ...scenario.breaker ? { breaker: scenario.breaker } : {},
315
+ pool: {
316
+ probe: { enabled: false },
317
+ transportFactory: (config) => testkit.createChaosTransport(server.transport, {
318
+ plan: scenario.plans[config.url] ?? [],
319
+ endpointId: config.url,
320
+ clock,
321
+ seed: options.seed ?? 42
322
+ })
323
+ }
324
+ }
325
+ );
326
+ let pending = [];
327
+ let lastServed = null;
328
+ bus.onAny((event) => {
329
+ switch (event.type) {
330
+ case "rpc.response":
331
+ if (event.ok) {
332
+ lastServed = event.endpoint;
333
+ pending.push({ kind: "served", endpoint: event.endpoint, detail: `${Math.round(event.latencyMs)}ms` });
334
+ }
335
+ break;
336
+ case "rpc.error":
337
+ pending.push({ kind: "error", endpoint: event.endpoint, detail: event.errorClass });
338
+ break;
339
+ case "rpc.failover":
340
+ if (event.to) pending.push({ kind: "failover", endpoint: event.to, detail: `${event.from} \u2192 ${event.to}` });
341
+ break;
342
+ case "breaker.state":
343
+ pending.push({
344
+ kind: event.state === "open" ? "breaker-open" : event.state === "half-open" ? "breaker-half-open" : "breaker-closed",
345
+ endpoint: event.endpoint,
346
+ detail: event.state.toUpperCase()
347
+ });
348
+ break;
349
+ }
350
+ });
351
+ const callsPerTick = Math.max(1, scenario.callsPerTick ?? 1);
352
+ const ticks = [];
353
+ for (let i = 0; i < scenario.ticks; i++) {
354
+ pending = [];
355
+ lastServed = null;
356
+ let userCallOk = true;
357
+ for (let c = 0; c < callsPerTick; c++) {
358
+ try {
359
+ await rpc.getSlot().send();
360
+ } catch {
361
+ userCallOk = false;
362
+ }
363
+ }
364
+ const snapshot = pool.getHealthSnapshot();
365
+ const tick = {
366
+ tick: i,
367
+ snapshot,
368
+ servedBy: userCallOk ? lastServed : null,
369
+ userCallOk,
370
+ events: pending
371
+ };
372
+ ticks.push(tick);
373
+ options.onTick?.(tick);
374
+ await options.advance(tickMs);
375
+ }
376
+ return ticks;
377
+ }
378
+ function summarizeSimulation(ticks) {
379
+ const perEndpoint = /* @__PURE__ */ new Map();
380
+ const ensure = (url) => {
381
+ let row = perEndpoint.get(url);
382
+ if (!row) {
383
+ row = { url, served: 0, errors: 0 };
384
+ perEndpoint.set(url, row);
385
+ }
386
+ return row;
387
+ };
388
+ let userCallsFailed = 0;
389
+ let breakerOpens = 0;
390
+ for (const tick of ticks) {
391
+ if (tick.userCallOk === false) userCallsFailed++;
392
+ if (tick.servedBy) ensure(tick.servedBy).served++;
393
+ for (const ev of tick.events ?? []) {
394
+ if (ev.kind === "error") ensure(ev.endpoint).errors++;
395
+ if (ev.kind === "breaker-open") breakerOpens++;
396
+ }
397
+ }
398
+ return {
399
+ userCallsTotal: ticks.length,
400
+ userCallsFailed,
401
+ perEndpoint: [...perEndpoint.values()],
402
+ breakerOpens
403
+ };
404
+ }
405
+ async function watchTransaction(signature, options) {
406
+ const clock = options.clock;
407
+ const engine = sender.createConfirmationEngine({
408
+ rpc: options.rpc,
409
+ ...options.rpcSubscriptions ? { rpcSubscriptions: options.rpcSubscriptions } : {},
410
+ ...clock ? { clock } : {}
411
+ });
412
+ const updates = [];
413
+ const now = () => clock?.now() ?? 0;
414
+ const push = (state, detail) => {
415
+ const update = { at: now(), state, detail };
416
+ updates.push(update);
417
+ options.onUpdate?.(update);
418
+ };
419
+ push("watching", `commitment=${options.commitment ?? "confirmed"}`);
420
+ const outcome = await engine.confirm(signature, {
421
+ commitment: options.commitment ?? "confirmed",
422
+ lastValidBlockHeight: options.lastValidBlockHeight,
423
+ ...options.pollIntervalMs !== void 0 ? { pollIntervalMs: options.pollIntervalMs } : {},
424
+ ...options.blockHeightIntervalMs !== void 0 ? { blockHeightIntervalMs: options.blockHeightIntervalMs } : {},
425
+ ...options.signal ? { signal: options.signal } : {}
426
+ });
427
+ switch (outcome.type) {
428
+ case "confirmed":
429
+ push("confirmed", `slot ${outcome.slot} (${outcome.confirmationStatus})`);
430
+ return { signature, outcome: "confirmed", slot: outcome.slot, updates };
431
+ case "failed":
432
+ push("failed", "on-chain execution error");
433
+ return { signature, outcome: "failed", slot: outcome.slot, updates };
434
+ case "expired":
435
+ push("expired", `block height ${outcome.blockHeight} > ${outcome.lastValidBlockHeight}`);
436
+ return { signature, outcome: "expired", slot: null, updates };
437
+ default:
438
+ push("aborted", "watch aborted");
439
+ return { signature, outcome: "aborted", slot: null, updates };
440
+ }
441
+ }
442
+
443
+ // src/ui/run.ts
444
+ init_args();
445
+
446
+ // src/ui/ansi.ts
447
+ var useColor = !process.env.NO_COLOR;
448
+ var wrap = (code) => (s) => useColor ? `\x1B[${code}m${s}\x1B[0m` : s;
449
+ var color = {
450
+ green: wrap("32"),
451
+ yellow: wrap("33"),
452
+ red: wrap("31"),
453
+ dim: wrap("2"),
454
+ bold: wrap("1"),
455
+ cyan: wrap("36")
456
+ };
457
+ function dot(kind) {
458
+ const ch = "\u25CF";
459
+ return kind === "green" ? color.green(ch) : kind === "yellow" ? color.yellow(ch) : color.red(ch);
460
+ }
461
+ function table(headers, rows) {
462
+ const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => stripAnsi(r[i] ?? "").length)));
463
+ const pad = (s, w) => s + " ".repeat(Math.max(0, w - stripAnsi(s).length));
464
+ const line = (cols) => cols.map((c, i) => pad(c, widths[i])).join(" ");
465
+ const head = color.bold(line(headers));
466
+ const sep = color.dim(widths.map((w) => "\u2500".repeat(w)).join(" "));
467
+ return [head, sep, ...rows.map(line)].join("\n");
468
+ }
469
+ var SPARK = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588";
470
+ function sparkline(values) {
471
+ if (values.length === 0) return "";
472
+ const max = Math.max(...values, 1);
473
+ return values.map((v) => SPARK[Math.min(SPARK.length - 1, Math.floor(v / max * (SPARK.length - 1)))]).join("");
474
+ }
475
+ function stripAnsi(s) {
476
+ return s.replace(/\[[0-9;]*m/g, "");
477
+ }
478
+ function clearScreen() {
479
+ process.stdout.write("\x1B[2J\x1B[H");
480
+ }
481
+ function print(line = "") {
482
+ console.log(line);
483
+ }
484
+ function bar(fraction, width = 10) {
485
+ const f = Math.max(0, Math.min(1, fraction));
486
+ const filled = Math.round(f * width);
487
+ return "\u2588".repeat(filled) + color.dim("\u2591".repeat(width - filled));
488
+ }
489
+ function breakerWord(state) {
490
+ const label = state.toUpperCase();
491
+ return state === "open" ? color.red(label) : state === "half-open" ? color.yellow(label) : color.green(label);
492
+ }
493
+ function comment(text) {
494
+ return color.dim(`# ${text}`);
495
+ }
496
+ function sleep(ms) {
497
+ return new Promise((resolve) => setTimeout(resolve, ms));
498
+ }
499
+
500
+ // src/ui/render.ts
501
+ function renderDoctor(report) {
502
+ const rows = report.endpoints.map((e) => [
503
+ e.reachable ? dot("green") : dot("red"),
504
+ e.url,
505
+ e.latencyP50Ms === null ? "\u2014" : `${e.latencyP50Ms}ms`,
506
+ e.health ?? "\u2014",
507
+ e.slot === null ? "\u2014" : String(e.slot),
508
+ e.slotLag === null ? "\u2014" : `+${e.slotLag}`,
509
+ e.version ?? "\u2014",
510
+ e.error ? color.red(e.error) : ""
511
+ ]);
512
+ print(table(["", "endpoint", "p50", "health", "slot", "lag", "version", "error"], rows));
513
+ print();
514
+ print(color.dim(`${report.reachable}/${report.total} reachable \xB7 cluster slot ${report.clusterMaxSlot ?? "\u2014"}`));
515
+ }
516
+ function renderMonitor(summary, history) {
517
+ const rows = summary.rows.map((r) => [
518
+ dot(r.dot),
519
+ r.url,
520
+ `${r.latencyMs}ms`,
521
+ sparkline(history[r.url] ?? []),
522
+ `${r.errorRatePct}%`,
523
+ r.slotLag === null ? "\u2014" : `+${r.slotLag}`,
524
+ r.breaker,
525
+ String(r.inFlight)
526
+ ]);
527
+ print(table(["", "endpoint", "latency", "trend", "err", "lag", "breaker", "inflight"], rows));
528
+ print();
529
+ print(color.dim(`${summary.healthy}/${summary.total} healthy \xB7 max lag ${summary.maxSlotLag ?? 0}`));
530
+ }
531
+ function renderWatchTx(result) {
532
+ for (const u of result.updates) {
533
+ const c = u.state === "confirmed" ? color.green : u.state === "watching" ? color.cyan : color.red;
534
+ print(`${color.dim(String(u.at))} ${c(u.state)} ${u.detail}`);
535
+ }
536
+ }
537
+ function renderSimulationTick(tick) {
538
+ const dots = tick.snapshot.map((h) => {
539
+ const k = h.breaker === "open" ? "red" : h.errorRate > 0.1 ? "yellow" : "green";
540
+ return `${dot(k)} ${h.url}`;
541
+ }).join(" ");
542
+ print(`tick ${String(tick.tick).padStart(2)} ${dots}`);
543
+ }
544
+ function dotKind(h) {
545
+ if (h.breaker === "open" || h.status === "broken") return "red";
546
+ if (h.breaker === "half-open" || h.status === "degraded" || h.errorRate > 0.1) return "yellow";
547
+ return "green";
548
+ }
549
+ function renderSimulationHeader(scenario) {
550
+ print(color.bold("bastion simulate") + color.dim(` \xB7 scenario: ${scenario.name}`));
551
+ print(color.dim(scenario.description));
552
+ print();
553
+ }
554
+ function renderSimulationLiveTick(tick) {
555
+ const maxLat = Math.max(60, ...tick.snapshot.map((h) => h.latencyEwmaMs));
556
+ for (const h of tick.snapshot) {
557
+ const served = tick.servedBy === h.url ? color.cyan(" \u25C0 traffic") : "";
558
+ const lat = h.latencyEwmaMs > 0 ? `${bar(h.latencyEwmaMs / maxLat, 8)} ${Math.round(h.latencyEwmaMs)}ms` : bar(0, 8);
559
+ print(` ${dot(dotKind(h))} ${h.url.padEnd(6)} ${breakerWord(h.breaker).padEnd(18)} ${lat}${served}`);
560
+ }
561
+ const probes = (tick.events ?? []).filter((e) => e.kind === "served" || e.kind === "error").map((e) => e.kind === "served" ? `${e.endpoint} ${e.detail}` : color.red(`${e.endpoint} \u2715`));
562
+ if (probes.length) print(color.dim(` probes: `) + probes.join(color.dim(" \xB7 ")));
563
+ for (const ev of tick.events ?? []) {
564
+ if (ev.kind === "served" || ev.kind === "error") continue;
565
+ print(` ${renderTickEvent(ev)}`);
566
+ }
567
+ }
568
+ function renderTickEvent(ev) {
569
+ switch (ev.kind) {
570
+ case "failover":
571
+ return color.yellow("\u21BB failover ") + color.dim(ev.detail);
572
+ case "breaker-open":
573
+ return color.red(`\u2715 breaker ${ev.endpoint} OPEN `) + comment("ejecting endpoint, rerouting traffic");
574
+ case "breaker-half-open":
575
+ return color.yellow(`\u25D0 breaker ${ev.endpoint} HALF-OPEN `) + comment("trial request");
576
+ case "breaker-closed":
577
+ return color.green(`\u2713 breaker ${ev.endpoint} CLOSED `) + comment("recovered, back in rotation");
578
+ default:
579
+ return color.dim(`\xB7 ${ev.endpoint} ${ev.detail}`);
580
+ }
581
+ }
582
+ function renderSimulationSummary(finalTick, summary) {
583
+ print();
584
+ print(color.bold("final health"));
585
+ const rows = finalTick.snapshot.map((h) => {
586
+ const served = summary.perEndpoint.find((r) => r.url === h.url)?.served ?? 0;
587
+ return [dot(dotKind(h)), h.url, breakerWord(h.breaker), `${Math.round(h.latencyEwmaMs)}ms`, `${served} served`];
588
+ });
589
+ print(table(["", "endpoint", "breaker", "latency", "traffic"], rows));
590
+ print();
591
+ const total = summary.userCallsTotal;
592
+ const failed = summary.userCallsFailed;
593
+ const thesis = failed === 0 ? color.green(color.bold(`\u2713 0 failed user calls`)) + color.dim(` across ${total} calls \u2014 ${summary.breakerOpens} breaker trip(s), every call served by a healthy endpoint`) : color.red(color.bold(`\u2715 ${failed}/${total} user calls failed`));
594
+ print(thesis);
595
+ }
596
+
597
+ // src/ui/run.ts
598
+ function endpointsFromArgs(args) {
599
+ const configPath = flagString(args, "config");
600
+ const config = configPath ? parseConfig(fs.readFileSync(configPath, "utf8")) : void 0;
601
+ const endpoints = flagString(args, "endpoints");
602
+ return resolveEndpoints({ ...endpoints ? { endpoints } : {}, ...config ? { config } : {} });
603
+ }
604
+ function doctorRpcFor(url) {
605
+ return kit.createSolanaRpc(url);
606
+ }
607
+ async function runDoctorCommand(args) {
608
+ const urls = endpointsFromArgs(args);
609
+ const report = await runDoctor(urls, {
610
+ rpcFactory: doctorRpcFor,
611
+ probes: flagInt(args, "probes", 5),
612
+ clock: core.systemClock
613
+ });
614
+ if (flagBool(args, "json")) {
615
+ print(doctorReportToJson(report));
616
+ } else {
617
+ renderDoctor(report);
618
+ }
619
+ }
620
+ async function runWatchTxCommand(args) {
621
+ const sig = args.positionals[0];
622
+ if (!sig) throw new Error("usage: bastion watch-tx <signature> --endpoints <urls>");
623
+ const urls = endpointsFromArgs(args);
624
+ const rpc = sender.asSenderRpc(kit.createSolanaRpc(urls[0]));
625
+ const subs = kit.createSolanaRpcSubscriptions(urls[0].replace(/^http/, "ws"));
626
+ const lvbh = BigInt(flagString(args, "last-valid-block-height") ?? "0");
627
+ const result = await watchTransaction(sig, {
628
+ rpc,
629
+ rpcSubscriptions: subs,
630
+ lastValidBlockHeight: lvbh,
631
+ clock: core.systemClock,
632
+ onUpdate: (u) => print(`${color.dim(String(u.at))} ${u.state} ${u.detail}`)
633
+ });
634
+ renderWatchTx(result);
635
+ }
636
+ async function runSimulateCommand(args) {
637
+ const name = flagString(args, "scenario");
638
+ if (!name || flagBool(args, "list")) {
639
+ print(color.bold("Available scenarios:"));
640
+ for (const s of listScenarios()) print(` ${color.cyan(s.name)} \u2014 ${s.description}`);
641
+ return;
642
+ }
643
+ const scenario = SCENARIOS[name];
644
+ if (!scenario) throw new Error(`unknown scenario "${name}" (try --list)`);
645
+ if (flagBool(args, "live")) {
646
+ await runSimulateLive(args);
647
+ return;
648
+ }
649
+ let t = 0;
650
+ await runSimulation(scenario, {
651
+ now: () => t,
652
+ advance: (ms) => {
653
+ t += ms;
654
+ },
655
+ onTick: (tick) => renderSimulationTick(tick)
656
+ });
657
+ print();
658
+ print(color.dim(`scenario "${scenario.name}" complete`));
659
+ }
660
+ async function runSimulateLive(args) {
661
+ const scenario = SCENARIOS[flagString(args, "scenario")];
662
+ const beatMs = Math.max(200, Math.min(700, flagInt(args, "beat", 420)));
663
+ clearScreen();
664
+ renderSimulationHeader(scenario);
665
+ const collected = [];
666
+ let base = 0;
667
+ let anchor = performance.now();
668
+ await runSimulation(scenario, {
669
+ now: () => base + (performance.now() - anchor),
670
+ advance: (ms) => {
671
+ base += ms;
672
+ anchor = performance.now();
673
+ },
674
+ onTick: (tick) => {
675
+ collected.push(tick);
676
+ }
677
+ });
678
+ for (const tick of collected) {
679
+ renderSimulationLiveTick(tick);
680
+ print();
681
+ await sleep(beatMs);
682
+ }
683
+ const last = collected[collected.length - 1];
684
+ if (last) renderSimulationSummary(last, summarizeSimulation(collected));
685
+ }
686
+ async function runMonitorCommand(args) {
687
+ const urls = endpointsFromArgs(args);
688
+ const { pool, rpc } = core.createResilientRpc(urls.map((url) => ({ url })));
689
+ const history = {};
690
+ const intervalMs = flagInt(args, "interval", 1e3);
691
+ const tick = async () => {
692
+ try {
693
+ await rpc.getSlot().send();
694
+ } catch {
695
+ }
696
+ const snapshot = pool.getHealthSnapshot();
697
+ for (const h of snapshot) history[h.url] = pushSample(history[h.url] ?? [], h.latencyEwmaMs);
698
+ clearScreen();
699
+ print(color.bold("bastion monitor") + color.dim(" (ctrl-c to exit)"));
700
+ print();
701
+ renderMonitor(summarizeSnapshot(snapshot), history);
702
+ };
703
+ await tick();
704
+ const timer = setInterval(() => void tick(), intervalMs);
705
+ await new Promise((resolve) => {
706
+ process.on("SIGINT", () => {
707
+ clearInterval(timer);
708
+ resolve();
709
+ });
710
+ });
711
+ }
712
+ function runHelp() {
713
+ print(color.bold("bastion") + " \u2014 Solana RPC/transaction resilience diagnostics");
714
+ print();
715
+ print("Commands:");
716
+ print(` ${color.cyan("doctor")} --endpoints <urls> | --config <file> [--probes N] [--json]`);
717
+ print(` ${color.cyan("monitor")} --endpoints <urls> | --config <file> [--interval ms]`);
718
+ print(` ${color.cyan("watch-tx")} <signature> --endpoints <urls>`);
719
+ print(` ${color.cyan("simulate")} --scenario <name> [--live] | --list`);
720
+ }
721
+ async function main(argv) {
722
+ const { parseArgs: parseArgs2 } = await Promise.resolve().then(() => (init_args(), args_exports));
723
+ const { route: route2 } = await Promise.resolve().then(() => (init_cli(), cli_exports));
724
+ const args = parseArgs2(argv);
725
+ try {
726
+ switch (route2(args)) {
727
+ case "doctor":
728
+ await runDoctorCommand(args);
729
+ break;
730
+ case "monitor":
731
+ await runMonitorCommand(args);
732
+ break;
733
+ case "watch-tx":
734
+ await runWatchTxCommand(args);
735
+ break;
736
+ case "simulate":
737
+ await runSimulateCommand(args);
738
+ break;
739
+ default:
740
+ runHelp();
741
+ }
742
+ return 0;
743
+ } catch (e) {
744
+ console.error(`bastion: ${e instanceof Error ? e.message : String(e)}`);
745
+ return 1;
746
+ }
747
+ }
748
+
749
+ // src/ui/bin.ts
750
+ main(process.argv.slice(2)).then(
751
+ (code) => {
752
+ process.exitCode = code;
753
+ },
754
+ (err) => {
755
+ console.error(err);
756
+ process.exitCode = 1;
757
+ }
758
+ );
759
+ //# sourceMappingURL=bin.cjs.map
760
+ //# sourceMappingURL=bin.cjs.map