@fazetitans/fscopy 1.4.0 → 1.5.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.
@@ -1,5 +1,11 @@
1
1
  import type { Stats } from '../types.js';
2
2
  import type { Output } from '../utils/output.js';
3
+ import {
4
+ WEBHOOK_TIMEOUT_MS,
5
+ WEBHOOK_MAX_PAYLOAD_BYTES,
6
+ WEBHOOK_MAX_RETRIES,
7
+ WEBHOOK_RETRY_DELAY_MS,
8
+ } from '../constants.js';
3
9
 
4
10
  export interface WebhookPayload {
5
11
  source: string;
@@ -22,12 +28,21 @@ export function detectWebhookType(url: string): 'slack' | 'discord' | 'custom' {
22
28
  return 'custom';
23
29
  }
24
30
 
25
- export function validateWebhookUrl(url: string): { valid: boolean; warning?: string } {
31
+ export function validateWebhookUrl(
32
+ url: string,
33
+ allowHttp: boolean = false
34
+ ): { valid: boolean; warning?: string } {
26
35
  try {
27
36
  const parsed = new URL(url);
28
37
  const isLocalhost = parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1';
29
38
 
30
39
  if (parsed.protocol !== 'https:' && !isLocalhost) {
40
+ if (!allowHttp) {
41
+ return {
42
+ valid: false,
43
+ warning: `Webhook URL uses HTTP instead of HTTPS. Use --allow-http-webhook to allow unencrypted webhooks.`,
44
+ };
45
+ }
31
46
  return {
32
47
  valid: true,
33
48
  warning: `Webhook URL uses HTTP instead of HTTPS. Data will be sent unencrypted.`,
@@ -105,33 +120,19 @@ export function formatDiscordPayload(payload: WebhookPayload): Record<string, un
105
120
  };
106
121
  }
107
122
 
108
- export async function sendWebhook(
123
+ async function attemptWebhookSend(
109
124
  webhookUrl: string,
110
- payload: WebhookPayload,
125
+ bodyJson: string,
111
126
  output: Output
112
- ): Promise<void> {
113
- const webhookType = detectWebhookType(webhookUrl);
114
-
115
- let body: Record<string, unknown>;
116
- switch (webhookType) {
117
- case 'slack':
118
- body = formatSlackPayload(payload);
119
- break;
120
- case 'discord':
121
- body = formatDiscordPayload(payload);
122
- break;
123
- default:
124
- body = payload as unknown as Record<string, unknown>;
125
- }
127
+ ): Promise<boolean> {
128
+ const controller = new AbortController();
129
+ const timeout = setTimeout(() => controller.abort(), WEBHOOK_TIMEOUT_MS);
126
130
 
127
131
  try {
128
- const controller = new AbortController();
129
- const timeout = setTimeout(() => controller.abort(), 30000);
130
-
131
132
  const response = await fetch(webhookUrl, {
132
133
  method: 'POST',
133
134
  headers: { 'Content-Type': 'application/json' },
134
- body: JSON.stringify(body),
135
+ body: bodyJson,
135
136
  signal: controller.signal,
136
137
  });
137
138
 
@@ -142,7 +143,6 @@ export async function sendWebhook(
142
143
  const statusCode = response.status;
143
144
 
144
145
  if (statusCode >= 400 && statusCode < 500) {
145
- // Client error - likely bad URL or payload format
146
146
  output.logError(`Webhook client error (${statusCode})`, {
147
147
  url: webhookUrl,
148
148
  status: statusCode,
@@ -151,36 +151,119 @@ export async function sendWebhook(
151
151
  output.warn(
152
152
  `⚠️ Webhook failed (HTTP ${statusCode}): Check webhook URL or payload format`
153
153
  );
154
- } else if (statusCode >= 500) {
155
- // Server error - retry might help
154
+ // Client errors are not retryable
155
+ return false;
156
+ }
157
+
158
+ if (statusCode >= 500) {
156
159
  output.logError(`Webhook server error (${statusCode})`, {
157
160
  url: webhookUrl,
158
161
  status: statusCode,
159
162
  error: errorText,
160
163
  });
161
- output.warn(
162
- `⚠️ Webhook server error (HTTP ${statusCode}): The webhook service may be temporarily unavailable`
163
- );
164
+ throw new Error(`Server error (HTTP ${statusCode})`);
164
165
  }
165
- return;
166
+
167
+ return false;
166
168
  }
167
169
 
168
- output.logInfo(`Webhook sent successfully (${webhookType})`, { url: webhookUrl });
169
- output.info(`📤 Webhook notification sent (${webhookType})`);
170
- } catch (error) {
171
- const err = error as Error;
172
-
173
- if (err.name === 'AbortError') {
174
- output.logError('Webhook timeout after 30s', { url: webhookUrl });
175
- output.warn('⚠️ Webhook request timed out after 30 seconds');
176
- } else if (err.message.includes('ECONNREFUSED') || err.message.includes('ENOTFOUND')) {
177
- output.logError(`Webhook connection failed: ${err.message}`, { url: webhookUrl });
178
- output.warn(
179
- `⚠️ Webhook connection failed: Unable to reach ${new URL(webhookUrl).hostname}`
180
- );
181
- } else {
182
- output.logError(`Failed to send webhook: ${err.message}`, { url: webhookUrl });
183
- output.warn(`⚠️ Failed to send webhook: ${err.message}`);
170
+ return true;
171
+ } finally {
172
+ clearTimeout(timeout);
173
+ }
174
+ }
175
+
176
+ export async function sendWebhook(
177
+ webhookUrl: string,
178
+ payload: WebhookPayload,
179
+ output: Output
180
+ ): Promise<boolean> {
181
+ const webhookType = detectWebhookType(webhookUrl);
182
+
183
+ let body: Record<string, unknown>;
184
+ switch (webhookType) {
185
+ case 'slack':
186
+ body = formatSlackPayload(payload);
187
+ break;
188
+ case 'discord':
189
+ body = formatDiscordPayload(payload);
190
+ break;
191
+ default:
192
+ body = { ...payload };
193
+ }
194
+
195
+ const bodyJson = JSON.stringify(body);
196
+
197
+ // Validate payload size
198
+ const payloadSize = new TextEncoder().encode(bodyJson).length;
199
+ if (payloadSize > WEBHOOK_MAX_PAYLOAD_BYTES) {
200
+ output.logError('Webhook payload too large', {
201
+ url: webhookUrl,
202
+ size: payloadSize,
203
+ limit: WEBHOOK_MAX_PAYLOAD_BYTES,
204
+ });
205
+ output.warn(
206
+ `⚠️ Webhook payload too large (${Math.round(payloadSize / 1024)}KB > ${Math.round(WEBHOOK_MAX_PAYLOAD_BYTES / 1024)}KB limit)`
207
+ );
208
+ return false;
209
+ }
210
+
211
+ // Retry loop for server errors and network failures
212
+ for (let attempt = 0; attempt <= WEBHOOK_MAX_RETRIES; attempt++) {
213
+ try {
214
+ const success = await attemptWebhookSend(webhookUrl, bodyJson, output);
215
+ if (success) {
216
+ output.logInfo(`Webhook sent successfully (${webhookType})`, { url: webhookUrl });
217
+ output.info(`📤 Webhook notification sent (${webhookType})`);
218
+ return true;
219
+ }
220
+ // Client error (4xx) - don't retry
221
+ return false;
222
+ } catch (error) {
223
+ const err = error as Error;
224
+ const isLastAttempt = attempt === WEBHOOK_MAX_RETRIES;
225
+
226
+ if (err.name === 'AbortError') {
227
+ output.logError(`Webhook timeout after ${WEBHOOK_TIMEOUT_MS / 1000}s`, {
228
+ url: webhookUrl,
229
+ attempt: attempt + 1,
230
+ });
231
+ if (isLastAttempt) {
232
+ output.warn(
233
+ `⚠️ Webhook request timed out after ${WEBHOOK_TIMEOUT_MS / 1000} seconds`
234
+ );
235
+ return false;
236
+ }
237
+ } else if (err.message.includes('ECONNREFUSED') || err.message.includes('ENOTFOUND')) {
238
+ output.logError(`Webhook connection failed: ${err.message}`, {
239
+ url: webhookUrl,
240
+ attempt: attempt + 1,
241
+ });
242
+ if (isLastAttempt) {
243
+ output.warn(
244
+ `⚠️ Webhook connection failed: Unable to reach ${new URL(webhookUrl).hostname}`
245
+ );
246
+ return false;
247
+ }
248
+ } else {
249
+ output.logError(`Failed to send webhook: ${err.message}`, {
250
+ url: webhookUrl,
251
+ attempt: attempt + 1,
252
+ });
253
+ if (isLastAttempt) {
254
+ output.warn(`⚠️ Failed to send webhook: ${err.message}`);
255
+ return false;
256
+ }
257
+ }
258
+
259
+ // Wait before retrying
260
+ const delay = WEBHOOK_RETRY_DELAY_MS * Math.pow(2, attempt);
261
+ await new Promise((resolve) => setTimeout(resolve, delay));
262
+ output.logInfo(`Retrying webhook (attempt ${attempt + 2}/${WEBHOOK_MAX_RETRIES + 1})`, {
263
+ url: webhookUrl,
264
+ });
184
265
  }
185
266
  }
267
+
268
+ return false;
186
269
  }