Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/RequestWrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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:
Expand Down
10 changes: 10 additions & 0 deletions src/resources/webhook/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
WebhookPayloadValidationError,
WebhookPayloadParseError,
} from './errors.js';
import { setResponseHeader, webhookUserAgent } from './response.js';

export { WebhookEventType, WebhookContentType };
export {
Expand Down Expand Up @@ -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<ReqT, ResT> = {
event,
request,
Expand Down
85 changes: 85 additions & 0 deletions src/resources/webhook/response.ts
Original file line number Diff line number Diff line change
@@ -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<T extends HeaderTarget>(
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";
}
68 changes: 68 additions & 0 deletions test/webhook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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;
});
});