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