diff --git a/src/RequestWrapper.ts b/src/RequestWrapper.ts index 1b8db31..7075790 100644 --- a/src/RequestWrapper.ts +++ b/src/RequestWrapper.ts @@ -17,6 +17,7 @@ import { RetryConfig, } from './types.js'; import { handleResponse } from './coreCommon.js'; +import { setWebhookUserAgent } from './resources/webhook/response.js'; import { Buffer } from 'node:buffer'; export class RequestWrapper { @@ -128,6 +129,7 @@ export class RequestWrapper { : 'application/x-www-form-urlencoded; charset=utf-8'; const userAgent = `Chargebee-NodeJs-Client ${env.clientVersion}${env.userAgentSuffix ? ';' + env.userAgentSuffix : ''}`; + setWebhookUserAgent(userAgent); extend(true, requestHeaders, { Authorization: diff --git a/src/resources/webhook/handler.ts b/src/resources/webhook/handler.ts index 11fbcab..f978ba2 100644 --- a/src/resources/webhook/handler.ts +++ b/src/resources/webhook/handler.ts @@ -8,6 +8,7 @@ import { WebhookPayloadValidationError, WebhookPayloadParseError, } from './errors.js'; +import { setResponseHeader, webhookUserAgent } from './response.js'; export { WebhookEventType, WebhookContentType }; export { @@ -511,6 +512,15 @@ export class WebhookHandler< ); } + try { + setResponseHeader(response as any, 'User-Agent', webhookUserAgent); + } catch (err) { + console.warn( + '[chargebee] Warning: Failed to set webhook user agent:', + err instanceof Error ? err.message : String(err), + ); + } + const context: WebhookContext = { event, request, diff --git a/src/resources/webhook/response.ts b/src/resources/webhook/response.ts new file mode 100644 index 0000000..ffb1733 --- /dev/null +++ b/src/resources/webhook/response.ts @@ -0,0 +1,85 @@ +type NodeLike = { + setHeader(name: string, value: string | readonly string[]): unknown; +}; + +type ReplyLike = { + header?(name: string, value: string): unknown; // e.g. fastify/hono-style + set?(name: string, value: string): unknown; // e.g. koa ctx.set +}; + +type WebHeaderTarget = + | Headers + | { headers: Headers } + | Response; + +type HeaderTarget = WebHeaderTarget | NodeLike | ReplyLike; + +export let webhookUserAgent: string = "Chargebee-NodeJs-Client; Webhook"; + +export function setWebhookUserAgent(userAgent: string) { + webhookUserAgent = userAgent; +} + +/** + * Set headers on the response object + * - Works with: + * - Web: Response, Headers, { headers: Headers } + * - Node/Bun http-like: { setHeader(name, value): any } + * - Some libs (optional): { header(name, value): any } or { set(name, value): any } + * + * No dependency on express/fastify/hono/etc types existing. + */ +export function setResponseHeader( + response: T, + name: string, + value: string +): T { + if (!response) { + return response; + } + + // instance of Headers + if (isHeaders(response)) { + response.set(name, value); + return response; + } + + // Object with .headers (Request-like/Response-like wrappers) + if (hasHeaders(response)) { + response.headers.set(name, value); + return response; + } + + // Node/Bun http-like response: setHeader(name, value) + if (hasSetHeader(response)) { + response.setHeader(name, value); + return response; + } + + // Object with .header(name, value) + if (typeof (response as any).header === "function") { + (response as any).header(name, value); + return response; + } + // Context-like object with .set(name, value) + if (typeof (response as any).set === "function") { + (response as any).set(name, value); + return response; + } + + return response; +} + +function isHeaders(x: unknown): x is Headers { + return typeof x === "object" && x !== null && typeof (x as any).set === "function" + && typeof (x as any).append === "function" + && typeof (x as any).get === "function"; +} + +function hasHeaders(x: unknown): x is { headers: Headers } { + return typeof x === "object" && x !== null && isHeaders((x as any).headers); +} + +function hasSetHeader(x: unknown): x is NodeLike { + return typeof x === "object" && x !== null && typeof (x as any).setHeader === "function"; +} diff --git a/test/webhook.test.ts b/test/webhook.test.ts index da1f11c..5baf111 100644 --- a/test/webhook.test.ts +++ b/test/webhook.test.ts @@ -2,6 +2,7 @@ import { expect } from 'chai'; import { WebhookHandler, createDefaultHandler } from '../src/resources/webhook/handler.js'; import { basicAuthValidator } from '../src/resources/webhook/auth.js'; import { CreateChargebee } from '../src/createChargebee.js'; +import { setResponseHeader, setWebhookUserAgent, webhookUserAgent } from '../src/resources/webhook/response.js'; // Mock HTTP client for Chargebee instance const mockHttpClient = { @@ -828,3 +829,70 @@ describe('Webhook Auth Warnings', () => { expect(eventProcessed).to.be.true; }); }); + +describe('Webhook User Agent', () => { + it('should set the webhook user agent', () => { + setWebhookUserAgent('Custom-User-Agent'); + expect(webhookUserAgent).to.equal('Custom-User-Agent'); + }); + + it('should set webhook user agent for Response object', async () => { + const response = new Response('{}'); + setWebhookUserAgent('Custom-User-Agent'); + setResponseHeader(response, 'User-Agent', webhookUserAgent); + + expect(response.headers.get('User-Agent')).to.equal('Custom-User-Agent'); + }); + + it('should set webhook user agent for Headers object', async () => { + const headers = new Headers([['Content-Type', 'application/json']]); + setWebhookUserAgent('Custom-User-Agent'); + setResponseHeader(headers, 'User-Agent', webhookUserAgent); + + expect(headers.get('Content-Type')).to.equal('application/json'); + expect(headers.get('User-Agent')).to.equal('Custom-User-Agent'); + }); + + it('should set webhook user agent for { headers: Headers } object', async () => { + const headers = { headers: new Headers([['Content-Type', 'application/json']]) }; + setWebhookUserAgent('Custom-User-Agent'); + setResponseHeader(headers, 'User-Agent', webhookUserAgent); + + expect(headers.headers.get('Content-Type')).to.equal('application/json'); + expect(headers.headers.get('User-Agent')).to.equal('Custom-User-Agent'); + }); + + it('should set webhook user agent for Node/Bun http-like response object', async () => { + const response = { setHeader: (name: string, value: string) => { + expect(name).to.equal('User-Agent'); + expect(value).to.equal('Custom-User-Agent'); + } }; + setWebhookUserAgent('Custom-User-Agent'); + setResponseHeader(response, 'User-Agent', webhookUserAgent); + }); + + it('should set webhook user agent for Context-like object', async () => { + const context = { set: (name: string, value: string) => { + expect(name).to.equal('User-Agent'); + expect(value).to.equal('Custom-User-Agent'); + } }; + setWebhookUserAgent('Custom-User-Agent'); + setResponseHeader(context, 'User-Agent', webhookUserAgent); + }); + + it('should set webhook user agent for Reply-like object', async () => { + const reply = { header: (name: string, value: string) => { + expect(name).to.equal('User-Agent'); + expect(value).to.equal('Custom-User-Agent'); + } }; + setWebhookUserAgent('Custom-User-Agent'); + setResponseHeader(reply, 'User-Agent', webhookUserAgent); + }); + + it('should not throw error for unknown object', async () => { + const response: any = null; + setWebhookUserAgent('Custom-User-Agent'); + setResponseHeader(response, 'User-Agent', webhookUserAgent); + expect(response).to.be.null; + }); +}); \ No newline at end of file