diff --git a/src/commands/webhooks.ts b/src/commands/webhooks.ts index aa69dc1..ea135c9 100644 --- a/src/commands/webhooks.ts +++ b/src/commands/webhooks.ts @@ -71,16 +71,14 @@ export const webhooksCommand = (program: Command) => { streamType: 'webhook_event', }) - if (!rootOpts.json) { - const whsMessage = session.webhook_secret - ? ` Your webhook signing secret is ${session.webhook_secret}` - : '' + const whsMessage = session.webhook_secret + ? ` Your webhook signing secret is ${session.webhook_secret}` + : '' - info(`Listening for webhooks.${whsMessage}`) - info('Press Ctrl+C to stop.') + info(`Listening for webhooks.${whsMessage}`) + info('Press Ctrl+C to stop.') - hint('') - } + hint('') await listenToRelay({ websocketUrl: session.websocket_url, diff --git a/src/lib/output.ts b/src/lib/output.ts index 5a5bc80..aebb0cd 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -56,10 +56,11 @@ export const _resetColorCache = () => { _colorEnabled = undefined } -const green = (text: string) => (supportsColor() ? `\x1B[32m${text}\x1B[0m` : text) -const yellow = (text: string) => (supportsColor() ? `\x1B[33m${text}\x1B[0m` : text) -const red = (text: string) => (supportsColor() ? `\x1B[31m${text}\x1B[0m` : text) -const dim = (text: string) => (supportsColor() ? `\x1B[2m${text}\x1B[0m` : text) +export const green = (text: string) => (supportsColor() ? `\x1B[32m${text}\x1B[0m` : text) +export const yellow = (text: string) => (supportsColor() ? `\x1B[33m${text}\x1B[0m` : text) +export const red = (text: string) => (supportsColor() ? `\x1B[31m${text}\x1B[0m` : text) +export const dim = (text: string) => (supportsColor() ? `\x1B[2m${text}\x1B[0m` : text) +export const bold = (text: string) => (supportsColor() ? `\x1B[1m${text}\x1B[0m` : text) const STATUS_COLORS = { succeeded: green, diff --git a/src/lib/webhooks/__tests__/handlers.test.ts b/src/lib/webhooks/__tests__/handlers.test.ts index d6f5423..fe9fca8 100644 --- a/src/lib/webhooks/__tests__/handlers.test.ts +++ b/src/lib/webhooks/__tests__/handlers.test.ts @@ -1,22 +1,35 @@ import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' -import { printJson } from '../../output.js' +import { log, printJson } from '../../output.js' import { createWebhookRelayHandlers } from '../handlers.js' vi.mock('../../output.js', () => ({ + bold: vi.fn((text: string) => text), + dim: vi.fn((text: string) => text), + green: vi.fn((text: string) => text), + log: vi.fn(), printJson: vi.fn(), + red: vi.fn((text: string) => text), + yellow: vi.fn((text: string) => text), })) +const webhookEvent = { + id: 'evt_123', + type: 'payment.succeeded', + created_at: '2026-05-11T14:52:12Z', +} + describe('webhook relay handlers', () => { beforeEach(() => { vi.clearAllMocks() }) afterEach(() => { + vi.useRealTimers() vi.unstubAllGlobals() }) - test('pretty-prints the full event for webhook messages', async () => { - const event = JSON.stringify({ id: 'evt_123' }) + test('prints a compact line for webhook messages', async () => { + const event = JSON.stringify(webhookEvent) const message = { type: 'webhook_event', event, @@ -27,15 +40,15 @@ describe('webhook relay handlers', () => { await createWebhookRelayHandlers({}).webhook_event!(message) - expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' }) + expect(log).toHaveBeenCalledWith('2026-05-11 14:52:12 --> payment.succeeded [evt_123]') }) - test('prints the full relay message in JSON mode', async () => { + test('prints the full event in JSON mode', async () => { const message = { type: 'webhook_event', id: 'wem_123', status: 'pending', - event: JSON.stringify({ id: 'evt_123' }), + event: JSON.stringify(webhookEvent), signature: 'test_signature', event_type: 'payment_intent.succeeded', timestamp: 1_234, @@ -43,13 +56,16 @@ describe('webhook relay handlers', () => { await createWebhookRelayHandlers({ json: true }).webhook_event!(message) - expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' }) + expect(printJson).toHaveBeenCalledWith(webhookEvent) }) test('forwards the raw event with the webhook signature', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-05-11T14:52:13Z')) const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })) vi.stubGlobal('fetch', fetchMock) - const event = JSON.stringify({ id: 'evt_123' }) + const event = JSON.stringify(webhookEvent) + const forwardTo = 'https://webhook.site/828b463d-c4a4-4f6b-a450-7c2cdb575d63' const message = { type: 'webhook_event', event, @@ -59,10 +75,10 @@ describe('webhook relay handlers', () => { } as const await createWebhookRelayHandlers({ - forwardTo: 'https://example.test/webhooks', + forwardTo, }).webhook_event!(message) - expect(fetchMock).toHaveBeenCalledWith('https://example.test/webhooks', { + expect(fetchMock).toHaveBeenCalledWith(forwardTo, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -70,11 +86,14 @@ describe('webhook relay handlers', () => { }, body: event, }) - expect(printJson).toHaveBeenCalledWith({ id: 'evt_123' }) + expect(log).toHaveBeenCalledWith('2026-05-11 14:52:12 --> payment.succeeded [evt_123]') + expect(log).toHaveBeenCalledWith( + '2026-05-11 14:52:13 <-- [204] POST https://webhook.site/828b463d-c4a4-4f6b-a450-7c2cdb575d63 [evt_123]', + ) }) test('handles events included in the event filter', async () => { - const event = JSON.stringify({ id: 'evt_123', type: 'payment.succeeded' }) + const event = JSON.stringify(webhookEvent) const message = { type: 'webhook_event', event, @@ -85,10 +104,7 @@ describe('webhook relay handlers', () => { await createWebhookRelayHandlers({ events: ['payment.succeeded'] }).webhook_event!(message) - expect(printJson).toHaveBeenCalledWith({ - id: 'evt_123', - type: 'payment.succeeded', - }) + expect(log).toHaveBeenCalledWith('2026-05-11 14:52:12 --> payment.succeeded [evt_123]') }) test('skips events not included in the event filter', async () => { @@ -96,7 +112,7 @@ describe('webhook relay handlers', () => { vi.stubGlobal('fetch', fetchMock) const message = { type: 'webhook_event', - event: JSON.stringify({ id: 'evt_123', type: 'payment.failed' }), + event: JSON.stringify({ ...webhookEvent, type: 'payment.failed' }), signature: 'test_signature', event_type: 'payment.failed', timestamp: 1_234, @@ -107,7 +123,7 @@ describe('webhook relay handlers', () => { forwardTo: 'https://example.test/webhooks', }).webhook_event!(message) - expect(printJson).not.toHaveBeenCalled() + expect(log).not.toHaveBeenCalled() expect(fetchMock).not.toHaveBeenCalled() }) @@ -122,19 +138,7 @@ describe('webhook relay handlers', () => { await createWebhookRelayHandlers({ events: ['payment.succeeded'] }).webhook_event!(message) - expect(printJson).not.toHaveBeenCalled() - }) - - test('rejects webhook event payloads that are not records', async () => { - await expect( - createWebhookRelayHandlers({}).webhook_event!({ - type: 'webhook_event', - event: JSON.stringify('evt_123'), - signature: 'test_signature', - event_type: 'payment_intent.succeeded', - timestamp: 1_234, - }), - ).rejects.toThrow('Invalid webhook event payload') + expect(log).not.toHaveBeenCalled() }) test('rejects malformed webhook messages', async () => { diff --git a/src/lib/webhooks/handlers.ts b/src/lib/webhooks/handlers.ts index 9788f24..e76f156 100644 --- a/src/lib/webhooks/handlers.ts +++ b/src/lib/webhooks/handlers.ts @@ -1,6 +1,6 @@ import type { HandledRelayMessage, RelayMessageHandlers } from '../action-cable.js' import * as z from 'zod/mini' -import { printJson } from '../output.js' +import { bold, dim, green, log, printJson, red, yellow } from '../output.js' const webhookEventMessageSchema = z.object({ type: z.literal('webhook_event'), @@ -21,6 +21,8 @@ type WebhookRelayOptions = { events?: string[] } +const reformatDate = (isoDate: string) => isoDate.replace('T', ' ').slice(0, 19) + const forwardWebhookEvent = async ( url: string, data: z.infer, @@ -34,18 +36,64 @@ const forwardWebhookEvent = async ( body: data.event, }) - if (!response.ok) { - throw new Error(`Failed to forward webhook event: ${response.status} ${response.statusText}`) + return { + statusCode: response.status, + timestamp: new Date().toISOString(), } } +const WebhookEventSchema = z.looseObject({ + id: z.string(), + type: z.string(), + created_at: z.string(), +}) + +type WebhookEvent = z.infer + const parseWebhookEvent = (event: string) => { - const eventResult = z.record(z.string(), z.unknown()).safeParse(JSON.parse(event)) - if (!eventResult.success) { - throw new Error(`Invalid webhook event payload: ${z.prettifyError(eventResult.error)}`) + const eventResult = WebhookEventSchema.safeParse(JSON.parse(event)) + + return eventResult.success ? eventResult.data : undefined +} + +const displayWebhookEvent = (event: WebhookEvent, options: { json: boolean | undefined }) => { + if (options.json) { + printJson(event) + log('\n') + return } - return eventResult.data + log(`${dim(reformatDate(event.created_at))} --> ${bold(event.type)} [${event.id}]`) +} + +const colorStatusCode = (statusCode: number) => { + if (statusCode >= 200 && statusCode < 300) { + return green(String(statusCode)) + } + if (statusCode >= 400) { + return red(String(statusCode)) + } + return yellow(String(statusCode)) +} + +const displayForwardResult = ( + result: { + timestamp: string + statusCode: number + method: string + url: string + event: string + }, + options: { json: boolean | undefined }, +) => { + const timestamp = dim(reformatDate(result.timestamp)) + const code = bold(colorStatusCode(result.statusCode)) + + log(`${timestamp} <-- [${code}] ${result.method} ${result.url} [${result.event}]`) + + if (options.json) { + log('\n') + } } export const handleWebhookEvent: WebhookRelayHandler = async (message, options) => { @@ -56,19 +104,24 @@ export const handleWebhookEvent: WebhookRelayHandler = async (message, options) const parsed = parseWebhookEvent(result.data.event) - if ( - options.events && - (!('type' in parsed) || - typeof parsed.type !== 'string' || - !options.events.includes(parsed.type)) - ) { + if (!parsed || (options.events && !options.events.includes(parsed.type))) { return } - printJson(parsed) + displayWebhookEvent(parsed, { json: options.json }) if (options.forwardTo) { - await forwardWebhookEvent(options.forwardTo, result.data) + const { statusCode, timestamp } = await forwardWebhookEvent(options.forwardTo, result.data) + displayForwardResult( + { + timestamp, + statusCode, + method: 'POST', + url: options.forwardTo, + event: parsed.id, + }, + { json: options.json }, + ) } }