@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.
- package/__tests__/loader-watches.test.ts +125 -0
- package/__tests__/rest-source.test.ts +191 -0
- package/__tests__/websocket-source.test.ts +187 -0
- package/dist/index.js +117 -60
- package/package.json +1 -1
- package/src/config/loader.ts +1 -0
- package/src/config/schema.ts +22 -0
- package/src/server/sources/kafka.ts +4 -16
- package/src/server/sources/mysql.ts +2 -14
- package/src/server/sources/postgres.ts +2 -20
- package/src/server/sources/rest.ts +33 -5
- package/src/server/sources/websocket.ts +73 -9
- package/src/utils/expandEnv.ts +23 -0
|
@@ -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
|
|
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
|
|
207
|
-
|
|
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
|
-
|
|
241
|
-
this.ws
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
324
|
+
ws.on("error", (err) => {
|
|
263
325
|
this.error = err.message;
|
|
264
|
-
|
|
326
|
+
if (!settled) {
|
|
327
|
+
settled = true;
|
|
328
|
+
reject(err);
|
|
329
|
+
}
|
|
265
330
|
});
|
|
266
|
-
|
|
331
|
+
ws.on("close", () => {
|
|
332
|
+
this.stopHeartbeat();
|
|
333
|
+
if (this.stopped) return;
|
|
267
334
|
setTimeout(() => {
|
|
268
|
-
if (this.
|
|
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 =
|
|
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) =>
|
|
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:
|
|
763
|
-
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
|
|
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 =
|
|
2981
|
+
this.token = expandEnv2(tokenSource);
|
|
2925
2982
|
this.chatId = cfg.chatId;
|
|
2926
2983
|
this.template = cfg.message;
|
|
2927
2984
|
}
|
package/package.json
CHANGED
package/src/config/loader.ts
CHANGED
|
@@ -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 };
|
package/src/config/schema.ts
CHANGED
|
@@ -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
|
|
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
|
|
67
|
-
|
|
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
|
-
|
|
24
|
+
const ws = new WebSocket(this.config.url);
|
|
25
|
+
this.ws = ws;
|
|
26
|
+
let settled = false;
|
|
20
27
|
|
|
21
|
-
|
|
28
|
+
ws.on('open', () => {
|
|
22
29
|
this.error = undefined;
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
+
ws.on('error', (err) => {
|
|
46
70
|
this.error = err.message;
|
|
47
|
-
|
|
71
|
+
if (!settled) {
|
|
72
|
+
settled = true;
|
|
73
|
+
reject(err);
|
|
74
|
+
}
|
|
48
75
|
});
|
|
49
76
|
|
|
50
|
-
|
|
51
|
-
|
|
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.
|
|
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
|
+
}
|