@vaultgradient/pipequery-cli 0.6.1 → 0.7.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.
@@ -0,0 +1,191 @@
1
+ /**
2
+ * REST source — env-var interpolation in url / headers / params,
3
+ * plus auth: bearer helper. Issue #37 (slice).
4
+ *
5
+ * Spins up a real http server in-process so we exercise the actual
6
+ * outbound request shape rather than mocking globalThis.fetch.
7
+ */
8
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
9
+ import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
10
+ import type { AddressInfo } from 'node:net';
11
+ import { RestSourceAdapter } from '../src/server/sources/rest.js';
12
+
13
+ interface Captured {
14
+ url: string;
15
+ headers: Record<string, string | string[] | undefined>;
16
+ }
17
+
18
+ interface Harness {
19
+ url: string;
20
+ captured: Captured[];
21
+ /** Body the server returns for the next request. Default: empty array. */
22
+ setResponse(body: unknown, status?: number): void;
23
+ close: () => Promise<void>;
24
+ }
25
+
26
+ async function makeServer(): Promise<Harness> {
27
+ const captured: Captured[] = [];
28
+ let nextBody: unknown = [];
29
+ let nextStatus = 200;
30
+
31
+ const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
32
+ captured.push({ url: req.url ?? '', headers: req.headers });
33
+ res.statusCode = nextStatus;
34
+ res.setHeader('content-type', 'application/json');
35
+ res.end(JSON.stringify(nextBody));
36
+ });
37
+ await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
38
+ const port = (server.address() as AddressInfo).port;
39
+ return {
40
+ url: `http://127.0.0.1:${port}`,
41
+ captured,
42
+ setResponse(body, status = 200) {
43
+ nextBody = body;
44
+ nextStatus = status;
45
+ },
46
+ close: () => new Promise<void>((r) => server.close(() => r())),
47
+ };
48
+ }
49
+
50
+ describe('REST source — env interpolation + auth: bearer (#37 slice)', () => {
51
+ let server: Harness;
52
+ let envBackup: Record<string, string | undefined>;
53
+
54
+ beforeEach(async () => {
55
+ server = await makeServer();
56
+ envBackup = {
57
+ MY_TOKEN: process.env.MY_TOKEN,
58
+ MY_KEY: process.env.MY_KEY,
59
+ USER_PATH: process.env.USER_PATH,
60
+ MY_REGION: process.env.MY_REGION,
61
+ };
62
+ });
63
+ afterEach(async () => {
64
+ await server.close();
65
+ // Restore env
66
+ for (const [k, v] of Object.entries(envBackup)) {
67
+ if (v === undefined) delete process.env[k];
68
+ else process.env[k] = v;
69
+ }
70
+ });
71
+
72
+ it('expands ${ENV_VAR} in URL', async () => {
73
+ process.env.USER_PATH = 'users';
74
+ server.setResponse([]);
75
+ const adapter = new RestSourceAdapter({
76
+ type: 'rest',
77
+ url: `${server.url}/api/\${USER_PATH}`,
78
+ });
79
+ await adapter.start();
80
+ adapter.stop();
81
+ expect(server.captured[0].url).toBe('/api/users');
82
+ });
83
+
84
+ it('expands ${ENV_VAR} in headers', async () => {
85
+ process.env.MY_KEY = 'sk-secret-abc';
86
+ server.setResponse([]);
87
+ const adapter = new RestSourceAdapter({
88
+ type: 'rest',
89
+ url: server.url,
90
+ headers: { 'X-API-Key': '${MY_KEY}', 'X-Static': 'literal' },
91
+ });
92
+ await adapter.start();
93
+ adapter.stop();
94
+ expect(server.captured[0].headers['x-api-key']).toBe('sk-secret-abc');
95
+ expect(server.captured[0].headers['x-static']).toBe('literal');
96
+ });
97
+
98
+ it('expands ${ENV_VAR} in query params and merges with existing query string', async () => {
99
+ process.env.MY_REGION = 'eu-west-1';
100
+ server.setResponse([]);
101
+ const adapter = new RestSourceAdapter({
102
+ type: 'rest',
103
+ url: `${server.url}/api?fixed=1`,
104
+ params: { region: '${MY_REGION}', kind: 'live' },
105
+ });
106
+ await adapter.start();
107
+ adapter.stop();
108
+ // Query string contains both the inline ?fixed=1 and the appended params,
109
+ // joined with `&`. URLSearchParams encodes dashes as-is.
110
+ const seen = server.captured[0].url;
111
+ expect(seen).toContain('fixed=1');
112
+ expect(seen).toContain('region=eu-west-1');
113
+ expect(seen).toContain('kind=live');
114
+ expect(seen.startsWith('/api?')).toBe(true);
115
+ });
116
+
117
+ it('emits empty string and a warning for missing env vars (does not embed literal "${FOO}")', async () => {
118
+ // No MY_TOKEN set
119
+ delete process.env.MY_TOKEN;
120
+ server.setResponse([]);
121
+ const stderrCaptured: string[] = [];
122
+ const origWrite = process.stderr.write.bind(process.stderr);
123
+ process.stderr.write = ((chunk: string | Uint8Array) => {
124
+ stderrCaptured.push(typeof chunk === 'string' ? chunk : chunk.toString());
125
+ return true;
126
+ }) as typeof process.stderr.write;
127
+
128
+ const adapter = new RestSourceAdapter({
129
+ type: 'rest',
130
+ url: server.url,
131
+ headers: { Authorization: 'Bearer ${MY_TOKEN}' },
132
+ });
133
+ try {
134
+ await adapter.start();
135
+ adapter.stop();
136
+ } finally {
137
+ process.stderr.write = origWrite;
138
+ }
139
+
140
+ // The literal "${...}" must NOT appear in the outbound header. Node
141
+ // trims trailing whitespace from header values on the wire, so the
142
+ // received value is "Bearer" not "Bearer ".
143
+ const auth = server.captured[0].headers['authorization'] as string;
144
+ expect(auth).not.toContain('${');
145
+ expect(auth.startsWith('Bearer')).toBe(true);
146
+ expect(stderrCaptured.some((s) => s.includes('MY_TOKEN'))).toBe(true);
147
+ });
148
+
149
+ it('auth: bearer adds Authorization: Bearer <token> with env interpolation', async () => {
150
+ process.env.MY_TOKEN = 'jwt.abc.123';
151
+ server.setResponse([]);
152
+ const adapter = new RestSourceAdapter({
153
+ type: 'rest',
154
+ url: server.url,
155
+ auth: { kind: 'bearer', token: '${MY_TOKEN}' },
156
+ });
157
+ await adapter.start();
158
+ adapter.stop();
159
+ expect(server.captured[0].headers['authorization']).toBe('Bearer jwt.abc.123');
160
+ });
161
+
162
+ it('auth: bearer wins over a hand-rolled headers.Authorization on collision', async () => {
163
+ // If a user sets both, they almost certainly meant the auth helper —
164
+ // bearer is the one that actually plumbs the token through env vars.
165
+ process.env.MY_TOKEN = 'jwt.xyz';
166
+ server.setResponse([]);
167
+ const adapter = new RestSourceAdapter({
168
+ type: 'rest',
169
+ url: server.url,
170
+ headers: { Authorization: 'Bearer should-be-overridden' },
171
+ auth: { kind: 'bearer', token: '${MY_TOKEN}' },
172
+ });
173
+ await adapter.start();
174
+ adapter.stop();
175
+ expect(server.captured[0].headers['authorization']).toBe('Bearer jwt.xyz');
176
+ });
177
+
178
+ it('back-compat: no env vars + no auth → behaves exactly like before', async () => {
179
+ server.setResponse([{ id: 1 }, { id: 2 }]);
180
+ const adapter = new RestSourceAdapter({
181
+ type: 'rest',
182
+ url: server.url,
183
+ headers: { 'X-Custom': 'value' },
184
+ });
185
+ await adapter.start();
186
+ expect(adapter.getData()).toEqual([{ id: 1 }, { id: 2 }]);
187
+ expect(server.captured[0].headers['x-custom']).toBe('value');
188
+ expect(server.captured[0].headers['authorization']).toBeUndefined();
189
+ adapter.stop();
190
+ });
191
+ });
@@ -0,0 +1,187 @@
1
+ /**
2
+ * WebSocket source tests — covers the new subscribe-on-open + heartbeat
3
+ * behaviour from issue #35. Spins up a real `ws` server in-process so
4
+ * we exercise the actual JSON round-trip rather than mocking the socket.
5
+ */
6
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
7
+ import { WebSocketServer, type WebSocket as WSocket } from 'ws';
8
+ import type { AddressInfo } from 'node:net';
9
+ import { WebSocketSourceAdapter } from '../src/server/sources/websocket.js';
10
+
11
+ interface Harness {
12
+ url: string;
13
+ /** Messages received by the server, parsed as JSON when possible. */
14
+ received: unknown[];
15
+ /** First connected client — used to push messages back at the adapter. */
16
+ clientPromise: Promise<WSocket>;
17
+ /** Force-close the active client connection (simulates a server restart). */
18
+ forceCloseClient: () => void;
19
+ close: () => Promise<void>;
20
+ }
21
+
22
+ async function makeServer(): Promise<Harness> {
23
+ const wss = new WebSocketServer({ port: 0 });
24
+ const received: unknown[] = [];
25
+ let resolveClient!: (ws: WSocket) => void;
26
+ const clientPromise = new Promise<WSocket>((res) => {
27
+ resolveClient = res;
28
+ });
29
+ let activeClient: WSocket | null = null;
30
+
31
+ wss.on('connection', (ws) => {
32
+ activeClient = ws;
33
+ ws.on('message', (raw) => {
34
+ try {
35
+ received.push(JSON.parse(raw.toString()));
36
+ } catch {
37
+ received.push(raw.toString());
38
+ }
39
+ });
40
+ resolveClient(ws);
41
+ });
42
+
43
+ await new Promise<void>((res) => wss.once('listening', res));
44
+ const port = (wss.address() as AddressInfo).port;
45
+ return {
46
+ url: `ws://127.0.0.1:${port}`,
47
+ received,
48
+ clientPromise,
49
+ forceCloseClient: () => activeClient?.close(),
50
+ close: () =>
51
+ new Promise<void>((res) => {
52
+ for (const c of wss.clients) c.terminate();
53
+ wss.close(() => res());
54
+ }),
55
+ };
56
+ }
57
+
58
+ describe('WebSocketSourceAdapter — subscribe + heartbeat', () => {
59
+ let server: Harness;
60
+ beforeEach(async () => {
61
+ server = await makeServer();
62
+ });
63
+ afterEach(async () => {
64
+ await server.close();
65
+ });
66
+
67
+ it('sends a single subscribe payload immediately after open', async () => {
68
+ const adapter = new WebSocketSourceAdapter({
69
+ type: 'websocket',
70
+ url: server.url,
71
+ subscribe: { method: 'SUBSCRIBE', params: ['btcusdt@ticker'], id: 1 },
72
+ });
73
+ await adapter.start();
74
+ await server.clientPromise;
75
+ // Give the server a tick to flush the message into `received`.
76
+ await new Promise((r) => setTimeout(r, 50));
77
+ expect(server.received).toEqual([
78
+ { method: 'SUBSCRIBE', params: ['btcusdt@ticker'], id: 1 },
79
+ ]);
80
+ adapter.stop();
81
+ });
82
+
83
+ it('sends an array of subscribe payloads in order', async () => {
84
+ const adapter = new WebSocketSourceAdapter({
85
+ type: 'websocket',
86
+ url: server.url,
87
+ subscribe: [
88
+ { method: 'SUBSCRIBE', params: ['btc@ticker'], id: 1 },
89
+ { method: 'SUBSCRIBE', params: ['eth@ticker'], id: 2 },
90
+ { method: 'SUBSCRIBE', params: ['sol@ticker'], id: 3 },
91
+ ],
92
+ });
93
+ await adapter.start();
94
+ await server.clientPromise;
95
+ await new Promise((r) => setTimeout(r, 50));
96
+ expect(server.received).toHaveLength(3);
97
+ expect((server.received[0] as { id: number }).id).toBe(1);
98
+ expect((server.received[1] as { id: number }).id).toBe(2);
99
+ expect((server.received[2] as { id: number }).id).toBe(3);
100
+ adapter.stop();
101
+ });
102
+
103
+ it('sends nothing when subscribe is omitted (back-compat)', async () => {
104
+ const adapter = new WebSocketSourceAdapter({
105
+ type: 'websocket',
106
+ url: server.url,
107
+ });
108
+ await adapter.start();
109
+ await server.clientPromise;
110
+ await new Promise((r) => setTimeout(r, 50));
111
+ expect(server.received).toEqual([]);
112
+ adapter.stop();
113
+ });
114
+
115
+ it('emits heartbeat payloads on the configured second-resolution interval', async () => {
116
+ const adapter = new WebSocketSourceAdapter({
117
+ type: 'websocket',
118
+ url: server.url,
119
+ heartbeat: { payload: { method: 'PING' }, interval: '1s' },
120
+ });
121
+ await adapter.start();
122
+ await server.clientPromise;
123
+ // Wait long enough for at least 2 ticks plus the open-time slack.
124
+ await new Promise((r) => setTimeout(r, 2300));
125
+ const pings = server.received.filter(
126
+ (m) => typeof m === 'object' && m !== null && (m as { method?: string }).method === 'PING',
127
+ );
128
+ expect(pings.length).toBeGreaterThanOrEqual(2);
129
+ adapter.stop();
130
+ }, 5000);
131
+
132
+ it('re-sends subscribe payloads on reconnect', async () => {
133
+ const adapter = new WebSocketSourceAdapter({
134
+ type: 'websocket',
135
+ url: server.url,
136
+ subscribe: { method: 'SUBSCRIBE', params: ['btc@ticker'], id: 1 },
137
+ });
138
+ await adapter.start();
139
+ await server.clientPromise;
140
+ await new Promise((r) => setTimeout(r, 50));
141
+ expect(server.received).toHaveLength(1);
142
+
143
+ // Server force-closes the client; adapter's internal reconnect (5s
144
+ // delay) should fire and re-run start(), which re-sends subscribe.
145
+ server.forceCloseClient();
146
+ await new Promise((r) => setTimeout(r, 5500));
147
+ expect(server.received.length).toBeGreaterThanOrEqual(2);
148
+ expect((server.received[server.received.length - 1] as { method: string }).method).toBe(
149
+ 'SUBSCRIBE',
150
+ );
151
+ adapter.stop();
152
+ }, 10_000);
153
+
154
+ it('stops heartbeat timer and does not reconnect after stop()', async () => {
155
+ const adapter = new WebSocketSourceAdapter({
156
+ type: 'websocket',
157
+ url: server.url,
158
+ subscribe: { method: 'SUBSCRIBE', params: ['x'], id: 1 },
159
+ heartbeat: { payload: { method: 'PING' }, interval: '1s' },
160
+ });
161
+ await adapter.start();
162
+ await server.clientPromise;
163
+ await new Promise((r) => setTimeout(r, 100));
164
+ const beforeStop = server.received.length;
165
+
166
+ adapter.stop();
167
+ // Wait through one would-be reconnect window + a few heartbeat ticks.
168
+ await new Promise((r) => setTimeout(r, 6000));
169
+ // No new SUBSCRIBE re-sent (would indicate reconnect happened) and
170
+ // no new PING ticks (heartbeat would have kept firing).
171
+ expect(server.received.length).toBe(beforeStop);
172
+ }, 10_000);
173
+
174
+ it('still ingests messages from the server (no regression to base behaviour)', async () => {
175
+ const adapter = new WebSocketSourceAdapter({
176
+ type: 'websocket',
177
+ url: server.url,
178
+ });
179
+ await adapter.start();
180
+ const client = await server.clientPromise;
181
+ client.send(JSON.stringify({ price: 100 }));
182
+ client.send(JSON.stringify({ price: 101 }));
183
+ await new Promise((r) => setTimeout(r, 50));
184
+ expect(adapter.getData()).toEqual([{ price: 100 }, { price: 101 }]);
185
+ adapter.stop();
186
+ });
187
+ });
package/dist/index.js CHANGED
@@ -149,6 +149,21 @@ function parseDuration(input) {
149
149
  return parseFloat(match[1]) * UNITS[match[2]];
150
150
  }
151
151
 
152
+ // src/utils/expandEnv.ts
153
+ function expandEnv(input, context) {
154
+ return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
155
+ const value = process.env[name];
156
+ if (value === void 0) {
157
+ process.stderr.write(
158
+ `[pipequery] env var "${name}" referenced in ${context} is not set
159
+ `
160
+ );
161
+ return "";
162
+ }
163
+ return value;
164
+ });
165
+ }
166
+
152
167
  // src/server/sources/rest.ts
153
168
  function extractPath(obj, path) {
154
169
  let current = obj;
@@ -199,14 +214,17 @@ var RestSourceAdapter = class {
199
214
  }
200
215
  async fetch() {
201
216
  try {
202
- let url = this.config.url;
217
+ let url = expandEnv(this.config.url, "rest source URL");
203
218
  if (this.config.params) {
204
- const params = new URLSearchParams(this.config.params);
219
+ const expanded = {};
220
+ for (const [k, v] of Object.entries(this.config.params)) {
221
+ expanded[k] = expandEnv(v, `rest source param "${k}"`);
222
+ }
223
+ const params = new URLSearchParams(expanded);
205
224
  url += (url.includes("?") ? "&" : "?") + params.toString();
206
225
  }
207
- const res = await globalThis.fetch(url, {
208
- headers: this.config.headers
209
- });
226
+ const headers = this.buildHeaders();
227
+ const res = await globalThis.fetch(url, { headers });
210
228
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
211
229
  const json = await res.json();
212
230
  this.data = this.config.dataPath ? extractPath(json, this.config.dataPath) : Array.isArray(json) ? json : [json];
@@ -220,6 +238,28 @@ var RestSourceAdapter = class {
220
238
  notify() {
221
239
  for (const cb of this.listeners) cb(this.data);
222
240
  }
241
+ /**
242
+ * Build the final HTTP headers for one fetch.
243
+ *
244
+ * Two sources contribute, in order: any user-declared `headers` (with
245
+ * env-var interpolation applied per value), then the `auth` helper
246
+ * (currently just `bearer`). Auth wins on collision — if a user sets
247
+ * both `headers.Authorization` and `auth.kind === 'bearer'`, the bearer
248
+ * helper is what they actually meant.
249
+ */
250
+ buildHeaders() {
251
+ const headers = {};
252
+ if (this.config.headers) {
253
+ for (const [k, v] of Object.entries(this.config.headers)) {
254
+ headers[k] = expandEnv(v, `rest source header "${k}"`);
255
+ }
256
+ }
257
+ if (this.config.auth?.kind === "bearer") {
258
+ const token = expandEnv(this.config.auth.token, "rest source bearer token");
259
+ headers["Authorization"] = `Bearer ${token}`;
260
+ }
261
+ return Object.keys(headers).length > 0 ? headers : void 0;
262
+ }
223
263
  };
224
264
 
225
265
  // src/server/sources/websocket.ts
@@ -236,14 +276,35 @@ var WebSocketSourceAdapter = class {
236
276
  error;
237
277
  listeners = /* @__PURE__ */ new Set();
238
278
  maxBuffer;
279
+ heartbeatTimer = null;
280
+ /** Tracks whether stop() was called so reconnect respects intent. */
281
+ stopped = false;
239
282
  async start() {
283
+ this.stopped = false;
240
284
  return new Promise((resolve10, reject) => {
241
- this.ws = new WebSocket(this.config.url);
242
- this.ws.on("open", () => {
285
+ const ws = new WebSocket(this.config.url);
286
+ this.ws = ws;
287
+ let settled = false;
288
+ ws.on("open", () => {
243
289
  this.error = void 0;
244
- resolve10();
290
+ try {
291
+ this.sendSubscribePayloads(ws);
292
+ this.startHeartbeat(ws);
293
+ } catch (err) {
294
+ this.error = err instanceof Error ? err.message : String(err);
295
+ ws.close();
296
+ if (!settled) {
297
+ settled = true;
298
+ reject(err instanceof Error ? err : new Error(this.error));
299
+ }
300
+ return;
301
+ }
302
+ if (!settled) {
303
+ settled = true;
304
+ resolve10();
305
+ }
245
306
  });
246
- this.ws.on("message", (raw) => {
307
+ ws.on("message", (raw) => {
247
308
  try {
248
309
  const parsed = JSON.parse(raw.toString());
249
310
  if (Array.isArray(parsed)) {
@@ -260,19 +321,26 @@ var WebSocketSourceAdapter = class {
260
321
  } catch {
261
322
  }
262
323
  });
263
- this.ws.on("error", (err) => {
324
+ ws.on("error", (err) => {
264
325
  this.error = err.message;
265
- reject(err);
326
+ if (!settled) {
327
+ settled = true;
328
+ reject(err);
329
+ }
266
330
  });
267
- this.ws.on("close", () => {
331
+ ws.on("close", () => {
332
+ this.stopHeartbeat();
333
+ if (this.stopped) return;
268
334
  setTimeout(() => {
269
- if (this.ws) this.start().catch(() => {
335
+ if (!this.stopped) this.start().catch(() => {
270
336
  });
271
337
  }, 5e3);
272
338
  });
273
339
  });
274
340
  }
275
341
  stop() {
342
+ this.stopped = true;
343
+ this.stopHeartbeat();
276
344
  const ws = this.ws;
277
345
  this.ws = null;
278
346
  ws?.close();
@@ -295,6 +363,33 @@ var WebSocketSourceAdapter = class {
295
363
  notify() {
296
364
  for (const cb of this.listeners) cb(this.data);
297
365
  }
366
+ sendSubscribePayloads(ws) {
367
+ const subscribe = this.config.subscribe;
368
+ if (subscribe === void 0 || subscribe === null) return;
369
+ const list = Array.isArray(subscribe) ? subscribe : [subscribe];
370
+ for (const payload of list) {
371
+ ws.send(JSON.stringify(payload));
372
+ }
373
+ }
374
+ startHeartbeat(ws) {
375
+ this.stopHeartbeat();
376
+ const hb = this.config.heartbeat;
377
+ if (!hb) return;
378
+ const intervalMs = parseDuration(hb.interval);
379
+ this.heartbeatTimer = setInterval(() => {
380
+ if (ws.readyState !== WebSocket.OPEN) return;
381
+ try {
382
+ ws.send(JSON.stringify(hb.payload));
383
+ } catch {
384
+ }
385
+ }, intervalMs);
386
+ }
387
+ stopHeartbeat() {
388
+ if (this.heartbeatTimer) {
389
+ clearInterval(this.heartbeatTimer);
390
+ this.heartbeatTimer = null;
391
+ }
392
+ }
298
393
  };
299
394
 
300
395
  // src/server/sources/file.ts
@@ -399,19 +494,6 @@ var StaticSourceAdapter = class {
399
494
  import pg from "pg";
400
495
  var DEFAULT_INTERVAL_MS = 3e4;
401
496
  var DEFAULT_MAX_ROWS = 1e4;
402
- function expandEnv(input) {
403
- return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
404
- const value = process.env[name];
405
- if (value === void 0) {
406
- process.stderr.write(
407
- `[pipequery] env var "${name}" referenced in postgres source URL is not set
408
- `
409
- );
410
- return "";
411
- }
412
- return value;
413
- });
414
- }
415
497
  function resolveSsl(ssl) {
416
498
  if (ssl === false) return false;
417
499
  if (ssl === "no-verify") return { rejectUnauthorized: false };
@@ -433,7 +515,7 @@ var PostgresSourceAdapter = class {
433
515
  intervalMs;
434
516
  maxRows;
435
517
  async start() {
436
- const connectionString = expandEnv(this.config.url);
518
+ const connectionString = expandEnv(this.config.url, "postgres source URL");
437
519
  this.pool = new pg.Pool({
438
520
  connectionString,
439
521
  ssl: resolveSsl(this.config.ssl),
@@ -511,19 +593,6 @@ function stripTrailingSemicolon(sql) {
511
593
  import mysql from "mysql2/promise";
512
594
  var DEFAULT_INTERVAL_MS2 = 3e4;
513
595
  var DEFAULT_MAX_ROWS2 = 1e4;
514
- function expandEnv2(input) {
515
- return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
516
- const value = process.env[name];
517
- if (value === void 0) {
518
- process.stderr.write(
519
- `[pipequery] env var "${name}" referenced in mysql source URL is not set
520
- `
521
- );
522
- return "";
523
- }
524
- return value;
525
- });
526
- }
527
596
  function resolveSsl2(ssl) {
528
597
  if (ssl === false) return void 0;
529
598
  if (ssl === "no-verify") return { rejectUnauthorized: false };
@@ -545,7 +614,7 @@ var MysqlSourceAdapter = class {
545
614
  intervalMs;
546
615
  maxRows;
547
616
  async start() {
548
- const uri = expandEnv2(this.config.url);
617
+ const uri = expandEnv(this.config.url, "mysql source URL");
549
618
  this.pool = mysql.createPool({
550
619
  uri,
551
620
  ssl: resolveSsl2(this.config.ssl),
@@ -716,22 +785,9 @@ function stripTrailingSemicolon3(sql) {
716
785
  import { randomBytes } from "crypto";
717
786
  import { Kafka, logLevel } from "kafkajs";
718
787
  var DEFAULT_MAX_BUFFER = 1e3;
719
- function expandEnv3(input) {
720
- return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
721
- const value = process.env[name];
722
- if (value === void 0) {
723
- process.stderr.write(
724
- `[pipequery] env var "${name}" referenced in kafka source is not set
725
- `
726
- );
727
- return "";
728
- }
729
- return value;
730
- });
731
- }
732
788
  function normalizeBrokers(brokers) {
733
789
  const list = Array.isArray(brokers) ? brokers : brokers.split(",");
734
- return list.map((b) => expandEnv3(b.trim())).filter(Boolean);
790
+ return list.map((b) => expandEnv(b.trim(), "kafka source brokers")).filter(Boolean);
735
791
  }
736
792
  var KafkaSourceAdapter = class {
737
793
  constructor(config) {
@@ -760,8 +816,8 @@ var KafkaSourceAdapter = class {
760
816
  ssl: this.config.ssl ?? false,
761
817
  sasl: this.config.sasl ? {
762
818
  mechanism: this.config.sasl.mechanism,
763
- username: expandEnv3(this.config.sasl.username),
764
- password: expandEnv3(this.config.sasl.password)
819
+ username: expandEnv(this.config.sasl.username, "kafka source SASL username"),
820
+ password: expandEnv(this.config.sasl.password, "kafka source SASL password")
765
821
  } : void 0,
766
822
  // Silence kafkajs's own logger — we surface errors via SourceStatus.
767
823
  logLevel: logLevel.ERROR
@@ -2900,7 +2956,7 @@ function escapeHtml(s) {
2900
2956
 
2901
2957
  // src/telegram/notifier.ts
2902
2958
  var botCache = /* @__PURE__ */ new Map();
2903
- function expandEnv4(input) {
2959
+ function expandEnv2(input) {
2904
2960
  return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => process.env[name] ?? "");
2905
2961
  }
2906
2962
  function getBot(token) {
@@ -2922,7 +2978,7 @@ var TelegramNotifier = class {
2922
2978
  "TelegramNotifier: no bot token. Set notify.telegram.botToken or PIPEQUERY_TG_BOT_TOKEN."
2923
2979
  );
2924
2980
  }
2925
- this.token = expandEnv4(tokenSource);
2981
+ this.token = expandEnv2(tokenSource);
2926
2982
  this.chatId = cfg.chatId;
2927
2983
  this.template = cfg.message;
2928
2984
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultgradient/pipequery-cli",
3
- "version": "0.6.1",
3
+ "version": "0.7.0",
4
4
  "description": "CLI for PipeQuery — data sources, aggregations, APIs, and terminal dashboards",
5
5
  "type": "module",
6
6
  "bin": {
@@ -35,12 +35,34 @@ export interface RestSourceConfig {
35
35
  headers?: Record<string, string>;
36
36
  dataPath?: string;
37
37
  interval?: string;
38
+ /** Optional auth helper. Currently supports `bearer`; more kinds (basic,
39
+ * apiKeyHeader, hmac-*) tracked in issue #37. The token is interpolated
40
+ * through ${ENV_VAR} like every other string field. */
41
+ auth?: BearerAuth;
42
+ }
43
+
44
+ export interface BearerAuth {
45
+ kind: 'bearer';
46
+ token: string;
38
47
  }
39
48
 
40
49
  export interface WebSocketSourceConfig {
41
50
  type: 'websocket';
42
51
  url: string;
43
52
  maxBuffer?: number;
53
+ /** Payload(s) to send to the server immediately after the WebSocket
54
+ * opens. Most exchange feeds (Binance, Coinbase, Kraken, OKX, Bybit, …)
55
+ * require this — without it, no data is delivered. Each payload is
56
+ * JSON-stringified and sent in order. Re-sent on every reconnect. */
57
+ subscribe?: unknown | unknown[];
58
+ /** Optional keepalive: sends `payload` to the server every `interval`
59
+ * while the socket is open. Most exchanges close idle sockets within
60
+ * a few minutes; a periodic ping keeps the stream alive. */
61
+ heartbeat?: {
62
+ payload: unknown;
63
+ /** Duration like "30s", "1m". */
64
+ interval: string;
65
+ };
44
66
  }
45
67
 
46
68
  export interface FileSourceConfig {
@@ -1,26 +1,14 @@
1
1
  import { randomBytes } from 'node:crypto';
2
2
  import { Kafka, logLevel, type Consumer, type SASLOptions } from 'kafkajs';
3
3
  import type { KafkaSourceConfig } from '../../config/schema.js';
4
+ import { expandEnv } from '../../utils/expandEnv.js';
4
5
  import type { SourceAdapter, SourceStatus } from './types.js';
5
6
 
6
7
  const DEFAULT_MAX_BUFFER = 1000;
7
8
 
8
- function expandEnv(input: string): string {
9
- return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
10
- const value = process.env[name];
11
- if (value === undefined) {
12
- process.stderr.write(
13
- `[pipequery] env var "${name}" referenced in kafka source is not set\n`,
14
- );
15
- return '';
16
- }
17
- return value;
18
- });
19
- }
20
-
21
9
  function normalizeBrokers(brokers: string | string[]): string[] {
22
10
  const list = Array.isArray(brokers) ? brokers : brokers.split(',');
23
- return list.map((b) => expandEnv(b.trim())).filter(Boolean);
11
+ return list.map((b) => expandEnv(b.trim(), 'kafka source brokers')).filter(Boolean);
24
12
  }
25
13
 
26
14
  export class KafkaSourceAdapter implements SourceAdapter {
@@ -55,8 +43,8 @@ export class KafkaSourceAdapter implements SourceAdapter {
55
43
  sasl: this.config.sasl
56
44
  ? ({
57
45
  mechanism: this.config.sasl.mechanism,
58
- username: expandEnv(this.config.sasl.username),
59
- password: expandEnv(this.config.sasl.password),
46
+ username: expandEnv(this.config.sasl.username, 'kafka source SASL username'),
47
+ password: expandEnv(this.config.sasl.password, 'kafka source SASL password'),
60
48
  } as SASLOptions)
61
49
  : undefined,
62
50
  // Silence kafkajs's own logger — we surface errors via SourceStatus.
@@ -1,24 +1,12 @@
1
1
  import mysql from 'mysql2/promise';
2
2
  import type { MysqlSourceConfig } from '../../config/schema.js';
3
3
  import { parseDuration } from '../../utils/parseDuration.js';
4
+ import { expandEnv } from '../../utils/expandEnv.js';
4
5
  import type { SourceAdapter, SourceStatus } from './types.js';
5
6
 
6
7
  const DEFAULT_INTERVAL_MS = 30_000;
7
8
  const DEFAULT_MAX_ROWS = 10_000;
8
9
 
9
- function expandEnv(input: string): string {
10
- return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
11
- const value = process.env[name];
12
- if (value === undefined) {
13
- process.stderr.write(
14
- `[pipequery] env var "${name}" referenced in mysql source URL is not set\n`,
15
- );
16
- return '';
17
- }
18
- return value;
19
- });
20
- }
21
-
22
10
  function resolveSsl(ssl: MysqlSourceConfig['ssl']): mysql.PoolOptions['ssl'] {
23
11
  if (ssl === false) return undefined;
24
12
  if (ssl === 'no-verify') return { rejectUnauthorized: false };
@@ -41,7 +29,7 @@ export class MysqlSourceAdapter implements SourceAdapter {
41
29
  }
42
30
 
43
31
  async start(): Promise<void> {
44
- const uri = expandEnv(this.config.url);
32
+ const uri = expandEnv(this.config.url, 'mysql source URL');
45
33
  this.pool = mysql.createPool({
46
34
  uri,
47
35
  ssl: resolveSsl(this.config.ssl),
@@ -1,30 +1,12 @@
1
1
  import pg from 'pg';
2
2
  import type { PostgresSourceConfig } from '../../config/schema.js';
3
3
  import { parseDuration } from '../../utils/parseDuration.js';
4
+ import { expandEnv } from '../../utils/expandEnv.js';
4
5
  import type { SourceAdapter, SourceStatus } from './types.js';
5
6
 
6
7
  const DEFAULT_INTERVAL_MS = 30_000;
7
8
  const DEFAULT_MAX_ROWS = 10_000;
8
9
 
9
- /**
10
- * Resolve ${ENV_VAR} placeholders in a string from process.env.
11
- * Missing vars expand to empty string (with a stderr warning) so a
12
- * mis-typed env doesn't silently embed literal "${FOO}" into a
13
- * Postgres connection string.
14
- */
15
- function expandEnv(input: string): string {
16
- return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
17
- const value = process.env[name];
18
- if (value === undefined) {
19
- process.stderr.write(
20
- `[pipequery] env var "${name}" referenced in postgres source URL is not set\n`,
21
- );
22
- return '';
23
- }
24
- return value;
25
- });
26
- }
27
-
28
10
  function resolveSsl(ssl: PostgresSourceConfig['ssl']): pg.ClientConfig['ssl'] {
29
11
  if (ssl === false) return false;
30
12
  if (ssl === 'no-verify') return { rejectUnauthorized: false };
@@ -48,7 +30,7 @@ export class PostgresSourceAdapter implements SourceAdapter {
48
30
  }
49
31
 
50
32
  async start(): Promise<void> {
51
- const connectionString = expandEnv(this.config.url);
33
+ const connectionString = expandEnv(this.config.url, 'postgres source URL');
52
34
  this.pool = new pg.Pool({
53
35
  connectionString,
54
36
  ssl: resolveSsl(this.config.ssl),
@@ -1,5 +1,6 @@
1
1
  import type { RestSourceConfig } from '../../config/schema.js';
2
2
  import { parseDuration } from '../../utils/parseDuration.js';
3
+ import { expandEnv } from '../../utils/expandEnv.js';
3
4
  import type { SourceAdapter, SourceStatus } from './types.js';
4
5
 
5
6
  function extractPath(obj: unknown, path: string): unknown[] {
@@ -57,15 +58,19 @@ export class RestSourceAdapter implements SourceAdapter {
57
58
 
58
59
  private async fetch(): Promise<void> {
59
60
  try {
60
- let url = this.config.url;
61
+ let url = expandEnv(this.config.url, 'rest source URL');
61
62
  if (this.config.params) {
62
- const params = new URLSearchParams(this.config.params);
63
+ const expanded: Record<string, string> = {};
64
+ for (const [k, v] of Object.entries(this.config.params)) {
65
+ expanded[k] = expandEnv(v, `rest source param "${k}"`);
66
+ }
67
+ const params = new URLSearchParams(expanded);
63
68
  url += (url.includes('?') ? '&' : '?') + params.toString();
64
69
  }
65
70
 
66
- const res = await globalThis.fetch(url, {
67
- headers: this.config.headers,
68
- });
71
+ const headers = this.buildHeaders();
72
+
73
+ const res = await globalThis.fetch(url, { headers });
69
74
 
70
75
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
71
76
 
@@ -82,4 +87,27 @@ export class RestSourceAdapter implements SourceAdapter {
82
87
  private notify(): void {
83
88
  for (const cb of this.listeners) cb(this.data);
84
89
  }
90
+
91
+ /**
92
+ * Build the final HTTP headers for one fetch.
93
+ *
94
+ * Two sources contribute, in order: any user-declared `headers` (with
95
+ * env-var interpolation applied per value), then the `auth` helper
96
+ * (currently just `bearer`). Auth wins on collision — if a user sets
97
+ * both `headers.Authorization` and `auth.kind === 'bearer'`, the bearer
98
+ * helper is what they actually meant.
99
+ */
100
+ private buildHeaders(): Record<string, string> | undefined {
101
+ const headers: Record<string, string> = {};
102
+ if (this.config.headers) {
103
+ for (const [k, v] of Object.entries(this.config.headers)) {
104
+ headers[k] = expandEnv(v, `rest source header "${k}"`);
105
+ }
106
+ }
107
+ if (this.config.auth?.kind === 'bearer') {
108
+ const token = expandEnv(this.config.auth.token, 'rest source bearer token');
109
+ headers['Authorization'] = `Bearer ${token}`;
110
+ }
111
+ return Object.keys(headers).length > 0 ? headers : undefined;
112
+ }
85
113
  }
@@ -1,6 +1,7 @@
1
1
  import WebSocket from 'ws';
2
2
  import type { WebSocketSourceConfig } from '../../config/schema.js';
3
3
  import type { SourceAdapter, SourceStatus } from './types.js';
4
+ import { parseDuration } from '../../utils/parseDuration.js';
4
5
 
5
6
  export class WebSocketSourceAdapter implements SourceAdapter {
6
7
  private data: unknown[] = [];
@@ -9,21 +10,44 @@ export class WebSocketSourceAdapter implements SourceAdapter {
9
10
  private error?: string;
10
11
  private listeners = new Set<(data: unknown[]) => void>();
11
12
  private maxBuffer: number;
13
+ private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
14
+ /** Tracks whether stop() was called so reconnect respects intent. */
15
+ private stopped = false;
12
16
 
13
17
  constructor(private config: WebSocketSourceConfig) {
14
18
  this.maxBuffer = config.maxBuffer ?? 1000;
15
19
  }
16
20
 
17
21
  async start(): Promise<void> {
22
+ this.stopped = false;
18
23
  return new Promise((resolve, reject) => {
19
- this.ws = new WebSocket(this.config.url);
24
+ const ws = new WebSocket(this.config.url);
25
+ this.ws = ws;
26
+ let settled = false;
20
27
 
21
- this.ws.on('open', () => {
28
+ ws.on('open', () => {
22
29
  this.error = undefined;
23
- resolve();
30
+ try {
31
+ this.sendSubscribePayloads(ws);
32
+ this.startHeartbeat(ws);
33
+ } catch (err) {
34
+ // A failure to send the subscribe frame leaves the socket open
35
+ // but useless; record it and reject the start() promise.
36
+ this.error = err instanceof Error ? err.message : String(err);
37
+ ws.close();
38
+ if (!settled) {
39
+ settled = true;
40
+ reject(err instanceof Error ? err : new Error(this.error));
41
+ }
42
+ return;
43
+ }
44
+ if (!settled) {
45
+ settled = true;
46
+ resolve();
47
+ }
24
48
  });
25
49
 
26
- this.ws.on('message', (raw) => {
50
+ ws.on('message', (raw) => {
27
51
  try {
28
52
  const parsed = JSON.parse(raw.toString());
29
53
  if (Array.isArray(parsed)) {
@@ -42,21 +66,30 @@ export class WebSocketSourceAdapter implements SourceAdapter {
42
66
  }
43
67
  });
44
68
 
45
- this.ws.on('error', (err) => {
69
+ ws.on('error', (err) => {
46
70
  this.error = err.message;
47
- reject(err);
71
+ if (!settled) {
72
+ settled = true;
73
+ reject(err);
74
+ }
48
75
  });
49
76
 
50
- this.ws.on('close', () => {
51
- // Auto-reconnect after 5s
77
+ ws.on('close', () => {
78
+ this.stopHeartbeat();
79
+ // Auto-reconnect after 5s unless stop() was explicitly called.
80
+ // Re-running start() also re-sends the subscribe payloads, which
81
+ // is exactly what every exchange expects after a connection drop.
82
+ if (this.stopped) return;
52
83
  setTimeout(() => {
53
- if (this.ws) this.start().catch(() => {});
84
+ if (!this.stopped) this.start().catch(() => {});
54
85
  }, 5000);
55
86
  });
56
87
  });
57
88
  }
58
89
 
59
90
  stop(): void {
91
+ this.stopped = true;
92
+ this.stopHeartbeat();
60
93
  const ws = this.ws;
61
94
  this.ws = null;
62
95
  ws?.close();
@@ -83,4 +116,35 @@ export class WebSocketSourceAdapter implements SourceAdapter {
83
116
  private notify(): void {
84
117
  for (const cb of this.listeners) cb(this.data);
85
118
  }
119
+
120
+ private sendSubscribePayloads(ws: WebSocket): void {
121
+ const subscribe = this.config.subscribe;
122
+ if (subscribe === undefined || subscribe === null) return;
123
+ const list = Array.isArray(subscribe) ? subscribe : [subscribe];
124
+ for (const payload of list) {
125
+ ws.send(JSON.stringify(payload));
126
+ }
127
+ }
128
+
129
+ private startHeartbeat(ws: WebSocket): void {
130
+ this.stopHeartbeat();
131
+ const hb = this.config.heartbeat;
132
+ if (!hb) return;
133
+ const intervalMs = parseDuration(hb.interval);
134
+ this.heartbeatTimer = setInterval(() => {
135
+ if (ws.readyState !== WebSocket.OPEN) return;
136
+ try {
137
+ ws.send(JSON.stringify(hb.payload));
138
+ } catch {
139
+ // Ignore — next 'close' / 'error' will surface anything fatal.
140
+ }
141
+ }, intervalMs);
142
+ }
143
+
144
+ private stopHeartbeat(): void {
145
+ if (this.heartbeatTimer) {
146
+ clearInterval(this.heartbeatTimer);
147
+ this.heartbeatTimer = null;
148
+ }
149
+ }
86
150
  }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Resolve `${ENV_VAR}` placeholders in a string from process.env.
3
+ *
4
+ * Missing vars expand to empty string and emit a stderr warning so a
5
+ * mistyped env doesn't silently embed literal "${FOO}" into a connection
6
+ * string, header, URL, etc.
7
+ *
8
+ * `context` is included in the warning to help users locate the source
9
+ * of the misconfiguration (e.g. "postgres source URL", "rest source
10
+ * Authorization header"). Pass a short, human-readable string.
11
+ */
12
+ export function expandEnv(input: string, context: string): string {
13
+ return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name: string) => {
14
+ const value = process.env[name];
15
+ if (value === undefined) {
16
+ process.stderr.write(
17
+ `[pipequery] env var "${name}" referenced in ${context} is not set\n`,
18
+ );
19
+ return '';
20
+ }
21
+ return value;
22
+ });
23
+ }