diff --git a/README.md b/README.md index 759cccf..320e2f3 100644 --- a/README.md +++ b/README.md @@ -216,7 +216,7 @@ npm test # Run tests ## Requirements -- Node.js 18+ +- Node.js 20.12.0+ - A [HitPay account](https://dashboard.hit-pay.com/register) (sandbox available) ## License diff --git a/package-lock.json b/package-lock.json index b0cb01a..94439f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "chalk": "^5.4.1", "cli-table3": "^0.6.5", "commander": "^13.1.0", - "hitpay-mcp": "file:../hitpay-mcp", "localtunnel": "^2.0.2", "ora": "^8.2.0", "qrcode-terminal": "^0.12.0" @@ -30,11 +29,12 @@ "vitest": "^3.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.12.0" } }, "../hitpay-mcp": { "version": "1.1.1", + "extraneous": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.26.0", @@ -2556,10 +2556,6 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, - "node_modules/hitpay-mcp": { - "resolved": "../hitpay-mcp", - "link": true - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", diff --git a/package.json b/package.json index 5f6c4f5..bfcd7a1 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "author": "HitPay", "license": "MIT", "engines": { - "node": ">=18.0.0" + "node": ">=20.12.0" }, "dependencies": { "@inquirer/prompts": "^8.3.2", diff --git a/src/commands/listen.ts b/src/commands/listen.ts index bec4a32..9b111d4 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -7,7 +7,7 @@ import * as output from '../lib/output.js'; import { handleError } from '../lib/errors.js'; import { createWebhookServer } from '../listen/server.js'; import { createTunnel } from '../listen/tunnel.js'; -import { forwardEvent } from '../listen/forwarder.js'; +import { forwardEvent, ForwardResult } from '../listen/forwarder.js'; import { printEvent } from '../listen/printer.js'; export function registerListen(program: Command): void { @@ -15,14 +15,14 @@ export function registerListen(program: Command): void { .command('listen') .description('Listen for webhook events and forward to your local server') .requiredOption('--forward-to ', 'Local URL to forward events to (e.g. http://localhost:3000/webhook)') - .option('--events ', 'Comma-separated event types to listen for') + .option('--events ', 'Events to listen for') .option('--port ', 'Local port for the webhook receiver', '0') .action(async (opts, cmd) => { try { const globalOpts = cmd.parent?.opts() || {}; const client = createClient({ environment: globalOpts.env }); const forwardUrl = opts.forwardTo; - const eventFilter = opts.events ? opts.events.split(',').map((e: string) => e.trim()) : undefined; + const eventFilter = opts.events ? opts.events.map((e: string) => e.trim()) : undefined; let webhookId: string | undefined; let tunnelRef: { close: () => Promise } | undefined; @@ -61,13 +61,15 @@ export function registerListen(program: Command): void { // 1. Start local HTTP server const { start, stop, server } = createWebhookServer(async (event) => { // Filter events if specified - const eventType = String(event.headers['hitpay-event-type'] || event.body.event || ''); - if (eventFilter && !eventFilter.some((f: string) => eventType.includes(f))) { + const eventObject = String(event.headers['hitpay-event-object'] || ''); + const eventType = String(event.headers['hitpay-event-type'] || ''); + const eventName = `${eventObject}.${eventType}`; + if (eventFilter && !eventFilter.includes(eventName)) { return; } // Forward to user's dev server - let result; + let result: ForwardResult | undefined; try { result = await forwardEvent(event, forwardUrl); } catch (err) { @@ -76,6 +78,7 @@ export function registerListen(program: Command): void { // Print event in terminal printEvent(event, result); + return result; }); serverRef = { stop }; @@ -89,10 +92,11 @@ export function registerListen(program: Command): void { // 3. Register webhook with HitPay spinner.text = 'Registering webhook endpoint...'; const webhookBody: Record = { + name: `Testing Webhook for ${forwardUrl}`, url: tunnel.url, }; if (eventFilter) { - webhookBody.events = eventFilter; + webhookBody.event_types = eventFilter; } const webhook = await client.post('/v1/webhook-events', webhookBody); diff --git a/src/listen/forwarder.ts b/src/listen/forwarder.ts index 8854152..89cd1e2 100644 --- a/src/listen/forwarder.ts +++ b/src/listen/forwarder.ts @@ -3,6 +3,7 @@ import type { WebhookEvent } from './server.js'; export interface ForwardResult { status: number; ok: boolean; + message: unknown; duration: number; } @@ -24,9 +25,18 @@ export async function forwardEvent( body: JSON.stringify(event.body), }); + const text = await res.text(); + let message: unknown = text; + try { + message = JSON.parse(text); + } catch { + // Ignore and leave as text + } + return { status: res.status, ok: res.ok, + message, duration: Date.now() - start, }; } diff --git a/src/listen/printer.ts b/src/listen/printer.ts index f00e9f5..343d2b5 100644 --- a/src/listen/printer.ts +++ b/src/listen/printer.ts @@ -4,14 +4,16 @@ import type { ForwardResult } from './forwarder.js'; export function printEvent(event: WebhookEvent, forwardResult?: ForwardResult): void { const time = event.receivedAt.toISOString().split('T')[1].replace('Z', ''); - const eventType = String(event.headers['hitpay-event-type'] || event.body.event || 'unknown'); + const eventType = String(event.headers['hitpay-event-type'] || 'unknown'); + const eventObject = String(event.headers['hitpay-event-object'] || 'unknown'); + const eventName = `${eventObject}.${eventType}`; const status = String(event.body.status || ''); // Event header console.log( chalk.dim(time) + ' ' + - chalk.bold.cyan(eventType) + + chalk.bold.cyan(eventName) + (status ? ` [${statusColor(status)}]` : ''), ); diff --git a/src/listen/server.ts b/src/listen/server.ts index 205aade..972cc95 100644 --- a/src/listen/server.ts +++ b/src/listen/server.ts @@ -1,4 +1,5 @@ import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from 'node:http'; +import { ForwardResult } from './forwarder'; export interface WebhookEvent { headers: Record; @@ -6,7 +7,7 @@ export interface WebhookEvent { receivedAt: Date; } -export type EventHandler = (event: WebhookEvent) => void; +export type EventHandler = (event: WebhookEvent) => Promise | any; export function createWebhookServer(onEvent: EventHandler): { server: ReturnType; @@ -35,17 +36,27 @@ export function createWebhookServer(onEvent: EventHandler): { body = Object.fromEntries(new URLSearchParams(rawBody)); } - onEvent({ + const result = await onEvent({ headers: req.headers as Record, body, receivedAt: new Date(), }); - res.writeHead(200, { 'Content-Type': 'application/json' }); - res.end(JSON.stringify({ success: true })); - } catch { - res.writeHead(500); - res.end('Internal error'); + + if (!result || typeof result !== 'object' || typeof result.status !== 'number') { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true, message: 'Event ignored by filter' })); + return; + } + + const payload = typeof result.message === 'string' ? result.message : JSON.stringify(result.message); + res.writeHead(result.status, { 'Content-Type': 'application/json' }); + res.end(payload); + } catch (err) { + if (!res.headersSent) { + res.writeHead(500); + res.end('Internal error'); + } } }); diff --git a/tests/listen/server.test.ts b/tests/listen/server.test.ts index b8a64c9..f58d748 100644 --- a/tests/listen/server.test.ts +++ b/tests/listen/server.test.ts @@ -42,7 +42,8 @@ describe('Webhook server', () => { method: 'POST', headers: { 'Content-Type': 'application/json', - 'hitpay-event-type': 'charge.created', + 'hitpay-event-object': 'charge', + 'hitpay-event-type': 'created', }, body: JSON.stringify({ amount: '100', currency: 'SGD', status: 'completed' }), }); @@ -50,7 +51,8 @@ describe('Webhook server', () => { expect(res.status).toBe(200); expect(events).toHaveLength(1); expect(events[0].body.amount).toBe('100'); - expect(events[0].headers['hitpay-event-type']).toBe('charge.created'); + expect(events[0].headers['hitpay-event-object']).toBe('charge'); + expect(events[0].headers['hitpay-event-type']).toBe('created'); }); it('handles form-encoded POST body', async () => {