From ffe7ed76a0ea3debdb21fea7293bb4875411f3e3 Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Fri, 6 Feb 2026 10:56:38 -0700 Subject: [PATCH 1/4] Add an option for headers that are not multi-value --- src/ClientBuilder.js | 21 ++++++++++++++++++++- src/CustomHeaderSender.ts | 21 +++++++++++++++++++-- tests/test_CustomHeaderSender.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 3 deletions(-) diff --git a/src/ClientBuilder.js b/src/ClientBuilder.js index d4ae9d5..e73d945 100644 --- a/src/ClientBuilder.js +++ b/src/ClientBuilder.js @@ -50,6 +50,7 @@ class ClientBuilder { this.baseUrl = undefined; this.proxy = undefined; this.customHeaders = {}; + this.appendHeaders = {}; this.debug = undefined; this.licenses = []; this.customQueries = new Map(); @@ -133,6 +134,24 @@ class ClientBuilder { return this; } + /** + * Appends the provided value to the existing header value using the specified separator, + * rather than replacing it. This is useful for single-value headers like User-Agent. + * @param key The header name. + * @param value The value to append. + * @param separator The separator to use when joining values. + * @return ClientBuilder this to accommodate method chaining. + */ + withAppendedHeader(key, value, separator) { + this.appendHeaders[key] = separator; + if (this.customHeaders[key]) { + this.customHeaders[key] += separator + value; + } else { + this.customHeaders[key] = value; + } + return this; + } + /** * Enables debug mode, which will print information about the HTTP request and response to console.log * @return ClientBuilder this to accommodate method chaining. @@ -216,7 +235,7 @@ class ClientBuilder { const retrySender = new RetrySender(this.maxRetries, signingSender, new Sleeper()); agentSender = new AgentSender(retrySender); } - const customHeaderSender = new CustomHeaderSender(agentSender, this.customHeaders); + const customHeaderSender = new CustomHeaderSender(agentSender, this.customHeaders, this.appendHeaders); const baseUrlSender = new BaseUrlSender(customHeaderSender, this.baseUrl); const licenseSender = new LicenseSender(baseUrlSender, this.licenses); const customQuerySender = new CustomQuerySender(licenseSender, this.customQueries); diff --git a/src/CustomHeaderSender.ts b/src/CustomHeaderSender.ts index 33b7110..69c32a4 100644 --- a/src/CustomHeaderSender.ts +++ b/src/CustomHeaderSender.ts @@ -3,15 +3,32 @@ import { Request, Response, Sender } from "./types"; export default class CustomHeaderSender { private sender: Sender; private customHeaders: Record; + private appendHeaders: Record; - constructor(innerSender: Sender, customHeaders: Record) { + constructor( + innerSender: Sender, + customHeaders: Record, + appendHeaders: Record = {}, + ) { this.sender = innerSender; this.customHeaders = customHeaders; + this.appendHeaders = appendHeaders; } send(request: Request): Promise { for (let key in this.customHeaders) { - request.headers[key] = this.customHeaders[key]; + if (key in this.appendHeaders) { + const separator = this.appendHeaders[key]; + const existing = (request.headers as Record)[key]; + if (existing) { + (request.headers as Record)[key] = + existing + separator + this.customHeaders[key]; + } else { + (request.headers as Record)[key] = this.customHeaders[key]; + } + } else { + request.headers[key] = this.customHeaders[key]; + } } return new Promise((resolve, reject) => { diff --git a/tests/test_CustomHeaderSender.ts b/tests/test_CustomHeaderSender.ts index 70cbc13..ac66fcc 100644 --- a/tests/test_CustomHeaderSender.ts +++ b/tests/test_CustomHeaderSender.ts @@ -30,4 +30,34 @@ describe("A custom header sender", function () { expect("b" in mockSender.request!.headers).to.equal(true); expect((mockSender.request!.headers as Record)["b"]).to.equal("2"); }); + + it("appended headers are joined with separator.", function () { + class MockSender implements Sender { + request?: Request; + + send = (request: Request): Promise => { + this.request = request; + return Promise.resolve(new Response(200, {})); + }; + } + + const mockSender = new MockSender(); + const customHeaders = { + "User-Agent": "custom-value", + }; + const appendHeaders = { + "User-Agent": " ", + }; + const customHeaderSender = new CustomHeaderSender(mockSender, customHeaders, appendHeaders); + const request = new Request(undefined, { + "Content-Type": "application/json; charset=utf-8", + "User-Agent": "base-value", + }); + + customHeaderSender.send(request); + + expect((mockSender.request!.headers as Record)["User-Agent"]).to.equal( + "base-value custom-value", + ); + }); }); From 7740aa7ebea81378fb460df89ee9dd5fdd41a665 Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Fri, 6 Feb 2026 16:47:40 -0700 Subject: [PATCH 2/4] Rename appendHeaders to appendHeaderSeparators and add tests Clarify that the map stores separators, not header values. Add test coverage for the no-existing-header path and accumulated appends. Extract shared MockSender to reduce duplication. --- src/ClientBuilder.js | 6 +-- src/CustomHeaderSender.ts | 10 ++--- tests/test_CustomHeaderSender.ts | 69 +++++++++++++++++++++++--------- 3 files changed, 57 insertions(+), 28 deletions(-) diff --git a/src/ClientBuilder.js b/src/ClientBuilder.js index e73d945..703f12a 100644 --- a/src/ClientBuilder.js +++ b/src/ClientBuilder.js @@ -50,7 +50,7 @@ class ClientBuilder { this.baseUrl = undefined; this.proxy = undefined; this.customHeaders = {}; - this.appendHeaders = {}; + this.appendHeaderSeparators = {}; this.debug = undefined; this.licenses = []; this.customQueries = new Map(); @@ -143,7 +143,7 @@ class ClientBuilder { * @return ClientBuilder this to accommodate method chaining. */ withAppendedHeader(key, value, separator) { - this.appendHeaders[key] = separator; + this.appendHeaderSeparators[key] = separator; if (this.customHeaders[key]) { this.customHeaders[key] += separator + value; } else { @@ -235,7 +235,7 @@ class ClientBuilder { const retrySender = new RetrySender(this.maxRetries, signingSender, new Sleeper()); agentSender = new AgentSender(retrySender); } - const customHeaderSender = new CustomHeaderSender(agentSender, this.customHeaders, this.appendHeaders); + const customHeaderSender = new CustomHeaderSender(agentSender, this.customHeaders, this.appendHeaderSeparators); const baseUrlSender = new BaseUrlSender(customHeaderSender, this.baseUrl); const licenseSender = new LicenseSender(baseUrlSender, this.licenses); const customQuerySender = new CustomQuerySender(licenseSender, this.customQueries); diff --git a/src/CustomHeaderSender.ts b/src/CustomHeaderSender.ts index 69c32a4..298036d 100644 --- a/src/CustomHeaderSender.ts +++ b/src/CustomHeaderSender.ts @@ -3,22 +3,22 @@ import { Request, Response, Sender } from "./types"; export default class CustomHeaderSender { private sender: Sender; private customHeaders: Record; - private appendHeaders: Record; + private appendHeaderSeparators: Record; constructor( innerSender: Sender, customHeaders: Record, - appendHeaders: Record = {}, + appendHeaderSeparators: Record = {}, ) { this.sender = innerSender; this.customHeaders = customHeaders; - this.appendHeaders = appendHeaders; + this.appendHeaderSeparators = appendHeaderSeparators; } send(request: Request): Promise { for (let key in this.customHeaders) { - if (key in this.appendHeaders) { - const separator = this.appendHeaders[key]; + if (key in this.appendHeaderSeparators) { + const separator = this.appendHeaderSeparators[key]; const existing = (request.headers as Record)[key]; if (existing) { (request.headers as Record)[key] = diff --git a/tests/test_CustomHeaderSender.ts b/tests/test_CustomHeaderSender.ts index ac66fcc..96f1368 100644 --- a/tests/test_CustomHeaderSender.ts +++ b/tests/test_CustomHeaderSender.ts @@ -4,17 +4,17 @@ import Request from "../src/Request.js"; import Response from "../src/Response.js"; import { Sender } from "../src/types"; -describe("A custom header sender", function () { - it("adds custom headers to the request.", function () { - class MockSender implements Sender { - request?: Request; +class MockSender implements Sender { + request?: Request; - send = (request: Request): Promise => { - this.request = request; - return Promise.resolve(new Response(200, {})); - }; - } + send = (request: Request): Promise => { + this.request = request; + return Promise.resolve(new Response(200, {})); + }; +} +describe("A custom header sender", function () { + it("adds custom headers to the request.", function () { const mockSender = new MockSender(); const customHeaders = { a: "1", @@ -32,23 +32,14 @@ describe("A custom header sender", function () { }); it("appended headers are joined with separator.", function () { - class MockSender implements Sender { - request?: Request; - - send = (request: Request): Promise => { - this.request = request; - return Promise.resolve(new Response(200, {})); - }; - } - const mockSender = new MockSender(); const customHeaders = { "User-Agent": "custom-value", }; - const appendHeaders = { + const appendHeaderSeparators = { "User-Agent": " ", }; - const customHeaderSender = new CustomHeaderSender(mockSender, customHeaders, appendHeaders); + const customHeaderSender = new CustomHeaderSender(mockSender, customHeaders, appendHeaderSeparators); const request = new Request(undefined, { "Content-Type": "application/json; charset=utf-8", "User-Agent": "base-value", @@ -60,4 +51,42 @@ describe("A custom header sender", function () { "base-value custom-value", ); }); + + it("appended headers set the value when no existing header is present.", function () { + const mockSender = new MockSender(); + const customHeaders = { + "User-Agent": "custom-value", + }; + const appendHeaderSeparators = { + "User-Agent": " ", + }; + const customHeaderSender = new CustomHeaderSender(mockSender, customHeaders, appendHeaderSeparators); + const request = new Request(); + + customHeaderSender.send(request); + + expect((mockSender.request!.headers as Record)["User-Agent"]).to.equal( + "custom-value", + ); + }); + + it("multiple appended header calls are accumulated.", function () { + const mockSender = new MockSender(); + const customHeaders = { + "User-Agent": "foo bar", + }; + const appendHeaderSeparators = { + "User-Agent": " ", + }; + const customHeaderSender = new CustomHeaderSender(mockSender, customHeaders, appendHeaderSeparators); + const request = new Request(undefined, { + "User-Agent": "base-value", + }); + + customHeaderSender.send(request); + + expect((mockSender.request!.headers as Record)["User-Agent"]).to.equal( + "base-value foo bar", + ); + }); }); From e7a9235f509a81dd27cd367f931d01cd18524538 Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Fri, 6 Feb 2026 16:54:20 -0700 Subject: [PATCH 3/4] Store append headers separately to consolidate join logic in CustomHeaderSender Move value accumulation out of ClientBuilder and into CustomHeaderSender so joining logic lives in one place. Storing append headers in a separate field also prevents withCustomHeaders from clobbering appended values. Update the accumulation test to exercise multiple discrete values. --- src/ClientBuilder.js | 11 +++++------ src/CustomHeaderSender.ts | 33 +++++++++++++++++++------------- tests/test_CustomHeaderSender.ts | 31 +++++++++++------------------- 3 files changed, 36 insertions(+), 39 deletions(-) diff --git a/src/ClientBuilder.js b/src/ClientBuilder.js index 703f12a..c994558 100644 --- a/src/ClientBuilder.js +++ b/src/ClientBuilder.js @@ -50,7 +50,7 @@ class ClientBuilder { this.baseUrl = undefined; this.proxy = undefined; this.customHeaders = {}; - this.appendHeaderSeparators = {}; + this.appendHeaders = {}; this.debug = undefined; this.licenses = []; this.customQueries = new Map(); @@ -143,11 +143,10 @@ class ClientBuilder { * @return ClientBuilder this to accommodate method chaining. */ withAppendedHeader(key, value, separator) { - this.appendHeaderSeparators[key] = separator; - if (this.customHeaders[key]) { - this.customHeaders[key] += separator + value; + if (this.appendHeaders[key]) { + this.appendHeaders[key].values.push(value); } else { - this.customHeaders[key] = value; + this.appendHeaders[key] = { values: [value], separator }; } return this; } @@ -235,7 +234,7 @@ class ClientBuilder { const retrySender = new RetrySender(this.maxRetries, signingSender, new Sleeper()); agentSender = new AgentSender(retrySender); } - const customHeaderSender = new CustomHeaderSender(agentSender, this.customHeaders, this.appendHeaderSeparators); + const customHeaderSender = new CustomHeaderSender(agentSender, this.customHeaders, this.appendHeaders); const baseUrlSender = new BaseUrlSender(customHeaderSender, this.baseUrl); const licenseSender = new LicenseSender(baseUrlSender, this.licenses); const customQuerySender = new CustomQuerySender(licenseSender, this.customQueries); diff --git a/src/CustomHeaderSender.ts b/src/CustomHeaderSender.ts index 298036d..de1999d 100644 --- a/src/CustomHeaderSender.ts +++ b/src/CustomHeaderSender.ts @@ -1,33 +1,40 @@ import { Request, Response, Sender } from "./types"; +export interface AppendHeader { + values: string[]; + separator: string; +} + export default class CustomHeaderSender { private sender: Sender; private customHeaders: Record; - private appendHeaderSeparators: Record; + private appendHeaders: Record; constructor( innerSender: Sender, customHeaders: Record, - appendHeaderSeparators: Record = {}, + appendHeaders: Record = {}, ) { this.sender = innerSender; this.customHeaders = customHeaders; - this.appendHeaderSeparators = appendHeaderSeparators; + this.appendHeaders = appendHeaders; } send(request: Request): Promise { + const headers = request.headers as Record; + for (let key in this.customHeaders) { - if (key in this.appendHeaderSeparators) { - const separator = this.appendHeaderSeparators[key]; - const existing = (request.headers as Record)[key]; - if (existing) { - (request.headers as Record)[key] = - existing + separator + this.customHeaders[key]; - } else { - (request.headers as Record)[key] = this.customHeaders[key]; - } + headers[key] = this.customHeaders[key]; + } + + for (let key in this.appendHeaders) { + const { values, separator } = this.appendHeaders[key]; + const appendValue = values.join(separator); + const existing = headers[key]; + if (existing) { + headers[key] = existing + separator + appendValue; } else { - request.headers[key] = this.customHeaders[key]; + headers[key] = appendValue; } } diff --git a/tests/test_CustomHeaderSender.ts b/tests/test_CustomHeaderSender.ts index 96f1368..2ee2565 100644 --- a/tests/test_CustomHeaderSender.ts +++ b/tests/test_CustomHeaderSender.ts @@ -1,5 +1,5 @@ import { expect } from "chai"; -import CustomHeaderSender from "../src/CustomHeaderSender.js"; +import CustomHeaderSender, { AppendHeader } from "../src/CustomHeaderSender.js"; import Request from "../src/Request.js"; import Response from "../src/Response.js"; import { Sender } from "../src/types"; @@ -33,13 +33,10 @@ describe("A custom header sender", function () { it("appended headers are joined with separator.", function () { const mockSender = new MockSender(); - const customHeaders = { - "User-Agent": "custom-value", - }; - const appendHeaderSeparators = { - "User-Agent": " ", + const appendHeaders: Record = { + "User-Agent": { values: ["custom-value"], separator: " " }, }; - const customHeaderSender = new CustomHeaderSender(mockSender, customHeaders, appendHeaderSeparators); + const customHeaderSender = new CustomHeaderSender(mockSender, {}, appendHeaders); const request = new Request(undefined, { "Content-Type": "application/json; charset=utf-8", "User-Agent": "base-value", @@ -54,13 +51,10 @@ describe("A custom header sender", function () { it("appended headers set the value when no existing header is present.", function () { const mockSender = new MockSender(); - const customHeaders = { - "User-Agent": "custom-value", + const appendHeaders: Record = { + "User-Agent": { values: ["custom-value"], separator: " " }, }; - const appendHeaderSeparators = { - "User-Agent": " ", - }; - const customHeaderSender = new CustomHeaderSender(mockSender, customHeaders, appendHeaderSeparators); + const customHeaderSender = new CustomHeaderSender(mockSender, {}, appendHeaders); const request = new Request(); customHeaderSender.send(request); @@ -70,15 +64,12 @@ describe("A custom header sender", function () { ); }); - it("multiple appended header calls are accumulated.", function () { + it("multiple appended header values are accumulated.", function () { const mockSender = new MockSender(); - const customHeaders = { - "User-Agent": "foo bar", - }; - const appendHeaderSeparators = { - "User-Agent": " ", + const appendHeaders: Record = { + "User-Agent": { values: ["foo", "bar"], separator: " " }, }; - const customHeaderSender = new CustomHeaderSender(mockSender, customHeaders, appendHeaderSeparators); + const customHeaderSender = new CustomHeaderSender(mockSender, {}, appendHeaders); const request = new Request(undefined, { "User-Agent": "base-value", }); From 97002b9ac6108e6327f27d0293e0baf110b4ad31 Mon Sep 17 00:00:00 2001 From: Ryan Cox Date: Fri, 6 Feb 2026 17:04:33 -0700 Subject: [PATCH 4/4] Harden appendHeaders: error on separator conflicts, use Object.entries, add tests --- src/ClientBuilder.js | 6 +++++ src/CustomHeaderSender.ts | 7 +++--- tests/test_CustomHeaderSender.ts | 43 ++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/ClientBuilder.js b/src/ClientBuilder.js index c994558..8fa8b37 100644 --- a/src/ClientBuilder.js +++ b/src/ClientBuilder.js @@ -144,6 +144,12 @@ class ClientBuilder { */ withAppendedHeader(key, value, separator) { if (this.appendHeaders[key]) { + if (this.appendHeaders[key].separator !== separator) { + throw new Error( + `Conflicting separators for appended header "${key}": ` + + `existing "${this.appendHeaders[key].separator}" vs new "${separator}"`, + ); + } this.appendHeaders[key].values.push(value); } else { this.appendHeaders[key] = { values: [value], separator }; diff --git a/src/CustomHeaderSender.ts b/src/CustomHeaderSender.ts index de1999d..5609740 100644 --- a/src/CustomHeaderSender.ts +++ b/src/CustomHeaderSender.ts @@ -23,12 +23,11 @@ export default class CustomHeaderSender { send(request: Request): Promise { const headers = request.headers as Record; - for (let key in this.customHeaders) { - headers[key] = this.customHeaders[key]; + for (const [key, value] of Object.entries(this.customHeaders)) { + headers[key] = value; } - for (let key in this.appendHeaders) { - const { values, separator } = this.appendHeaders[key]; + for (const [key, { values, separator }] of Object.entries(this.appendHeaders)) { const appendValue = values.join(separator); const existing = headers[key]; if (existing) { diff --git a/tests/test_CustomHeaderSender.ts b/tests/test_CustomHeaderSender.ts index 2ee2565..01c7598 100644 --- a/tests/test_CustomHeaderSender.ts +++ b/tests/test_CustomHeaderSender.ts @@ -64,6 +64,24 @@ describe("A custom header sender", function () { ); }); + it("customHeaders value is used as base when appendHeaders targets the same key.", function () { + const mockSender = new MockSender(); + const customHeaders = { "User-Agent": "custom-base" }; + const appendHeaders: Record = { + "User-Agent": { values: ["extra"], separator: " " }, + }; + const customHeaderSender = new CustomHeaderSender(mockSender, customHeaders, appendHeaders); + const request = new Request(undefined, { + "User-Agent": "original", + }); + + customHeaderSender.send(request); + + expect((mockSender.request!.headers as Record)["User-Agent"]).to.equal( + "custom-base extra", + ); + }); + it("multiple appended header values are accumulated.", function () { const mockSender = new MockSender(); const appendHeaders: Record = { @@ -80,4 +98,29 @@ describe("A custom header sender", function () { "base-value foo bar", ); }); + + it("withAppendedHeader throws on conflicting separators for the same key.", function () { + // Test the withAppendedHeader logic directly (ClientBuilder can't be easily + // constructed in tsx/cjs tests due to instanceof interop issues). + const appendHeaders: Record = {}; + const withAppendedHeader = (key: string, value: string, separator: string) => { + if (appendHeaders[key]) { + if (appendHeaders[key].separator !== separator) { + throw new Error( + `Conflicting separators for appended header "${key}": ` + + `existing "${appendHeaders[key].separator}" vs new "${separator}"`, + ); + } + appendHeaders[key].values.push(value); + } else { + appendHeaders[key] = { values: [value], separator }; + } + }; + + withAppendedHeader("User-Agent", "a", " "); + + expect(() => withAppendedHeader("User-Agent", "b", "/")).to.throw( + 'Conflicting separators for appended header "User-Agent"', + ); + }); });