@vaultgradient/pipequery-cli 0.6.0 → 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,125 @@
1
+ /**
2
+ * Regression test for the v0.6.0 watches-loader bug:
3
+ *
4
+ * loadConfig() built the typed PipeQueryConfig without copying raw.watches,
5
+ * so config.watches was always undefined. That silently broke three things:
6
+ * 1. pq watch list always reported "no watches"
7
+ * 2. pq watch add overwrote previous watches (load → empty → save → only the new one)
8
+ * 3. WatchManager never started in pq serve (the `if (config.watches && ...)` was always false)
9
+ *
10
+ * The fix is one line in loader.ts. These tests cover the round-trip and the
11
+ * additive save+reload behaviour so the regression can't sneak back.
12
+ */
13
+ import { mkdtemp, rm } from 'node:fs/promises';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
17
+ import { writeYaml } from '../src/utils/yaml.js';
18
+ import { loadConfig, saveConfig } from '../src/config/loader.js';
19
+ import type { PipeQueryConfig, WatchConfig } from '../src/config/schema.js';
20
+
21
+ describe('config loader — watches', () => {
22
+ let dir: string;
23
+ beforeEach(async () => {
24
+ dir = await mkdtemp(join(tmpdir(), 'pq-loader-test-'));
25
+ });
26
+ afterEach(async () => {
27
+ await rm(dir, { recursive: true, force: true });
28
+ });
29
+
30
+ it('preserves watches block from yaml on load', async () => {
31
+ const yamlPath = join(dir, 'pipequery.yaml');
32
+ const watch: WatchConfig = {
33
+ query: 'crypto | first(1)',
34
+ interval: '30s',
35
+ fireWhen: 'on_change',
36
+ notify: { telegram: { chatId: 123456789 } },
37
+ };
38
+ await writeYaml(yamlPath, {
39
+ sources: {},
40
+ endpoints: {},
41
+ watches: { foo: watch },
42
+ });
43
+
44
+ const { config } = await loadConfig(dir);
45
+
46
+ expect(config.watches).toBeDefined();
47
+ expect(Object.keys(config.watches!)).toEqual(['foo']);
48
+ expect(config.watches!.foo.query).toBe('crypto | first(1)');
49
+ expect(config.watches!.foo.fireWhen).toBe('on_change');
50
+ expect(config.watches!.foo.notify.telegram?.chatId).toBe(123456789);
51
+ });
52
+
53
+ it('save+reload keeps existing watches when adding a new one (no clobber)', async () => {
54
+ // Mirrors the user-reported reproduction: two consecutive `pq watch add`
55
+ // invocations should both end up in the yaml.
56
+ const yamlPath = join(dir, 'pipequery.yaml');
57
+ await writeYaml(yamlPath, {
58
+ sources: {},
59
+ endpoints: {},
60
+ });
61
+
62
+ // First add — load (empty), append `foo`, save.
63
+ const first = await loadConfig(dir);
64
+ first.config.watches = first.config.watches ?? {};
65
+ first.config.watches.foo = {
66
+ query: 'a | first(1)',
67
+ notify: { telegram: { chatId: 1 } },
68
+ };
69
+ await saveConfig(first.config, first.path);
70
+
71
+ // Second add — load (must include foo!), append `bar`, save.
72
+ const second = await loadConfig(dir);
73
+ second.config.watches = second.config.watches ?? {};
74
+ second.config.watches.bar = {
75
+ query: 'b | first(1)',
76
+ notify: { telegram: { chatId: 2 } },
77
+ };
78
+ await saveConfig(second.config, second.path);
79
+
80
+ // Reload and verify both watches survived.
81
+ const final = await loadConfig(dir);
82
+ expect(Object.keys(final.config.watches ?? {}).sort()).toEqual(['bar', 'foo']);
83
+ });
84
+
85
+ it('returns an empty watches object when the yaml has no watches block', async () => {
86
+ // Defaulting to {} (rather than undefined) keeps the call sites in
87
+ // watch.ts simple — they can write `config.watches[name] = ...` without
88
+ // a nullish-fallback dance.
89
+ const yamlPath = join(dir, 'pipequery.yaml');
90
+ await writeYaml(yamlPath, {
91
+ sources: {},
92
+ endpoints: {},
93
+ });
94
+
95
+ const { config } = await loadConfig(dir);
96
+ expect(config.watches).toEqual({});
97
+ });
98
+
99
+ it('round-trips a watches block through save → reload unchanged', async () => {
100
+ const yamlPath = join(dir, 'pipequery.yaml');
101
+ const original: PipeQueryConfig = {
102
+ server: { port: 3000, host: '127.0.0.1' },
103
+ sources: {},
104
+ endpoints: {},
105
+ dashboards: {},
106
+ watches: {
107
+ big: {
108
+ query: 'events | where(severity == "high")',
109
+ interval: '60s',
110
+ fireWhen: 'when_non_empty',
111
+ notify: {
112
+ telegram: {
113
+ chatId: -1001234567890,
114
+ message: '🚨 high severity: {{ .msg }}',
115
+ },
116
+ },
117
+ },
118
+ },
119
+ };
120
+ await saveConfig(original, yamlPath);
121
+
122
+ const { config } = await loadConfig(dir);
123
+ expect(config.watches).toEqual(original.watches);
124
+ });
125
+ });
@@ -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
@@ -123,7 +123,8 @@ async function loadConfig(cwd = process.cwd()) {
123
123
  remote: raw.remote,
124
124
  sources: raw.sources ?? {},
125
125
  endpoints: raw.endpoints ?? {},
126
- dashboards: raw.dashboards ?? {}
126
+ dashboards: raw.dashboards ?? {},
127
+ watches: raw.watches ?? {}
127
128
  };
128
129
  return { config, path: configPath };
129
130
  }
@@ -148,6 +149,21 @@ function parseDuration(input) {
148
149
  return parseFloat(match[1]) * UNITS[match[2]];
149
150
  }
150
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
+
151
167
  // src/server/sources/rest.ts
152
168
  function extractPath(obj, path) {
153
169
  let current = obj;
@@ -198,14 +214,17 @@ var RestSourceAdapter = class {
198
214
  }
199
215
  async fetch() {
200
216
  try {
201
- let url = this.config.url;
217
+ let url = expandEnv(this.config.url, "rest source URL");
202
218
  if (this.config.params) {
203
- 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);
204
224
  url += (url.includes("?") ? "&" : "?") + params.toString();
205
225
  }
206
- const res = await globalThis.fetch(url, {
207
- headers: this.config.headers
208
- });
226
+ const headers = this.buildHeaders();
227
+ const res = await globalThis.fetch(url, { headers });
209
228
  if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
210
229
  const json = await res.json();
211
230
  this.data = this.config.dataPath ? extractPath(json, this.config.dataPath) : Array.isArray(json) ? json : [json];
@@ -219,6 +238,28 @@ var RestSourceAdapter = class {
219
238
  notify() {
220
239
  for (const cb of this.listeners) cb(this.data);
221
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
+ }
222
263
  };
223
264
 
224
265
  // src/server/sources/websocket.ts
@@ -235,14 +276,35 @@ var WebSocketSourceAdapter = class {
235
276
  error;
236
277
  listeners = /* @__PURE__ */ new Set();
237
278
  maxBuffer;
279
+ heartbeatTimer = null;
280
+ /** Tracks whether stop() was called so reconnect respects intent. */
281
+ stopped = false;
238
282
  async start() {
283
+ this.stopped = false;
239
284
  return new Promise((resolve10, reject) => {
240
- this.ws = new WebSocket(this.config.url);
241
- this.ws.on("open", () => {
285
+ const ws = new WebSocket(this.config.url);
286
+ this.ws = ws;
287
+ let settled = false;
288
+ ws.on("open", () => {
242
289
  this.error = void 0;
243
- 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
+ }
244
306
  });
245
- this.ws.on("message", (raw) => {
307
+ ws.on("message", (raw) => {
246
308
  try {
247
309
  const parsed = JSON.parse(raw.toString());
248
310
  if (Array.isArray(parsed)) {
@@ -259,19 +321,26 @@ var WebSocketSourceAdapter = class {
259
321
  } catch {
260
322
  }
261
323
  });
262
- this.ws.on("error", (err) => {
324
+ ws.on("error", (err) => {
263
325
  this.error = err.message;
264
- reject(err);
326
+ if (!settled) {
327
+ settled = true;
328
+ reject(err);
329
+ }
265
330
  });
266
- this.ws.on("close", () => {
331
+ ws.on("close", () => {
332
+ this.stopHeartbeat();
333
+ if (this.stopped) return;
267
334
  setTimeout(() => {
268
- if (this.ws) this.start().catch(() => {
335
+ if (!this.stopped) this.start().catch(() => {
269
336
  });
270
337
  }, 5e3);
271
338
  });
272
339
  });
273
340
  }
274
341
  stop() {
342
+ this.stopped = true;
343
+ this.stopHeartbeat();
275
344
  const ws = this.ws;
276
345
  this.ws = null;
277
346
  ws?.close();
@@ -294,6 +363,33 @@ var WebSocketSourceAdapter = class {
294
363
  notify() {
295
364
  for (const cb of this.listeners) cb(this.data);
296
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
+ }
297
393
  };
298
394
 
299
395
  // src/server/sources/file.ts
@@ -398,19 +494,6 @@ var StaticSourceAdapter = class {
398
494
  import pg from "pg";
399
495
  var DEFAULT_INTERVAL_MS = 3e4;
400
496
  var DEFAULT_MAX_ROWS = 1e4;
401
- function expandEnv(input) {
402
- return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
403
- const value = process.env[name];
404
- if (value === void 0) {
405
- process.stderr.write(
406
- `[pipequery] env var "${name}" referenced in postgres source URL is not set
407
- `
408
- );
409
- return "";
410
- }
411
- return value;
412
- });
413
- }
414
497
  function resolveSsl(ssl) {
415
498
  if (ssl === false) return false;
416
499
  if (ssl === "no-verify") return { rejectUnauthorized: false };
@@ -432,7 +515,7 @@ var PostgresSourceAdapter = class {
432
515
  intervalMs;
433
516
  maxRows;
434
517
  async start() {
435
- const connectionString = expandEnv(this.config.url);
518
+ const connectionString = expandEnv(this.config.url, "postgres source URL");
436
519
  this.pool = new pg.Pool({
437
520
  connectionString,
438
521
  ssl: resolveSsl(this.config.ssl),
@@ -510,19 +593,6 @@ function stripTrailingSemicolon(sql) {
510
593
  import mysql from "mysql2/promise";
511
594
  var DEFAULT_INTERVAL_MS2 = 3e4;
512
595
  var DEFAULT_MAX_ROWS2 = 1e4;
513
- function expandEnv2(input) {
514
- return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
515
- const value = process.env[name];
516
- if (value === void 0) {
517
- process.stderr.write(
518
- `[pipequery] env var "${name}" referenced in mysql source URL is not set
519
- `
520
- );
521
- return "";
522
- }
523
- return value;
524
- });
525
- }
526
596
  function resolveSsl2(ssl) {
527
597
  if (ssl === false) return void 0;
528
598
  if (ssl === "no-verify") return { rejectUnauthorized: false };
@@ -544,7 +614,7 @@ var MysqlSourceAdapter = class {
544
614
  intervalMs;
545
615
  maxRows;
546
616
  async start() {
547
- const uri = expandEnv2(this.config.url);
617
+ const uri = expandEnv(this.config.url, "mysql source URL");
548
618
  this.pool = mysql.createPool({
549
619
  uri,
550
620
  ssl: resolveSsl2(this.config.ssl),
@@ -715,22 +785,9 @@ function stripTrailingSemicolon3(sql) {
715
785
  import { randomBytes } from "crypto";
716
786
  import { Kafka, logLevel } from "kafkajs";
717
787
  var DEFAULT_MAX_BUFFER = 1e3;
718
- function expandEnv3(input) {
719
- return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => {
720
- const value = process.env[name];
721
- if (value === void 0) {
722
- process.stderr.write(
723
- `[pipequery] env var "${name}" referenced in kafka source is not set
724
- `
725
- );
726
- return "";
727
- }
728
- return value;
729
- });
730
- }
731
788
  function normalizeBrokers(brokers) {
732
789
  const list = Array.isArray(brokers) ? brokers : brokers.split(",");
733
- return list.map((b) => expandEnv3(b.trim())).filter(Boolean);
790
+ return list.map((b) => expandEnv(b.trim(), "kafka source brokers")).filter(Boolean);
734
791
  }
735
792
  var KafkaSourceAdapter = class {
736
793
  constructor(config) {
@@ -759,8 +816,8 @@ var KafkaSourceAdapter = class {
759
816
  ssl: this.config.ssl ?? false,
760
817
  sasl: this.config.sasl ? {
761
818
  mechanism: this.config.sasl.mechanism,
762
- username: expandEnv3(this.config.sasl.username),
763
- 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")
764
821
  } : void 0,
765
822
  // Silence kafkajs's own logger — we surface errors via SourceStatus.
766
823
  logLevel: logLevel.ERROR
@@ -2899,7 +2956,7 @@ function escapeHtml(s) {
2899
2956
 
2900
2957
  // src/telegram/notifier.ts
2901
2958
  var botCache = /* @__PURE__ */ new Map();
2902
- function expandEnv4(input) {
2959
+ function expandEnv2(input) {
2903
2960
  return input.replace(/\$\{([A-Z0-9_]+)\}/gi, (_, name) => process.env[name] ?? "");
2904
2961
  }
2905
2962
  function getBot(token) {
@@ -2921,7 +2978,7 @@ var TelegramNotifier = class {
2921
2978
  "TelegramNotifier: no bot token. Set notify.telegram.botToken or PIPEQUERY_TG_BOT_TOKEN."
2922
2979
  );
2923
2980
  }
2924
- this.token = expandEnv4(tokenSource);
2981
+ this.token = expandEnv2(tokenSource);
2925
2982
  this.chatId = cfg.chatId;
2926
2983
  this.template = cfg.message;
2927
2984
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vaultgradient/pipequery-cli",
3
- "version": "0.6.0",
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": {
@@ -27,6 +27,7 @@ export async function loadConfig(cwd = process.cwd()): Promise<{ config: PipeQue
27
27
  sources: raw.sources ?? {},
28
28
  endpoints: raw.endpoints ?? {},
29
29
  dashboards: raw.dashboards ?? {},
30
+ watches: raw.watches ?? {},
30
31
  };
31
32
 
32
33
  return { config, path: configPath };
@@ -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
+ }