@modelriver/cli 1.0.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/README.md +406 -0
- package/bin/modelriver +5 -0
- package/package.json +52 -0
- package/src/commands/forward.js +61 -0
- package/src/commands/listen.js +315 -0
- package/src/commands/login.js +176 -0
- package/src/commands/test-webhook.js +289 -0
- package/src/commands/trigger.js +77 -0
- package/src/commands/webhook.js +101 -0
- package/src/commands/websocket.js +249 -0
- package/src/index.js +154 -0
- package/src/lib/api-client.js +173 -0
- package/src/lib/api-client.test.js +101 -0
- package/src/lib/cli-websocket-client.js +249 -0
- package/src/lib/config.js +137 -0
- package/src/lib/config.test.js +98 -0
- package/src/lib/webhook-verifier.js +56 -0
- package/src/lib/webhook-verifier.test.js +90 -0
- package/src/lib/websocket-client.js +225 -0
- package/src/utils/formatter.js +43 -0
- package/src/utils/formatter.test.js +84 -0
- package/src/utils/logger.js +39 -0
- package/src/utils/url-helpers.js +98 -0
- package/src/utils/url-helpers.test.js +84 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
const WebSocket = require('ws');
|
|
2
|
+
|
|
3
|
+
class WebSocketClient {
|
|
4
|
+
constructor(websocketUrl, token, verbose = false) {
|
|
5
|
+
this.websocketUrl = websocketUrl;
|
|
6
|
+
this.token = token;
|
|
7
|
+
this.verbose = verbose;
|
|
8
|
+
this.ws = null;
|
|
9
|
+
this.channel = null;
|
|
10
|
+
this.listeners = {};
|
|
11
|
+
this.heartbeatInterval = null;
|
|
12
|
+
this.heartbeatRef = 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Connect to WebSocket
|
|
17
|
+
* @returns {Promise<void>}
|
|
18
|
+
*/
|
|
19
|
+
connect() {
|
|
20
|
+
return new Promise((resolve, reject) => {
|
|
21
|
+
const url = `${this.websocketUrl}/websocket?token=${encodeURIComponent(this.token)}`;
|
|
22
|
+
|
|
23
|
+
this.ws = new WebSocket(url);
|
|
24
|
+
|
|
25
|
+
this.ws.on('open', () => {
|
|
26
|
+
// Start sending heartbeats every 30 seconds (Phoenix default is 30s)
|
|
27
|
+
this.startHeartbeat();
|
|
28
|
+
resolve();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this.ws.on('error', (error) => {
|
|
32
|
+
this.stopHeartbeat();
|
|
33
|
+
reject(error);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
this.ws.on('message', (data) => {
|
|
37
|
+
this.handleMessage(data);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
this.ws.on('close', () => {
|
|
41
|
+
this.stopHeartbeat();
|
|
42
|
+
if (this.listeners.close) {
|
|
43
|
+
this.listeners.close();
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start sending Phoenix heartbeats
|
|
51
|
+
*/
|
|
52
|
+
startHeartbeat() {
|
|
53
|
+
this.heartbeatInterval = setInterval(() => {
|
|
54
|
+
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
|
55
|
+
this.heartbeatRef++;
|
|
56
|
+
const heartbeat = JSON.stringify({
|
|
57
|
+
topic: 'phoenix',
|
|
58
|
+
event: 'heartbeat',
|
|
59
|
+
payload: {},
|
|
60
|
+
ref: `heartbeat-${this.heartbeatRef}`
|
|
61
|
+
});
|
|
62
|
+
this.ws.send(heartbeat);
|
|
63
|
+
}
|
|
64
|
+
}, 30000); // Every 30 seconds
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Stop sending heartbeats
|
|
69
|
+
*/
|
|
70
|
+
stopHeartbeat() {
|
|
71
|
+
if (this.heartbeatInterval) {
|
|
72
|
+
clearInterval(this.heartbeatInterval);
|
|
73
|
+
this.heartbeatInterval = null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Join a Phoenix channel
|
|
80
|
+
* @param {string} channelName - Channel name (e.g., "ai_response:project_id:channel_id")
|
|
81
|
+
* @returns {Promise<object>} - Join response
|
|
82
|
+
*/
|
|
83
|
+
joinChannel(channelName) {
|
|
84
|
+
return new Promise((resolve, reject) => {
|
|
85
|
+
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
|
|
86
|
+
reject(new Error('WebSocket is not connected'));
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const joinRef = '1';
|
|
91
|
+
|
|
92
|
+
const joinMsg = JSON.stringify({
|
|
93
|
+
topic: channelName,
|
|
94
|
+
event: 'phx_join',
|
|
95
|
+
payload: {},
|
|
96
|
+
ref: joinRef
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
let resolved = false;
|
|
100
|
+
|
|
101
|
+
// Set up a temporary listener for the join response
|
|
102
|
+
// This will process the response and then be removed
|
|
103
|
+
const handleJoinResponse = (data) => {
|
|
104
|
+
if (resolved) return;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const msg = JSON.parse(data.toString());
|
|
108
|
+
// Look for the join response (phx_reply with our ref)
|
|
109
|
+
if (msg.event === 'phx_reply' && msg.ref === joinRef && msg.topic === channelName) {
|
|
110
|
+
resolved = true;
|
|
111
|
+
// Remove this listener - handleMessage() will continue processing other messages
|
|
112
|
+
this.ws.removeListener('message', handleJoinResponse);
|
|
113
|
+
|
|
114
|
+
if (msg.payload.status === 'ok') {
|
|
115
|
+
this.channel = channelName;
|
|
116
|
+
resolve(msg.payload);
|
|
117
|
+
} else {
|
|
118
|
+
reject(new Error(`Failed to join channel: ${msg.payload.response || 'Unknown error'}`));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
} catch (error) {
|
|
122
|
+
// Ignore parse errors, wait for next message
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
// Add listener for join response
|
|
127
|
+
// The handleMessage() listener in connect() will also be called, but won't match
|
|
128
|
+
this.ws.on('message', handleJoinResponse);
|
|
129
|
+
|
|
130
|
+
this.ws.send(joinMsg);
|
|
131
|
+
|
|
132
|
+
// Timeout after 10 seconds
|
|
133
|
+
setTimeout(() => {
|
|
134
|
+
if (!resolved) {
|
|
135
|
+
resolved = true;
|
|
136
|
+
this.ws.removeListener('message', handleJoinResponse);
|
|
137
|
+
reject(new Error('Channel join timeout'));
|
|
138
|
+
}
|
|
139
|
+
}, 10000);
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Handle incoming WebSocket messages
|
|
145
|
+
* @param {Buffer} data - Message data
|
|
146
|
+
*/
|
|
147
|
+
handleMessage(data) {
|
|
148
|
+
try {
|
|
149
|
+
const msgStr = data.toString();
|
|
150
|
+
const msg = JSON.parse(msgStr);
|
|
151
|
+
|
|
152
|
+
const Logger = require('../utils/logger'); // Lazy load to avoid circular dependency if any
|
|
153
|
+
|
|
154
|
+
// Log all events for debugging purposes (except heartbeats and phx_reply unless verbose)
|
|
155
|
+
if (this.verbose) {
|
|
156
|
+
// In verbose mode, log ALL messages including raw JSON
|
|
157
|
+
if (msg.event !== 'heartbeat') {
|
|
158
|
+
Logger.info(`\n📨 Received WebSocket message:`);
|
|
159
|
+
Logger.info(` Event: ${msg.event}`);
|
|
160
|
+
Logger.info(` Topic: ${msg.topic}`);
|
|
161
|
+
Logger.info(` Payload keys: ${msg.payload ? Object.keys(msg.payload).join(', ') : 'none'}`);
|
|
162
|
+
if (msg.event === 'response') {
|
|
163
|
+
Logger.info(` ✅ This is a RESPONSE event!`);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} else {
|
|
167
|
+
// In non-verbose mode, only log non-standard events
|
|
168
|
+
if (msg.event !== 'phx_reply' && msg.event !== 'heartbeat' && msg.event !== 'response') {
|
|
169
|
+
Logger.info(`received event: ${msg.event} on topic: ${msg.topic}`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Handle response events
|
|
174
|
+
if (msg.event === 'response') {
|
|
175
|
+
if (this.verbose) {
|
|
176
|
+
Logger.info(`\n✅ Processing response event...`);
|
|
177
|
+
}
|
|
178
|
+
if (this.listeners.response) {
|
|
179
|
+
this.listeners.response(msg.payload);
|
|
180
|
+
} else if (this.verbose) {
|
|
181
|
+
Logger.warning(`⚠️ Response event received but no listener registered!`);
|
|
182
|
+
}
|
|
183
|
+
} else if (msg.event === 'phx_reply') {
|
|
184
|
+
// Handle join responses
|
|
185
|
+
if (this.listeners.join) {
|
|
186
|
+
this.listeners.join(msg);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
} catch (error) {
|
|
190
|
+
// Log parse errors in verbose mode
|
|
191
|
+
if (this.verbose) {
|
|
192
|
+
const Logger = require('../utils/logger');
|
|
193
|
+
Logger.warning(`Failed to parse WebSocket message: ${error.message}`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Listen for response events
|
|
200
|
+
* @param {Function} callback - Callback function
|
|
201
|
+
*/
|
|
202
|
+
onResponse(callback) {
|
|
203
|
+
this.listeners.response = callback;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Listen for close events
|
|
208
|
+
* @param {Function} callback - Callback function
|
|
209
|
+
*/
|
|
210
|
+
onClose(callback) {
|
|
211
|
+
this.listeners.close = callback;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Close WebSocket connection
|
|
216
|
+
*/
|
|
217
|
+
close() {
|
|
218
|
+
if (this.ws) {
|
|
219
|
+
this.ws.close();
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
module.exports = WebSocketClient;
|
|
225
|
+
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
class Formatter {
|
|
2
|
+
static json(data, indent = 2) {
|
|
3
|
+
return JSON.stringify(data, null, indent);
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
static table(headers, rows) {
|
|
7
|
+
// Simple table formatter
|
|
8
|
+
const maxWidths = headers.map((header, i) => {
|
|
9
|
+
const headerWidth = header.length;
|
|
10
|
+
const maxRowWidth = Math.max(
|
|
11
|
+
...rows.map(row => String(row[i] || '').length)
|
|
12
|
+
);
|
|
13
|
+
return Math.max(headerWidth, maxRowWidth);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
const formatRow = (row) => {
|
|
17
|
+
return row.map((cell, i) => {
|
|
18
|
+
const str = String(cell || '');
|
|
19
|
+
return str.padEnd(maxWidths[i]);
|
|
20
|
+
}).join(' ');
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const headerRow = formatRow(headers);
|
|
24
|
+
const separator = headers.map((_, i) => '─'.repeat(maxWidths[i])).join(' ');
|
|
25
|
+
const dataRows = rows.map(formatRow);
|
|
26
|
+
|
|
27
|
+
return [headerRow, separator, ...dataRows].join('\n');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
static timestamp(date = new Date()) {
|
|
31
|
+
return date.toISOString();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
static formatDuration(ms) {
|
|
35
|
+
if (ms < 1000) return `${ms}ms`;
|
|
36
|
+
if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
|
|
37
|
+
return `${(ms / 60000).toFixed(1)}m`;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
module.exports = Formatter;
|
|
42
|
+
|
|
43
|
+
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
const Formatter = require('./formatter');
|
|
2
|
+
|
|
3
|
+
describe('Formatter', () => {
|
|
4
|
+
describe('json', () => {
|
|
5
|
+
it('should format objects with default 2-space indentation', () => {
|
|
6
|
+
const obj = { name: 'test', value: 123 };
|
|
7
|
+
const result = Formatter.json(obj);
|
|
8
|
+
|
|
9
|
+
expect(result).toBe('{\n "name": "test",\n "value": 123\n}');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should format with custom indentation', () => {
|
|
13
|
+
const obj = { key: 'value' };
|
|
14
|
+
const result = Formatter.json(obj, 4);
|
|
15
|
+
|
|
16
|
+
expect(result).toBe('{\n "key": "value"\n}');
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should handle arrays', () => {
|
|
20
|
+
const arr = [1, 2, 3];
|
|
21
|
+
const result = Formatter.json(arr);
|
|
22
|
+
|
|
23
|
+
expect(result).toBe('[\n 1,\n 2,\n 3\n]');
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('timestamp', () => {
|
|
28
|
+
it('should return ISO format string', () => {
|
|
29
|
+
const date = new Date('2024-01-09T12:00:00Z');
|
|
30
|
+
const result = Formatter.timestamp(date);
|
|
31
|
+
|
|
32
|
+
expect(result).toBe('2024-01-09T12:00:00.000Z');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should use current date when no argument provided', () => {
|
|
36
|
+
const result = Formatter.timestamp();
|
|
37
|
+
|
|
38
|
+
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('formatDuration', () => {
|
|
43
|
+
it('should format milliseconds', () => {
|
|
44
|
+
expect(Formatter.formatDuration(500)).toBe('500ms');
|
|
45
|
+
expect(Formatter.formatDuration(999)).toBe('999ms');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it('should format seconds', () => {
|
|
49
|
+
expect(Formatter.formatDuration(1000)).toBe('1.0s');
|
|
50
|
+
expect(Formatter.formatDuration(5500)).toBe('5.5s');
|
|
51
|
+
expect(Formatter.formatDuration(59999)).toBe('60.0s');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('should format minutes', () => {
|
|
55
|
+
expect(Formatter.formatDuration(60000)).toBe('1.0m');
|
|
56
|
+
expect(Formatter.formatDuration(90000)).toBe('1.5m');
|
|
57
|
+
expect(Formatter.formatDuration(300000)).toBe('5.0m');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('table', () => {
|
|
62
|
+
it('should format a simple table', () => {
|
|
63
|
+
const headers = ['Name', 'Age'];
|
|
64
|
+
const rows = [
|
|
65
|
+
['Alice', 30],
|
|
66
|
+
['Bob', 25]
|
|
67
|
+
];
|
|
68
|
+
const result = Formatter.table(headers, rows);
|
|
69
|
+
|
|
70
|
+
expect(result).toContain('Name');
|
|
71
|
+
expect(result).toContain('Age');
|
|
72
|
+
expect(result).toContain('Alice');
|
|
73
|
+
expect(result).toContain('Bob');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should handle empty cells', () => {
|
|
77
|
+
const headers = ['A', 'B'];
|
|
78
|
+
const rows = [['value', null]];
|
|
79
|
+
const result = Formatter.table(headers, rows);
|
|
80
|
+
|
|
81
|
+
expect(result).toContain('value');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const chalk = require('chalk');
|
|
2
|
+
const ora = require('ora');
|
|
3
|
+
|
|
4
|
+
class Logger {
|
|
5
|
+
static success(message) {
|
|
6
|
+
console.log(chalk.green(`✓ ${message}`));
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
static error(message) {
|
|
10
|
+
console.error(chalk.red(`✗ ${message}`));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
static info(message) {
|
|
14
|
+
console.log(chalk.blue(`ℹ ${message}`));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
static warning(message) {
|
|
18
|
+
console.log(chalk.yellow(`⚠ ${message}`));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static log(message) {
|
|
22
|
+
console.log(message);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static spinner(message) {
|
|
26
|
+
// Handle both CommonJS and ESM exports
|
|
27
|
+
const oraInstance = typeof ora === 'function' ? ora : (ora.default || ora);
|
|
28
|
+
return oraInstance(message);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
static section(title) {
|
|
32
|
+
console.log(chalk.cyan.bold(`\n${title}`));
|
|
33
|
+
console.log(chalk.gray('─'.repeat(title.length)));
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
module.exports = Logger;
|
|
38
|
+
|
|
39
|
+
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL helper utilities for ModelRiver CLI
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Normalize a webhook URL to ensure it ends with /webhook/modelriver
|
|
7
|
+
* @param {string} url - The input URL
|
|
8
|
+
* @returns {string} - Normalized URL with /webhook/modelriver path
|
|
9
|
+
*/
|
|
10
|
+
function normalizeWebhookUrl(url) {
|
|
11
|
+
if (!url) return url;
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const urlObj = new URL(url);
|
|
15
|
+
let pathname = urlObj.pathname;
|
|
16
|
+
|
|
17
|
+
// Remove trailing slash
|
|
18
|
+
pathname = pathname.replace(/\/$/, '');
|
|
19
|
+
|
|
20
|
+
// Normalize the path to end with /webhook/modelriver
|
|
21
|
+
if (!pathname || pathname === '') {
|
|
22
|
+
pathname = '/webhook/modelriver';
|
|
23
|
+
} else if (pathname === '/webhook' || pathname === '/webhook/') {
|
|
24
|
+
pathname = '/webhook/modelriver';
|
|
25
|
+
} else if (!pathname.endsWith('/webhook/modelriver')) {
|
|
26
|
+
// If path doesn't already end with /webhook/modelriver
|
|
27
|
+
if (pathname.endsWith('/modelriver')) {
|
|
28
|
+
// Already has /modelriver, check if it has /webhook before it
|
|
29
|
+
if (!pathname.includes('/webhook/modelriver')) {
|
|
30
|
+
pathname = '/webhook/modelriver';
|
|
31
|
+
}
|
|
32
|
+
} else if (!pathname.includes('/webhook')) {
|
|
33
|
+
// No /webhook in path at all, append /webhook/modelriver
|
|
34
|
+
pathname = pathname + '/webhook/modelriver';
|
|
35
|
+
} else {
|
|
36
|
+
// Has /webhook but not /modelriver, append /modelriver
|
|
37
|
+
if (pathname.endsWith('/webhook')) {
|
|
38
|
+
pathname = pathname + '/modelriver';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
urlObj.pathname = pathname;
|
|
44
|
+
return urlObj.toString().replace(/\/$/, '');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
// If URL parsing fails, try simple string manipulation
|
|
47
|
+
let result = url.replace(/\/$/, '');
|
|
48
|
+
if (!result.includes('/webhook/modelriver')) {
|
|
49
|
+
if (result.endsWith('/webhook')) {
|
|
50
|
+
result += '/modelriver';
|
|
51
|
+
} else {
|
|
52
|
+
result += '/webhook/modelriver';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract port from a URL
|
|
61
|
+
* @param {string} url - The URL to extract port from
|
|
62
|
+
* @returns {number|null} - Port number or null if not found
|
|
63
|
+
*/
|
|
64
|
+
function extractPort(url) {
|
|
65
|
+
try {
|
|
66
|
+
const urlObj = new URL(url);
|
|
67
|
+
if (urlObj.port) {
|
|
68
|
+
return parseInt(urlObj.port, 10);
|
|
69
|
+
}
|
|
70
|
+
// Return default port based on protocol
|
|
71
|
+
if (urlObj.protocol === 'https:') return 443;
|
|
72
|
+
if (urlObj.protocol === 'http:') return 80;
|
|
73
|
+
return null;
|
|
74
|
+
} catch (error) {
|
|
75
|
+
// Try regex for URLs without protocol
|
|
76
|
+
const match = url.match(/:(\d+)/);
|
|
77
|
+
if (match) {
|
|
78
|
+
return parseInt(match[1], 10);
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Validate API key format
|
|
86
|
+
* @param {string} apiKey - The API key to validate
|
|
87
|
+
* @returns {boolean} - True if valid format
|
|
88
|
+
*/
|
|
89
|
+
function isValidApiKeyFormat(apiKey) {
|
|
90
|
+
if (!apiKey || typeof apiKey !== 'string') return false;
|
|
91
|
+
return apiKey.startsWith('mr_live_') || apiKey.startsWith('mr_test_');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
module.exports = {
|
|
95
|
+
normalizeWebhookUrl,
|
|
96
|
+
extractPort,
|
|
97
|
+
isValidApiKeyFormat
|
|
98
|
+
};
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Tests for URL helper utilities
|
|
2
|
+
const { normalizeWebhookUrl, extractPort, isValidApiKeyFormat } = require('./url-helpers');
|
|
3
|
+
|
|
4
|
+
describe('URL Helpers', () => {
|
|
5
|
+
describe('normalizeWebhookUrl', () => {
|
|
6
|
+
it('should add /webhook/modelriver to base URL', () => {
|
|
7
|
+
expect(normalizeWebhookUrl('http://localhost:4000'))
|
|
8
|
+
.toBe('http://localhost:4000/webhook/modelriver');
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it('should add /modelriver to URL ending with /webhook', () => {
|
|
12
|
+
expect(normalizeWebhookUrl('http://localhost:4000/webhook'))
|
|
13
|
+
.toBe('http://localhost:4000/webhook/modelriver');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should not modify URL already ending with /webhook/modelriver', () => {
|
|
17
|
+
expect(normalizeWebhookUrl('http://localhost:4000/webhook/modelriver'))
|
|
18
|
+
.toBe('http://localhost:4000/webhook/modelriver');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('should handle trailing slashes', () => {
|
|
22
|
+
expect(normalizeWebhookUrl('http://localhost:4000/'))
|
|
23
|
+
.toBe('http://localhost:4000/webhook/modelriver');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should handle URLs with port', () => {
|
|
27
|
+
expect(normalizeWebhookUrl('http://localhost:3001'))
|
|
28
|
+
.toBe('http://localhost:3001/webhook/modelriver');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should handle https URLs', () => {
|
|
32
|
+
expect(normalizeWebhookUrl('https://myserver.com'))
|
|
33
|
+
.toBe('https://myserver.com/webhook/modelriver');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should return empty for falsy input', () => {
|
|
37
|
+
expect(normalizeWebhookUrl('')).toBeFalsy();
|
|
38
|
+
expect(normalizeWebhookUrl(null)).toBeFalsy();
|
|
39
|
+
expect(normalizeWebhookUrl(undefined)).toBeFalsy();
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('extractPort', () => {
|
|
44
|
+
it('should extract port from URL with explicit port', () => {
|
|
45
|
+
expect(extractPort('http://localhost:4000')).toBe(4000);
|
|
46
|
+
expect(extractPort('http://localhost:3001/webhook')).toBe(3001);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('should return 80 for http without port', () => {
|
|
50
|
+
expect(extractPort('http://example.com')).toBe(80);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('should return 443 for https without port', () => {
|
|
54
|
+
expect(extractPort('https://example.com')).toBe(443);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should return null for invalid URLs', () => {
|
|
58
|
+
expect(extractPort('not-a-url')).toBeNull();
|
|
59
|
+
expect(extractPort('')).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
describe('isValidApiKeyFormat', () => {
|
|
64
|
+
it('should accept mr_live_ prefixed keys', () => {
|
|
65
|
+
expect(isValidApiKeyFormat('mr_live_abc123xyz')).toBe(true);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('should accept mr_test_ prefixed keys', () => {
|
|
69
|
+
expect(isValidApiKeyFormat('mr_test_abc123xyz')).toBe(true);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('should reject keys without valid prefix', () => {
|
|
73
|
+
expect(isValidApiKeyFormat('invalid_key')).toBe(false);
|
|
74
|
+
expect(isValidApiKeyFormat('mr_invalid_key')).toBe(false);
|
|
75
|
+
expect(isValidApiKeyFormat('abc123')).toBe(false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('should reject empty or falsy keys', () => {
|
|
79
|
+
expect(isValidApiKeyFormat('')).toBe(false);
|
|
80
|
+
expect(isValidApiKeyFormat(null)).toBe(false);
|
|
81
|
+
expect(isValidApiKeyFormat(undefined)).toBe(false);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
});
|