routstrd 0.3.2 → 0.3.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,269 @@
1
+ import { createWriteStream, mkdirSync, type WriteStream } from "fs";
2
+ import { writeFile } from "fs/promises";
3
+ import { join } from "path";
4
+ import type { SdkLogger } from "@routstr/sdk";
5
+
6
+ export interface RequestResponseLogRequestInput {
7
+ method: string;
8
+ url: string;
9
+ path: string;
10
+ baseUrl: string;
11
+ headers: Record<string, string>;
12
+ body?: unknown;
13
+ rawBody?: string;
14
+ }
15
+
16
+ export interface RequestResponseLogSink {
17
+ logRequest?(input: RequestResponseLogRequestInput): string | undefined | Promise<string | undefined>;
18
+ logResponseStart?(id: string | undefined, response: Response): void | Promise<void>;
19
+ logResponseChunk?(id: string | undefined, sequence: number, text: string): void | Promise<void>;
20
+ logResponseEnd?(id: string | undefined): void | Promise<void>;
21
+ logResponseError?(id: string | undefined, error: unknown): void | Promise<void>;
22
+ logResponseBody?(id: string | undefined, response: Response): void | Promise<void>;
23
+ }
24
+
25
+ interface ActiveResponseLog {
26
+ stream: WriteStream;
27
+ pending: Promise<void>;
28
+ }
29
+
30
+ export interface FileRequestResponseLogSinkOptions {
31
+ dir: string;
32
+ logger?: SdkLogger;
33
+ }
34
+
35
+ const SENSITIVE_HEADER_NAMES = new Set([
36
+ "authorization",
37
+ "x-cashu",
38
+ "cookie",
39
+ "set-cookie",
40
+ "proxy-authorization",
41
+ ]);
42
+
43
+ const SENSITIVE_BODY_FIELD_NAMES = new Set([
44
+ "authorization",
45
+ "api_key",
46
+ "apikey",
47
+ "apiKey",
48
+ "access_token",
49
+ "accessToken",
50
+ "bearer",
51
+ "cashu",
52
+ "cookie",
53
+ "key",
54
+ "password",
55
+ "secret",
56
+ "token",
57
+ "x-cashu",
58
+ ]);
59
+
60
+ const REDACTED = "[REDACTED]";
61
+
62
+ const sanitizeForFilename = (value: string): string =>
63
+ value.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
64
+
65
+ const makeId = (): string => {
66
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
67
+ const random = crypto.randomUUID().slice(0, 8);
68
+ return `${timestamp}-${random}`;
69
+ };
70
+
71
+ const headersToObject = (headers: Headers): Record<string, string> => {
72
+ const out: Record<string, string> = {};
73
+ headers.forEach((value, key) => {
74
+ out[key] = value;
75
+ });
76
+ return out;
77
+ };
78
+
79
+ const redactHeaders = (headers: Record<string, string>): Record<string, string> =>
80
+ Object.fromEntries(
81
+ Object.entries(headers).map(([key, value]) => [
82
+ key,
83
+ SENSITIVE_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED : value,
84
+ ]),
85
+ );
86
+
87
+ const redactBody = (value: unknown): unknown => {
88
+ if (!value || typeof value !== "object") return value;
89
+ if (Array.isArray(value)) return value.map(redactBody);
90
+
91
+ return Object.fromEntries(
92
+ Object.entries(value as Record<string, unknown>).map(([key, entry]) => [
93
+ key,
94
+ SENSITIVE_BODY_FIELD_NAMES.has(key) || SENSITIVE_BODY_FIELD_NAMES.has(key.toLowerCase())
95
+ ? REDACTED
96
+ : redactBody(entry),
97
+ ]),
98
+ );
99
+ };
100
+
101
+ const redactRawBody = (rawBody: string | undefined): string | undefined => {
102
+ if (!rawBody) return undefined;
103
+ try {
104
+ return JSON.stringify(redactBody(JSON.parse(rawBody)));
105
+ } catch {
106
+ return rawBody;
107
+ }
108
+ };
109
+
110
+ export class FileRequestResponseLogSink implements RequestResponseLogSink {
111
+ private requestsDir: string;
112
+ private responsesDir: string;
113
+ private activeResponses = new Map<string, ActiveResponseLog>();
114
+
115
+ constructor(private options: FileRequestResponseLogSinkOptions) {
116
+ this.requestsDir = join(options.dir, "requests");
117
+ this.responsesDir = join(options.dir, "responses");
118
+ mkdirSync(this.requestsDir, { recursive: true });
119
+ mkdirSync(this.responsesDir, { recursive: true });
120
+ }
121
+
122
+ async logRequest(input: RequestResponseLogRequestInput): Promise<string | undefined> {
123
+ try {
124
+ const id = makeId();
125
+ const filePath = join(this.requestsDir, `${sanitizeForFilename(id)}.json`);
126
+ await writeFile(
127
+ filePath,
128
+ JSON.stringify(
129
+ {
130
+ id,
131
+ timestamp: new Date().toISOString(),
132
+ method: input.method,
133
+ url: input.url,
134
+ path: input.path,
135
+ baseUrl: input.baseUrl,
136
+ headers: redactHeaders(input.headers),
137
+ body: redactBody(input.body),
138
+ rawBody: redactRawBody(input.rawBody),
139
+ },
140
+ null,
141
+ 2,
142
+ ),
143
+ );
144
+ return id;
145
+ } catch (error) {
146
+ this.options.logger?.error?.("[request-response-log] failed to log request:", error);
147
+ return undefined;
148
+ }
149
+ }
150
+
151
+ async logResponseStart(id: string | undefined, response: Response): Promise<void> {
152
+ if (!id) return;
153
+ await this.append(id, {
154
+ type: "response_start",
155
+ status: response.status,
156
+ statusText: response.statusText,
157
+ headers: redactHeaders(headersToObject(response.headers)),
158
+ });
159
+ }
160
+
161
+ logResponseChunk(id: string | undefined, sequence: number, text: string): void {
162
+ if (!id) return;
163
+ void this.append(id, {
164
+ type: "chunk",
165
+ sequence,
166
+ text,
167
+ });
168
+ }
169
+
170
+ async logResponseEnd(id: string | undefined): Promise<void> {
171
+ if (!id) return;
172
+ await this.append(id, { type: "end" });
173
+ await this.close(id);
174
+ }
175
+
176
+ async logResponseError(id: string | undefined, error: unknown): Promise<void> {
177
+ if (!id) return;
178
+ await this.append(id, {
179
+ type: "error",
180
+ error: error instanceof Error ? { message: error.message, stack: error.stack } : String(error),
181
+ });
182
+ await this.close(id);
183
+ }
184
+
185
+ async logResponseBody(id: string | undefined, response: Response): Promise<void> {
186
+ if (!id) return;
187
+ try {
188
+ if (!response.body) {
189
+ await this.logResponseEnd(id);
190
+ return;
191
+ }
192
+
193
+ const reader = response.body.getReader();
194
+ const decoder = new TextDecoder("utf-8");
195
+ let sequence = 0;
196
+
197
+ while (true) {
198
+ const { done, value } = await reader.read();
199
+ if (done) break;
200
+ if (value && value.byteLength > 0) {
201
+ await this.append(id, {
202
+ type: "chunk",
203
+ sequence: sequence++,
204
+ text: decoder.decode(value, { stream: true }),
205
+ });
206
+ }
207
+ }
208
+
209
+ const tail = decoder.decode();
210
+ if (tail) {
211
+ await this.append(id, {
212
+ type: "chunk",
213
+ sequence: sequence++,
214
+ text: tail,
215
+ });
216
+ }
217
+
218
+ await this.logResponseEnd(id);
219
+ } catch (error) {
220
+ await this.logResponseError(id, error);
221
+ }
222
+ }
223
+
224
+ private getOrCreate(id: string): ActiveResponseLog {
225
+ const existing = this.activeResponses.get(id);
226
+ if (existing) return existing;
227
+
228
+ const filePath = join(this.responsesDir, `${sanitizeForFilename(id)}.jsonl`);
229
+ const stream = createWriteStream(filePath, { flags: "a" });
230
+ const active: ActiveResponseLog = {
231
+ stream,
232
+ pending: Promise.resolve(),
233
+ };
234
+
235
+ stream.on("error", (error) => {
236
+ this.options.logger?.error?.("[request-response-log] response log stream error:", error);
237
+ });
238
+
239
+ this.activeResponses.set(id, active);
240
+ return active;
241
+ }
242
+
243
+ private async append(id: string, event: Record<string, unknown>): Promise<void> {
244
+ try {
245
+ const active = this.getOrCreate(id);
246
+ const line = JSON.stringify({ requestLogId: id, timestamp: new Date().toISOString(), ...event }) + "\n";
247
+ active.pending = active.pending.then(
248
+ () =>
249
+ new Promise<void>((resolve, reject) => {
250
+ active.stream.write(line, (error) => {
251
+ if (error) reject(error);
252
+ else resolve();
253
+ });
254
+ }),
255
+ );
256
+ await active.pending;
257
+ } catch (error) {
258
+ this.options.logger?.error?.("[request-response-log] failed to append response event:", error);
259
+ }
260
+ }
261
+
262
+ private async close(id: string): Promise<void> {
263
+ const active = this.activeResponses.get(id);
264
+ if (!active) return;
265
+ this.activeResponses.delete(id);
266
+ await active.pending;
267
+ await new Promise<void>((resolve) => active.stream.end(resolve));
268
+ }
269
+ }
@@ -1,6 +1,6 @@
1
1
  import { getVisibleTabs } from "./constants.ts";
2
2
  import type { Tab } from "./types.ts";
3
- import { fetchBalance, fetchClients, fetchStatus, fetchUsage, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
3
+ import { fetchBalance, fetchClients, fetchStatus, fetchUsageSummary, hasAnyNpubs, isDaemonRunning, type BalanceInfo, type ClientInfo, type StatusInfo } from "./data.ts";
4
4
  import {
5
5
  applyScrollToContent,
6
6
  exitSearchMode,
@@ -49,10 +49,9 @@ export async function runUsageTui(): Promise<void> {
49
49
  let clients: ClientInfo[] = [];
50
50
  let visibleTabs: Tab[] = getVisibleTabs(false);
51
51
  let refreshInterval: ReturnType<typeof setInterval> | null = null;
52
- let shouldUpdate = true;
53
52
  let autoRefresh = true;
54
53
  let cleanedUp = false;
55
- let rendering = false;
54
+ let fetching = false;
56
55
 
57
56
  if (isInteractive) {
58
57
  stdout.write(enterAlternateScreen() + hideCursor());
@@ -80,123 +79,141 @@ export async function runUsageTui(): Promise<void> {
80
79
  process.on("SIGINT", () => cleanup(0));
81
80
  process.on("SIGTERM", () => cleanup(0));
82
81
 
83
- async function render(forceFetch = false) {
84
- if (rendering) return;
85
- rendering = true;
86
-
82
+ /**
83
+ * Background fetch — async, never blocks rendering. Updates the state
84
+ * variables only on success, then triggers a repaint. Overlapping calls
85
+ * are skipped via the `fetching` guard so we don't race stale results.
86
+ */
87
+ async function fetchData(): Promise<void> {
88
+ if (fetching) return;
89
+ fetching = true;
87
90
  try {
88
- const width = getWidth();
89
- const height = getHeight();
91
+ const running = await isDaemonRunning();
92
+ if (!running) {
93
+ stats = null;
94
+ } else {
95
+ // Fire all 4 fetches concurrently — cuts the blocked window significantly.
96
+ const [newStats, newBalance, newStatus, newClients] = await Promise.all([
97
+ fetchUsageSummary(),
98
+ fetchBalance(),
99
+ fetchStatus(),
100
+ fetchClients(),
101
+ ]);
102
+ if (newStats) stats = newStats;
103
+ if (newBalance) balance = newBalance;
104
+ if (newStatus) status = newStatus;
105
+ if (newClients && newClients.length > 0) clients = newClients;
90
106
 
91
- if (forceFetch || shouldUpdate) {
92
- stats = await fetchUsage(10000);
93
- balance = await fetchBalance();
94
- status = await fetchStatus();
95
- clients = await fetchClients();
96
107
  const npubsVisible = hasAnyNpubs(clients);
97
108
  visibleTabs = getVisibleTabs(npubsVisible);
98
- // If current tab is npubs but it's no longer visible, fall back to clients
99
109
  if (currentTab === "npubs" && !npubsVisible) {
100
110
  currentTab = "clients";
101
111
  vimState.scrollPos = 0;
102
112
  }
103
- shouldUpdate = false;
104
- }
105
-
106
- if (!stats) {
107
- stdout.write(
108
- moveCursor(1, 1) +
109
- eraseDown() +
110
- `${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
111
- `Make sure routstrd is running.\n` +
112
- `\nPress Q to quit.`
113
- );
114
- return;
115
113
  }
114
+ render();
115
+ } finally {
116
+ fetching = false;
117
+ }
118
+ }
116
119
 
117
- const content = renderTabContent(currentTab, stats, balance, status, width, clients);
118
- const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
119
- const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
120
- const chromeLines = chrome.split("\n").length - 1;
121
- const footerSeparator = renderSeparator(width);
122
- const footerLines = footerSeparator.split("\n").length - 1;
123
- const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
124
- const visibleContent = applyScrollToContent(content, contentViewportHeight);
125
- const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
120
+ /**
121
+ * Synchronous paint — never awaits I/O. Reads the current state variables
122
+ * and writes to stdout. Safe to call from key handlers without blocking.
123
+ */
124
+ function render(): void {
125
+ const width = getWidth();
126
+ const height = getHeight();
126
127
 
127
- stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
128
- } finally {
129
- rendering = false;
128
+ if (!stats) {
129
+ stdout.write(
130
+ moveCursor(1, 1) +
131
+ eraseDown() +
132
+ `${COLORS.red}Error: Could not fetch usage data.${COLORS.reset}\n` +
133
+ `Make sure routstrd is running.\n` +
134
+ `\nPress Q to quit.`
135
+ );
136
+ return;
130
137
  }
138
+
139
+ const content = renderTabContent(currentTab, stats, balance, status, width, clients);
140
+ const footer = `${COLORS.dim}Press [Q] to quit, [R] to refresh, [A] to toggle auto-refresh${autoRefresh ? " (on)" : " (off)"} scroll:${vimState.scrollPos}${COLORS.reset}${vimState.mode === "normal" ? ` ${COLORS.yellow}vim: hjkl/arrows, / search, g top, gg bottom${COLORS.reset}` : ""}`;
141
+ const chrome = renderHeader(currentTab, width, visibleTabs) + renderTabs(currentTab, visibleTabs) + renderSeparator(width) + renderSearchBar();
142
+ const chromeLines = chrome.split("\n").length - 1;
143
+ const footerSeparator = renderSeparator(width);
144
+ const footerLines = footerSeparator.split("\n").length - 1;
145
+ const contentViewportHeight = Math.max(1, height - chromeLines - footerLines - 1);
146
+ const visibleContent = applyScrollToContent(content, contentViewportHeight);
147
+ const footerBlock = (visibleContent ? "\n" : "") + footerSeparator + footer;
148
+
149
+ stdout.write(moveCursor(1, 1) + eraseDown() + chrome + visibleContent + footerBlock);
131
150
  }
132
151
 
133
152
  const handleKey = (key: string) => {
134
153
  if (vimState.isSearching) {
135
154
  if (key === "\x1b" || key === "\x1b[3~") {
136
155
  exitSearchMode();
137
- void render(false);
156
+ render();
138
157
  return;
139
158
  }
140
159
  if (key === "\r" || key === "\n") {
141
160
  if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
142
161
  exitSearchMode();
143
- void render(false);
162
+ render();
144
163
  return;
145
164
  }
146
165
  if (key === "\x7f" || key === "\x08") {
147
166
  vimState.searchQuery = vimState.searchQuery.slice(0, -1);
148
167
  if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
149
- void render(false);
168
+ render();
150
169
  return;
151
170
  }
152
171
  if (key === "\x03") {
153
172
  exitSearchMode();
154
- void render(false);
173
+ render();
155
174
  return;
156
175
  }
157
176
  if (key.length === 1 && key.charCodeAt(0) >= 32 && key.charCodeAt(0) < 127) {
158
177
  vimState.searchQuery += key;
159
178
  if (stats?.entries) performSearch(vimState.searchQuery, stats.entries);
160
- void render(false);
179
+ render();
161
180
  }
162
181
  return;
163
182
  }
164
183
 
165
184
  if (key === "q" || key === "Q" || key === "\u0003") return cleanup(0);
166
185
  if (key === "r" || key === "R") {
167
- shouldUpdate = true;
168
- void render(true);
186
+ void fetchData();
169
187
  return;
170
188
  }
171
189
  if (key === "a" || key === "A") {
172
190
  autoRefresh = !autoRefresh;
173
- shouldUpdate = true;
174
- void render(false);
191
+ render();
175
192
  return;
176
193
  }
177
194
 
178
195
  if (key === "j" || key === "\x1b[B" || key === "\x1bOB") {
179
196
  scrollDown();
180
- void render(false);
197
+ render();
181
198
  return;
182
199
  }
183
200
  if (key === "k" || key === "\x1b[A" || key === "\x1bOA") {
184
201
  scrollUp();
185
- void render(false);
202
+ render();
186
203
  return;
187
204
  }
188
205
  if (key === "l" || key === "\x1b[C" || key === "\x1bOC") {
189
206
  const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
190
207
  currentTab = visibleTabs[(currentIdx + 1) % visibleTabs.length]!.id;
191
208
  vimState.scrollPos = 0;
192
- void render(false);
209
+ render();
193
210
  return;
194
211
  }
195
212
  if (key === "h" || key === "\x1b[D" || key === "\x1bOD") {
196
213
  const currentIdx = visibleTabs.findIndex((t) => t.id === currentTab);
197
214
  currentTab = visibleTabs[(currentIdx - 1 + visibleTabs.length) % visibleTabs.length]!.id;
198
215
  vimState.scrollPos = 0;
199
- void render(false);
216
+ render();
200
217
  return;
201
218
  }
202
219
 
@@ -204,54 +221,53 @@ export async function runUsageTui(): Promise<void> {
204
221
  if (vimState.lastKey === "g" && Date.now() - vimState.lastKeyTime < 300) {
205
222
  scrollToBottom();
206
223
  vimState.lastKey = "";
207
- void render(false);
224
+ render();
208
225
  return;
209
226
  }
210
227
  vimState.lastKey = "g";
211
228
  vimState.lastKeyTime = Date.now();
212
229
  scrollToTop();
213
- void render(false);
230
+ render();
214
231
  return;
215
232
  }
216
233
 
217
- if (key === "\x02") { pageUp(); void render(false); return; }
218
- if (key === "\x06") { pageDown(); void render(false); return; }
219
- if (key === "\x15") { scrollUp(10); void render(false); return; }
220
- if (key === "\x04") { scrollDown(10); void render(false); return; }
221
- if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); void render(false); return; }
222
- if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); void render(false); return; }
223
- if (key === "/") { startSearch(false); void render(false); return; }
224
- if (key === "?") { startSearch(true); void render(false); return; }
234
+ if (key === "\x02") { pageUp(); render(); return; }
235
+ if (key === "\x06") { pageDown(); render(); return; }
236
+ if (key === "\x15") { scrollUp(10); render(); return; }
237
+ if (key === "\x04") { scrollDown(10); render(); return; }
238
+ if (key === "\x1b[H" || key === "\x1b[1~" || key === "\x1bOH") { scrollToTop(); render(); return; }
239
+ if (key === "\x1b[F" || key === "\x1b[4~" || key === "\x1bOF") { scrollToBottom(); render(); return; }
240
+ if (key === "/") { startSearch(false); render(); return; }
241
+ if (key === "?") { startSearch(true); render(); return; }
225
242
  if (key === "n") {
226
243
  if (vimState.searchReverse) prevSearchResult(stats?.entries.length || 0);
227
244
  else nextSearchResult(stats?.entries.length || 0);
228
- void render(false);
245
+ render();
229
246
  return;
230
247
  }
231
248
  if (key === "N") {
232
249
  if (vimState.searchReverse) nextSearchResult(stats?.entries.length || 0);
233
250
  else prevSearchResult(stats?.entries.length || 0);
234
- void render(false);
251
+ render();
235
252
  return;
236
253
  }
237
- if (key === "\x1b") { scrollToTop(); void render(false); return; }
254
+ if (key === "\x1b") { scrollToTop(); render(); return; }
238
255
 
239
256
  const tab = visibleTabs.find((t) => t.key === key);
240
257
  if (tab) {
241
258
  currentTab = tab.id;
242
259
  vimState.scrollPos = 0;
243
- void render(false);
260
+ render();
244
261
  }
245
262
  };
246
263
 
247
264
  if (isInteractive) stdin.on("data", handleKey);
248
265
 
249
- await render(true);
266
+ await fetchData();
250
267
 
251
268
  refreshInterval = setInterval(() => {
252
269
  if (autoRefresh) {
253
- shouldUpdate = true;
254
- void render(true);
270
+ void fetchData();
255
271
  }
256
272
  }, 2000);
257
273
  }